Compare commits
10 Commits
v2.1.0
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce11a13874 | ||
|
|
49ad09efe7 | ||
|
|
963fe062e3 | ||
|
|
3641cb113d | ||
|
|
b55486775e | ||
|
|
af7223b93c | ||
|
|
8940b97cc6 | ||
|
|
423d307125 | ||
|
|
1d9206494a | ||
|
|
217b374789 |
@@ -54,6 +54,9 @@ const (
|
|||||||
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
||||||
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
|
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
|
||||||
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
|
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
|
||||||
|
// Multi-device registry (v2.2.0)
|
||||||
|
prefixDevice = "device:" // device:<x25519_pub> → DeviceRecord JSON
|
||||||
|
prefixDevicesByOwner = "devicesbyowner:" // devicesbyowner:<master_pub>:<x25519_pub> → "" (reverse index for O(k) listing)
|
||||||
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
|
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
|
||||||
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
||||||
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
|
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
|
||||||
@@ -771,6 +774,99 @@ func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64)
|
|||||||
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
|
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DevicesOf returns the active (non-revoked) device records for a master
|
||||||
|
// identity. Sender fan-out reads this to decide how many envelopes to
|
||||||
|
// produce per outgoing message. Empty slice (not error) for identities
|
||||||
|
// with no registry entries yet — caller should fall back to the legacy
|
||||||
|
// single-X25519 path via IdentityInfo.
|
||||||
|
func (c *Chain) DevicesOf(masterPub string) ([]DeviceRecord, error) {
|
||||||
|
var out []DeviceRecord
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
|
||||||
|
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
|
||||||
|
defer it.Close()
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
key := it.Item().KeyCopy(nil)
|
||||||
|
// key layout: devicesbyowner:<master>:<x25519> — take the suffix
|
||||||
|
x25519Pub := string(key[len(prefix):])
|
||||||
|
recItem, err := txn.Get([]byte(prefixDevice + x25519Pub))
|
||||||
|
if err != nil {
|
||||||
|
// Reverse index points to a missing record — stale entry,
|
||||||
|
// skip silently (shouldn't happen unless the DB was tampered
|
||||||
|
// with, but we don't want to fail the whole call).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec DeviceRecord
|
||||||
|
if verr := recItem.Value(func(v []byte) error {
|
||||||
|
return json.Unmarshal(v, &rec)
|
||||||
|
}); verr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.RevokedAt != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// countActiveDevicesForOwner walks the reverse index (entries there are,
|
||||||
|
// by construction, non-revoked — UNLINK_DEVICE deletes the index row).
|
||||||
|
// O(k) where k = active device count, bounded by MaxDevicesPerOwner.
|
||||||
|
func (c *Chain) countActiveDevicesForOwner(txn *badger.Txn, masterPub string) (int, error) {
|
||||||
|
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
|
||||||
|
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
|
||||||
|
defer it.Close()
|
||||||
|
n := 0
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDevicePubKey checks the wire-level format of a device X25519
|
||||||
|
// public key: 64 hex characters (32 bytes). Does NOT check that the key
|
||||||
|
// is a valid curve point — senders learn that at encrypt time, and an
|
||||||
|
// invalid point there just yields garbage ciphertext which the device
|
||||||
|
// can't decrypt (self-punishing).
|
||||||
|
func validateDevicePubKey(hexPub string) error {
|
||||||
|
if len(hexPub) != 64 {
|
||||||
|
return fmt.Errorf("x25519_pub_key must be 64 hex chars (got %d)", len(hexPub))
|
||||||
|
}
|
||||||
|
// Enforce strict lowercase-hex to keep keys canonical across clients —
|
||||||
|
// two clients hashing the same bytes with different letter cases would
|
||||||
|
// produce different registry lookups, which breaks sender fan-out.
|
||||||
|
for _, r := range hexPub {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("x25519_pub_key must be lowercase hex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDeviceName enforces the UX constraint that device labels stay
|
||||||
|
// short and printable — this field gets displayed in Settings → Devices
|
||||||
|
// and has no business carrying newlines, control chars, or 2KB blobs.
|
||||||
|
func validateDeviceName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("device_name is required")
|
||||||
|
}
|
||||||
|
if len(name) > MaxDeviceNameLen {
|
||||||
|
return fmt.Errorf("device_name %d bytes > max %d", len(name), MaxDeviceNameLen)
|
||||||
|
}
|
||||||
|
for _, r := range name {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return fmt.Errorf("device_name contains a control character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Reputation returns the reputation stats for a public key.
|
// Reputation returns the reputation stats for a public key.
|
||||||
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
|
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
|
||||||
var r RepStats
|
var r RepStats
|
||||||
@@ -1261,6 +1357,127 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case EventLinkDevice:
|
||||||
|
// Master Ed25519 (= tx.From) publishes a per-device X25519 pub.
|
||||||
|
// Validation:
|
||||||
|
// 1. Payload well-formed, X25519 pub is hex(32 bytes).
|
||||||
|
// 2. Device name is short + printable.
|
||||||
|
// 3. X25519 pub isn't already registered to a DIFFERENT owner.
|
||||||
|
// 4. Same owner re-linking the same pub is a no-op refresh
|
||||||
|
// (updates device_name / added_at — useful for rename).
|
||||||
|
// 5. Owner has < MaxDevicesPerOwner active (non-revoked) devices.
|
||||||
|
//
|
||||||
|
// Fee: standard tx.Fee is debited from master's balance — cheap
|
||||||
|
// anti-spam. Block validation already enforced `tx.Fee >= MinFee`.
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("LINK_DEVICE debit: %w", err)
|
||||||
|
}
|
||||||
|
var p LinkDevicePayload
|
||||||
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: LINK_DEVICE bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if err := validateDeviceName(p.DeviceName); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
devKey := []byte(prefixDevice + p.X25519PubKey)
|
||||||
|
if item, err := txn.Get(devKey); err == nil {
|
||||||
|
var existing DeviceRecord
|
||||||
|
if verr := item.Value(func(v []byte) error {
|
||||||
|
return json.Unmarshal(v, &existing)
|
||||||
|
}); verr == nil {
|
||||||
|
if existing.Owner != tx.From {
|
||||||
|
return 0, fmt.Errorf("%w: LINK_DEVICE: x25519 pub already linked to a different owner",
|
||||||
|
ErrTxFailed)
|
||||||
|
}
|
||||||
|
// Same owner — rename/refresh path. Keep AddedAt.
|
||||||
|
existing.DeviceName = p.DeviceName
|
||||||
|
existing.RevokedAt = 0 // re-link cancels a previous revoke
|
||||||
|
refreshed, _ := json.Marshal(existing)
|
||||||
|
if err := txn.Set(devKey, refreshed); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Restore reverse index too — UNLINK_DEVICE deletes it, so
|
||||||
|
// a re-link after revoke must recreate it or DevicesOf
|
||||||
|
// will skip the record.
|
||||||
|
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
|
||||||
|
if err := txn.Set(idxKey, []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Count owner's active devices.
|
||||||
|
active, err := c.countActiveDevicesForOwner(txn, tx.From)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("LINK_DEVICE count devices: %w", err)
|
||||||
|
}
|
||||||
|
if active >= MaxDevicesPerOwner {
|
||||||
|
return 0, fmt.Errorf("%w: LINK_DEVICE: owner already has %d devices (max %d)",
|
||||||
|
ErrTxFailed, active, MaxDevicesPerOwner)
|
||||||
|
}
|
||||||
|
rec := DeviceRecord{
|
||||||
|
Owner: tx.From,
|
||||||
|
X25519PubKey: p.X25519PubKey,
|
||||||
|
DeviceName: p.DeviceName,
|
||||||
|
AddedAt: tx.Timestamp.Unix(),
|
||||||
|
}
|
||||||
|
val, _ := json.Marshal(rec)
|
||||||
|
if err := txn.Set(devKey, val); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
|
||||||
|
if err := txn.Set(idxKey, []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventUnlinkDevice:
|
||||||
|
// Revoke (soft-delete) a device record. Mark RevokedAt and keep
|
||||||
|
// the row — clients wake up, notice their own pub is revoked, and
|
||||||
|
// wipe local state. A permanent delete would let a sender that
|
||||||
|
// missed the revoke keep encrypting for the old pub silently.
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("UNLINK_DEVICE debit: %w", err)
|
||||||
|
}
|
||||||
|
var p UnlinkDevicePayload
|
||||||
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: UNLINK_DEVICE bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: UNLINK_DEVICE: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
devKey := []byte(prefixDevice + p.X25519PubKey)
|
||||||
|
item, err := txn.Get(devKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: UNLINK_DEVICE: device not found", ErrTxFailed)
|
||||||
|
}
|
||||||
|
var existing DeviceRecord
|
||||||
|
if verr := item.Value(func(v []byte) error {
|
||||||
|
return json.Unmarshal(v, &existing)
|
||||||
|
}); verr != nil {
|
||||||
|
return 0, fmt.Errorf("UNLINK_DEVICE decode: %w", verr)
|
||||||
|
}
|
||||||
|
if existing.Owner != tx.From {
|
||||||
|
return 0, fmt.Errorf("%w: UNLINK_DEVICE: signer is not the owner of this device",
|
||||||
|
ErrTxFailed)
|
||||||
|
}
|
||||||
|
if existing.RevokedAt != 0 {
|
||||||
|
// Already revoked — idempotent success, don't error.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
existing.RevokedAt = tx.Timestamp.Unix()
|
||||||
|
updated, _ := json.Marshal(existing)
|
||||||
|
if err := txn.Set(devKey, updated); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Drop reverse index so countActiveDevicesForOwner is O(k_active).
|
||||||
|
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
|
||||||
|
if err := txn.Delete(idxKey); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
case EventContactRequest:
|
case EventContactRequest:
|
||||||
var p ContactRequestPayload
|
var p ContactRequestPayload
|
||||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
|||||||
413
blockchain/devices_test.go
Normal file
413
blockchain/devices_test.go
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
package blockchain_test
|
||||||
|
|
||||||
|
// Multi-device registry tests (v2.2.0). Cover happy-path link + list,
|
||||||
|
// unlink idempotency, ownership validation, max-devices guard, and
|
||||||
|
// backward-compat sender fall-back semantics (empty list for unknown
|
||||||
|
// master). HTTP-layer tests live in the node package; these stay at
|
||||||
|
// chain level — applyTx + state-shape only.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uniqueTxID sidesteps the `chain_test.go:txID` race where two helper
|
||||||
|
// calls in the same nanosecond produce identical tx IDs → chain.AddBlock
|
||||||
|
// dedupes the second as "already committed". We add a strictly-monotonic
|
||||||
|
// counter so every tx built in the same test gets a fresh ID even on
|
||||||
|
// fast machines.
|
||||||
|
var devicesTxIDCounter atomic.Uint64
|
||||||
|
|
||||||
|
func uniqueTxID() string {
|
||||||
|
n := devicesTxIDCounter.Add(1)
|
||||||
|
h := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(h)
|
||||||
|
return hex.EncodeToString(h) + ":" + time.Now().Format("150405.000000000") + ":" + itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n uint64) string {
|
||||||
|
// tiny local conversion to avoid importing strconv just for tests.
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkDeviceRawTx / unlinkDeviceRawTx build txs with guaranteed-unique
|
||||||
|
// IDs so we can stuff many of them into a single block during tests.
|
||||||
|
func linkDeviceRawTx(master *identity.Identity, x25519Pub, name string) *blockchain.Transaction {
|
||||||
|
payload := mustJSON(blockchain.LinkDevicePayload{
|
||||||
|
X25519PubKey: x25519Pub,
|
||||||
|
DeviceName: name,
|
||||||
|
})
|
||||||
|
return &blockchain.Transaction{
|
||||||
|
ID: uniqueTxID(),
|
||||||
|
Type: blockchain.EventLinkDevice,
|
||||||
|
From: master.PubKeyHex(),
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlinkDeviceRawTx(master *identity.Identity, x25519Pub string) *blockchain.Transaction {
|
||||||
|
payload := mustJSON(blockchain.UnlinkDevicePayload{X25519PubKey: x25519Pub})
|
||||||
|
return &blockchain.Transaction{
|
||||||
|
ID: uniqueTxID(),
|
||||||
|
Type: blockchain.EventUnlinkDevice,
|
||||||
|
From: master.PubKeyHex(),
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// randX25519Pub returns a throw-away 32-byte hex string suitable for the
|
||||||
|
// x25519_pub_key field. We don't need the matching private key — the
|
||||||
|
// chain only stores the pub and doesn't verify it's a valid curve point.
|
||||||
|
func randX25519Pub(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
var b [32]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
t.Fatalf("rand: %v", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// fundIdentity credits enough balance to cover `nFees` worth of MinFee
|
||||||
|
// payments. Every test needs this because LINK_DEVICE and UNLINK_DEVICE
|
||||||
|
// debit tx.Fee from the master's balance — without funding the first tx
|
||||||
|
// errors with "insufficient funds" and no state changes.
|
||||||
|
func fundIdentity(t *testing.T, c *blockchain.Chain, val, recipient *identity.Identity, nFees int) {
|
||||||
|
t.Helper()
|
||||||
|
amount := uint64(nFees+2) * blockchain.MinFee
|
||||||
|
tx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), recipient.PubKeyHex(),
|
||||||
|
amount, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceHappyPath: after a single LINK_DEVICE, DevicesOf returns
|
||||||
|
// exactly one record with the right owner/name/pub.
|
||||||
|
func TestLinkDeviceHappyPath(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 1)
|
||||||
|
dev1 := randX25519Pub(t)
|
||||||
|
tx := linkDeviceRawTx(alice, dev1, "Alice's iPhone")
|
||||||
|
b := buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})
|
||||||
|
mustAddBlock(t, c, b)
|
||||||
|
|
||||||
|
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DevicesOf: %v", err)
|
||||||
|
}
|
||||||
|
if len(devs) != 1 {
|
||||||
|
t.Fatalf("expected 1 device, got %d", len(devs))
|
||||||
|
}
|
||||||
|
got := devs[0]
|
||||||
|
if got.Owner != alice.PubKeyHex() {
|
||||||
|
t.Errorf("owner: got %s, want %s", got.Owner, alice.PubKeyHex())
|
||||||
|
}
|
||||||
|
if got.X25519PubKey != dev1 {
|
||||||
|
t.Errorf("x25519: got %s, want %s", got.X25519PubKey, dev1)
|
||||||
|
}
|
||||||
|
if got.DeviceName != "Alice's iPhone" {
|
||||||
|
t.Errorf("name: got %q, want %q", got.DeviceName, "Alice's iPhone")
|
||||||
|
}
|
||||||
|
if got.RevokedAt != 0 {
|
||||||
|
t.Errorf("freshly linked device should have RevokedAt=0, got %d", got.RevokedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceMultiple: multiple devices for the same owner all show up
|
||||||
|
// in DevicesOf, and the count matches.
|
||||||
|
func TestLinkDeviceMultiple(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 3)
|
||||||
|
pubs := []string{randX25519Pub(t), randX25519Pub(t), randX25519Pub(t)}
|
||||||
|
var txs []*blockchain.Transaction
|
||||||
|
for i, p := range pubs {
|
||||||
|
txs = append(txs, linkDeviceRawTx(alice, p, "device-"+string(rune('A'+i))))
|
||||||
|
}
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, txs))
|
||||||
|
|
||||||
|
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DevicesOf: %v", err)
|
||||||
|
}
|
||||||
|
if len(devs) != 3 {
|
||||||
|
t.Fatalf("expected 3 devices, got %d", len(devs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceRejectsForeignOwner: if Alice already linked a X25519 pub
|
||||||
|
// and Bob tries to link the SAME pub to himself, the tx is rejected.
|
||||||
|
// This protects against a malicious actor hijacking someone else's
|
||||||
|
// device-pub and trying to claim it.
|
||||||
|
func TestLinkDeviceRejectsForeignOwner(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
bob := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 1)
|
||||||
|
fundIdentity(t, c, val, bob, 1)
|
||||||
|
dev := randX25519Pub(t)
|
||||||
|
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
|
||||||
|
|
||||||
|
// Bob tries to claim the same pub. The chain silently drops invalid
|
||||||
|
// txs (they're non-fatal for block commit), so we verify the *state*:
|
||||||
|
// Alice still owns the pub, Bob has zero devices.
|
||||||
|
bobTx := linkDeviceRawTx(bob, dev, "bob phone")
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{bobTx}))
|
||||||
|
|
||||||
|
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if len(aliceDevs) != 1 || aliceDevs[0].DeviceName != "alice phone" {
|
||||||
|
t.Fatalf("Alice should still own the device, got %+v", aliceDevs)
|
||||||
|
}
|
||||||
|
bobDevs, _ := c.DevicesOf(bob.PubKeyHex())
|
||||||
|
if len(bobDevs) != 0 {
|
||||||
|
t.Fatalf("Bob's hijack should have been rejected, got %+v", bobDevs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceSameOwnerRefresh: the same owner re-linking the same
|
||||||
|
// x25519_pub is a rename/refresh (not an error), and the revoked flag
|
||||||
|
// gets cleared if it was set.
|
||||||
|
func TestLinkDeviceSameOwnerRefresh(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 3)
|
||||||
|
dev := randX25519Pub(t)
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "old name")}))
|
||||||
|
|
||||||
|
// Unlink then re-link — should restore the record.
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "new name")}))
|
||||||
|
|
||||||
|
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DevicesOf: %v", err)
|
||||||
|
}
|
||||||
|
if len(devs) != 1 {
|
||||||
|
t.Fatalf("expected 1 active device after re-link, got %d", len(devs))
|
||||||
|
}
|
||||||
|
if devs[0].DeviceName != "new name" {
|
||||||
|
t.Errorf("expected refreshed name 'new name', got %q", devs[0].DeviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkDeviceRemovesFromActive: after UNLINK_DEVICE, DevicesOf no
|
||||||
|
// longer returns that record — senders stop fanning out to it.
|
||||||
|
func TestUnlinkDeviceRemovesFromActive(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 3)
|
||||||
|
keep := randX25519Pub(t)
|
||||||
|
revoke := randX25519Pub(t)
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{
|
||||||
|
linkDeviceRawTx(alice, keep, "kept"),
|
||||||
|
linkDeviceRawTx(alice, revoke, "revoked"),
|
||||||
|
}))
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, revoke)}))
|
||||||
|
|
||||||
|
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DevicesOf: %v", err)
|
||||||
|
}
|
||||||
|
if len(devs) != 1 || devs[0].X25519PubKey != keep {
|
||||||
|
t.Fatalf("expected only the kept device, got %+v", devs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkDeviceRejectsForeignSigner: Bob can't unlink Alice's device
|
||||||
|
// even if he knows the X25519 pub — only the owner's signature (= tx.From)
|
||||||
|
// authorises revoke.
|
||||||
|
func TestUnlinkDeviceRejectsForeignSigner(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
bob := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 1)
|
||||||
|
fundIdentity(t, c, val, bob, 1)
|
||||||
|
dev := randX25519Pub(t)
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
|
||||||
|
|
||||||
|
// Bob tries to unlink Alice's device — tx is silently dropped, Alice
|
||||||
|
// still has it in her active list.
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{unlinkDeviceRawTx(bob, dev)}))
|
||||||
|
|
||||||
|
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if len(aliceDevs) != 1 || aliceDevs[0].X25519PubKey != dev {
|
||||||
|
t.Fatalf("Alice's device should still be active, got %+v", aliceDevs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkDeviceIdempotent: unlinking a device that's already revoked
|
||||||
|
// does NOT error — this is needed for recovery scenarios where a client
|
||||||
|
// retries the tx after a spotty connection.
|
||||||
|
func TestUnlinkDeviceIdempotent(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 3)
|
||||||
|
dev := randX25519Pub(t)
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "phone")}))
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||||
|
// Second unlink of the same device must apply cleanly.
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceRejectsMalformedPub: payload.X25519PubKey must be 64-char
|
||||||
|
// lowercase hex. Garbage / short / uppercase values are rejected at
|
||||||
|
// applyTx time before any state change.
|
||||||
|
func TestLinkDeviceRejectsMalformedPub(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 5)
|
||||||
|
|
||||||
|
bad := []string{
|
||||||
|
"", // empty
|
||||||
|
"abc", // too short
|
||||||
|
strings.Repeat("x", 64), // non-hex
|
||||||
|
strings.Repeat("A", 64), // uppercase
|
||||||
|
}
|
||||||
|
for _, p := range bad {
|
||||||
|
tx := linkDeviceRawTx(alice, p, "dev")
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||||
|
}
|
||||||
|
|
||||||
|
devs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if len(devs) != 0 {
|
||||||
|
t.Fatalf("expected 0 devices after %d malformed link attempts, got %d",
|
||||||
|
len(bad), len(devs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceRejectsBadDeviceName: empty, too long, or control-char
|
||||||
|
// names are refused — this field is rendered verbatim in clients.
|
||||||
|
func TestLinkDeviceRejectsBadDeviceName(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
fundIdentity(t, c, val, alice, 5)
|
||||||
|
cases := map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"too long": strings.Repeat("a", blockchain.MaxDeviceNameLen+1),
|
||||||
|
"control char": "device\x01name",
|
||||||
|
}
|
||||||
|
for name, devName := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
before, _ := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
tx := linkDeviceRawTx(alice, randX25519Pub(t), devName)
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||||
|
after, _ := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Fatalf("bad %s name should not have been linked (before=%d, after=%d)",
|
||||||
|
name, len(before), len(after))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkDeviceMaxDevices: after MaxDevicesPerOwner active devices, any
|
||||||
|
// further LINK_DEVICE is rejected. Revoking one frees a slot.
|
||||||
|
func TestLinkDeviceMaxDevices(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
// Enough for cap + one overflow attempt + one unlink + one replacement.
|
||||||
|
fundIdentity(t, c, val, alice, blockchain.MaxDevicesPerOwner+4)
|
||||||
|
|
||||||
|
// Fill to the cap — each in its own block so timestamps differ.
|
||||||
|
pubs := make([]string, blockchain.MaxDevicesPerOwner)
|
||||||
|
for i := 0; i < blockchain.MaxDevicesPerOwner; i++ {
|
||||||
|
pubs[i] = randX25519Pub(t)
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, pubs[i], "d")}))
|
||||||
|
}
|
||||||
|
// One more — the tx is dropped, cap stays exact.
|
||||||
|
over := linkDeviceRawTx(alice, randX25519Pub(t), "overflow")
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{over}))
|
||||||
|
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
|
||||||
|
t.Fatalf("overflow link should have been rejected, count=%d", len(devs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one, then a fresh link succeeds.
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, pubs[0])}))
|
||||||
|
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||||
|
[]*blockchain.Transaction{linkDeviceRawTx(alice, randX25519Pub(t), "replacement")}))
|
||||||
|
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
|
||||||
|
t.Fatalf("after unlink+relink, should be %d active, got %d",
|
||||||
|
blockchain.MaxDevicesPerOwner, len(devs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDevicesOfEmptyForUnknown: unknown master pubs return an empty list
|
||||||
|
// (not an error) — this preserves the sender's ability to fall back to
|
||||||
|
// IdentityInfo.X25519Pub for legacy identities.
|
||||||
|
func TestDevicesOfEmptyForUnknown(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
alice := newIdentity(t)
|
||||||
|
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DevicesOf: %v", err)
|
||||||
|
}
|
||||||
|
if len(devs) != 0 {
|
||||||
|
t.Fatalf("expected 0 devices for unknown master, got %d", len(devs))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,14 @@ const (
|
|||||||
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
|
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
|
||||||
EventLikePost EventType = "LIKE_POST" // like a post
|
EventLikePost EventType = "LIKE_POST" // like a post
|
||||||
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
|
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
|
||||||
|
|
||||||
|
// Multi-device support (v2.2.0): each physical device of an identity gets
|
||||||
|
// its own X25519 keypair for the messenger mailbox. LINK/UNLINK_DEVICE
|
||||||
|
// publish/revoke those pubs on-chain so senders can fan-out envelopes
|
||||||
|
// across the recipient's active devices. Signed by the identity's
|
||||||
|
// master Ed25519 key.
|
||||||
|
EventLinkDevice EventType = "LINK_DEVICE"
|
||||||
|
EventUnlinkDevice EventType = "UNLINK_DEVICE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Token amounts are stored in micro-tokens (µT).
|
// Token amounts are stored in micro-tokens (µT).
|
||||||
@@ -554,7 +562,71 @@ type IdentityInfo struct {
|
|||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
|
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
|
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
|
||||||
|
// DeviceCount is the number of currently-linked (non-revoked) devices in
|
||||||
|
// this identity's multi-device registry. `0` for legacy identities that
|
||||||
|
// only published a single X25519 via REGISTER_KEY — senders fall back
|
||||||
|
// to IdentityInfo.X25519Pub in that case.
|
||||||
|
DeviceCount int `json:"device_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-device registry (v2.2.0) ───────────────────────────────────────
|
||||||
|
|
||||||
|
// MaxDevicesPerOwner caps how many devices a single identity can have
|
||||||
|
// linked concurrently. A sender must encrypt + relay one envelope per
|
||||||
|
// device, so the multiplier bounds the per-message cost. Ten covers
|
||||||
|
// typical users (phone + tablet + laptop + desktop + work phone + …)
|
||||||
|
// without letting an abuse case blow up mailbox traffic. Revoked devices
|
||||||
|
// don't count.
|
||||||
|
const MaxDevicesPerOwner = 10
|
||||||
|
|
||||||
|
// MaxDeviceNameLen caps the length of a human-friendly device label
|
||||||
|
// ("Alice's iPhone 14", "Work MacBook Pro"). Longer names get rejected
|
||||||
|
// at validation. Kept short to discourage using this field as a free-form
|
||||||
|
// comment/profile channel.
|
||||||
|
const MaxDeviceNameLen = 64
|
||||||
|
|
||||||
|
// LinkDevicePayload is embedded in EventLinkDevice transactions. Master
|
||||||
|
// Ed25519 (= tx.From) asserts that the given X25519 pub belongs to one
|
||||||
|
// of its physical devices, publishing it so senders can fan out envelopes.
|
||||||
|
type LinkDevicePayload struct {
|
||||||
|
// X25519PubKey is the hex-encoded Curve25519 pub the device generated
|
||||||
|
// locally. Must be unique in the device registry — senders index
|
||||||
|
// envelopes by this key in the relay mailbox.
|
||||||
|
X25519PubKey string `json:"x25519_pub_key"`
|
||||||
|
// DeviceName is a short human label shown in Settings → Devices.
|
||||||
|
// Purely informational; not used for routing.
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkDevicePayload is embedded in EventUnlinkDevice transactions. Signed
|
||||||
|
// by master Ed25519; marks the referenced device as revoked so senders
|
||||||
|
// stop shipping envelopes to its X25519 pub. The revoked device itself,
|
||||||
|
// once it sees its pub in the revoked list, is expected to wipe its
|
||||||
|
// local state (master Ed25519 priv + chat cache).
|
||||||
|
type UnlinkDevicePayload struct {
|
||||||
|
X25519PubKey string `json:"x25519_pub_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRecord is the on-chain persisted state for one device link.
|
||||||
|
// Stored at key `prefixDevice + x25519_pub`; the reverse index
|
||||||
|
// `prefixDevicesByOwner + master_pub` keeps a slice of x25519 pubs per
|
||||||
|
// owner for efficient listing.
|
||||||
|
type DeviceRecord struct {
|
||||||
|
Owner string `json:"owner"` // master Ed25519 pub hex
|
||||||
|
X25519PubKey string `json:"x25519_pub_key"` // device X25519 pub hex
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
AddedAt int64 `json:"added_at"` // unix seconds, from tx timestamp
|
||||||
|
RevokedAt int64 `json:"revoked_at,omitempty"` // 0 = active; >0 = revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceInfo is the public view of a DeviceRecord served by
|
||||||
|
// GET /api/devices/{master_pub}. Revoked records are not included
|
||||||
|
// in the response (intentionally — sender only needs the active set).
|
||||||
|
type DeviceInfo struct {
|
||||||
|
X25519PubKey string `json:"x25519_pub_key"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
AddedAt int64 `json:"added_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsensusMessage types used by the PBFT engine over the P2P layer.
|
// ConsensusMessage types used by the PBFT engine over the P2P layer.
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
|
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
|
||||||
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
|
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
|
||||||
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library."
|
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library.",
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "com.dchain.messenger",
|
"package": "com.dchain.messenger",
|
||||||
"softwareKeyboardLayoutMode": "pan",
|
"softwareKeyboardLayoutMode": "pan",
|
||||||
|
"usesCleartextTraffic": true,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* один раз; переходы между tab'ами их не перезапускают.
|
* один раз; переходы между tab'ами их не перезапускают.
|
||||||
*/
|
*/
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { View } from 'react-native';
|
import { View, Platform } from 'react-native';
|
||||||
import { router, usePathname } from 'expo-router';
|
import { router, usePathname } from 'expo-router';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
@@ -25,7 +25,13 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
|||||||
import { getWSClient } from '@/lib/ws';
|
import { getWSClient } from '@/lib/ws';
|
||||||
import { NavBar } from '@/components/NavBar';
|
import { NavBar } from '@/components/NavBar';
|
||||||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||||
import { saveContact } from '@/lib/storage';
|
import {
|
||||||
|
saveContact,
|
||||||
|
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||||
|
} from '@/lib/storage';
|
||||||
|
import {
|
||||||
|
fetchDevices, buildLinkDeviceTx, submitTx,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
@@ -73,6 +79,79 @@ export default function AppLayout() {
|
|||||||
else ws.setAuthCreds(null);
|
else ws.setAuthCreds(null);
|
||||||
}, [keyFile]);
|
}, [keyFile]);
|
||||||
|
|
||||||
|
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
|
||||||
|
//
|
||||||
|
// Three branches, by (chain list × local "was registered" flag):
|
||||||
|
//
|
||||||
|
// 1. Our pub is in the chain's active list →
|
||||||
|
// mark us registered locally (idempotent), done.
|
||||||
|
//
|
||||||
|
// 2. Our pub is NOT in the active list, AND we've registered before →
|
||||||
|
// another device issued UNLINK_DEVICE against us. Wipe ALL local
|
||||||
|
// state (master priv, contacts, chats, marker) and redirect to
|
||||||
|
// the auth screen. This is the security-critical path: without
|
||||||
|
// the wipe, a stolen phone after revoke would still decrypt
|
||||||
|
// historical messages.
|
||||||
|
//
|
||||||
|
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
|
||||||
|
// first boot on this chain; submit LINK_DEVICE so senders can
|
||||||
|
// target us. Failures (fee, offline) are swallowed; next launch
|
||||||
|
// retries.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
let chainList;
|
||||||
|
try {
|
||||||
|
chainList = await fetchDevices(keyFile.pub_key);
|
||||||
|
} catch {
|
||||||
|
// Network unavailable — leave state unchanged; we'll resync on
|
||||||
|
// the next launch. Do NOT wipe on network error.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||||
|
const previouslyRegistered = await isDeviceRegistered();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (inActive) {
|
||||||
|
// Branch #1 — ensure the local marker is set.
|
||||||
|
if (!previouslyRegistered) await markDeviceRegistered();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previouslyRegistered) {
|
||||||
|
// Branch #2 — REVOKED. Self-wipe.
|
||||||
|
await wipeAllLocalState();
|
||||||
|
useStore.getState().setKeyFile(null);
|
||||||
|
// The redirect-on-null-keyFile effect below will push the user
|
||||||
|
// back to the welcome screen automatically.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch #3 — first-boot link. Best-effort.
|
||||||
|
try {
|
||||||
|
const deviceName = Platform.select({
|
||||||
|
ios: 'iPhone',
|
||||||
|
android: 'Android phone',
|
||||||
|
default: 'Device',
|
||||||
|
}) ?? 'Device';
|
||||||
|
const tx = buildLinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: keyFile.x25519_pub,
|
||||||
|
deviceName,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
await markDeviceRegistered();
|
||||||
|
} catch {
|
||||||
|
/* next launch retries */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keyFile === null) {
|
if (keyFile === null) {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
|
|||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { useMessages } from '@/hooks/useMessages';
|
import { useMessages } from '@/hooks/useMessages';
|
||||||
import { encryptMessage } from '@/lib/crypto';
|
import { encryptMessage } from '@/lib/crypto';
|
||||||
import { sendEnvelope } from '@/lib/api';
|
import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
|
||||||
import { getWSClient } from '@/lib/ws';
|
import { getWSClient } from '@/lib/ws';
|
||||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||||
import { randomId, safeBack } from '@/lib/utils';
|
import { randomId, safeBack } from '@/lib/utils';
|
||||||
@@ -107,7 +107,7 @@ export default function ChatScreen() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const selectionMode = selectedIds.size > 0;
|
const selectionMode = selectedIds.size > 0;
|
||||||
|
|
||||||
useMessages(contact?.x25519Pub ?? '');
|
useMessages(contact?.x25519Pub ?? '', contact?.address);
|
||||||
|
|
||||||
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,15 +212,34 @@ export default function ChatScreen() {
|
|||||||
// leaves the device, so no encryption/fee/network round-trip is needed.
|
// leaves the device, so no encryption/fee/network round-trip is needed.
|
||||||
// Regular chats still go through the NaCl + relay pipeline below.
|
// Regular chats still go through the NaCl + relay pipeline below.
|
||||||
if (hasText && !isSavedMessages) {
|
if (hasText && !isSavedMessages) {
|
||||||
const { nonce, ciphertext } = encryptMessage(
|
// Multi-device fan-out (v2.2.0): resolve the recipient's active
|
||||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
// device X25519 pubs via /api/devices. Legacy identities (no
|
||||||
);
|
// devices registered) fall back to their published identity
|
||||||
await sendEnvelope({
|
// x25519 pub, preserving the pre-v2.2.0 single-device path.
|
||||||
senderPub: keyFile.x25519_pub,
|
// `contact.x25519Pub` stays the floor — if both network calls
|
||||||
recipientPub: contact.x25519Pub,
|
// fail we still attempt delivery to the cached pub so a flaky
|
||||||
senderEd25519Pub: keyFile.pub_key,
|
// connection doesn't block outgoing messages.
|
||||||
nonce, ciphertext,
|
let recipientPubs = await resolveRecipientKeys(contact.address);
|
||||||
});
|
if (recipientPubs.length === 0 && contact.x25519Pub) {
|
||||||
|
recipientPubs = [contact.x25519Pub];
|
||||||
|
}
|
||||||
|
if (recipientPubs.length === 0) {
|
||||||
|
throw new Error('recipient has no encryption key published');
|
||||||
|
}
|
||||||
|
// One sealed envelope per recipient device. Parallel — slow
|
||||||
|
// relays don't block each other; any individual failure
|
||||||
|
// rejects the whole send (user retries).
|
||||||
|
await Promise.all(recipientPubs.map(async (rpub) => {
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
actualText.trim(), keyFile.x25519_priv, rpub,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: rpub,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
|
|||||||
490
client-app/app/(app)/devices.tsx
Normal file
490
client-app/app/(app)/devices.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* Devices screen — Settings → Linked devices.
|
||||||
|
*
|
||||||
|
* Multi-device registry (v2.2.0). Lists every X25519 device published
|
||||||
|
* on-chain under this identity's master Ed25519 key. Operators can:
|
||||||
|
* - see added-at timestamps
|
||||||
|
* - rename this device (local alias for now; rename via LINK_DEVICE
|
||||||
|
* with same pub + new name is a v2.3 polish)
|
||||||
|
* - revoke a remote device via UNLINK_DEVICE (requires fee)
|
||||||
|
* - pair a new device (Phase 3 — separate modal, stub for now)
|
||||||
|
*
|
||||||
|
* This device is NEVER listed with an Unlink button — revoking yourself
|
||||||
|
* is a footgun (you'd wipe your own state on next launch). Export/import
|
||||||
|
* your key first, then revoke from the new device.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
|
||||||
|
TextInput, KeyboardAvoidingView, Platform, Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx,
|
||||||
|
sendEnvelope, humanizeTxError,
|
||||||
|
type DeviceInfo,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { encryptMessage } from '@/lib/crypto';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { IconButton } from '@/components/IconButton';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
|
function shortPub(p: string, n = 8): string {
|
||||||
|
if (!p) return '—';
|
||||||
|
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}…${p.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(unixSec: number): string {
|
||||||
|
return new Date(unixSec * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
|
||||||
|
|
||||||
|
const load = useCallback(async (isRefresh = false) => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
if (isRefresh) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await fetchDevices(keyFile.pub_key);
|
||||||
|
setDevices(list);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => { load(false); }, [load]);
|
||||||
|
|
||||||
|
const onUnlink = useCallback((dev: DeviceInfo) => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
Alert.alert(
|
||||||
|
'Unlink device?',
|
||||||
|
`"${dev.device_name}" will stop receiving messages sent to you. ` +
|
||||||
|
`This costs a small network fee. The revoked device wipes its ` +
|
||||||
|
`local state the next time it checks in.`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Unlink',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
setUnlinking(dev.x25519_pub_key);
|
||||||
|
try {
|
||||||
|
const tx = buildUnlinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: dev.x25519_pub_key,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
// Optimistic — drop from local list immediately; next load
|
||||||
|
// reconciles. Chain tx takes ~1 block to commit.
|
||||||
|
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Unlink failed', humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setUnlinking(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
const meX25519 = keyFile?.x25519_pub ?? '';
|
||||||
|
|
||||||
|
// Pairing modal state — filled by the operator reading values off the
|
||||||
|
// new device's /auth/pair screen.
|
||||||
|
const [pairOpen, setPairOpen] = useState(false);
|
||||||
|
const [pairCode, setPairCode] = useState('');
|
||||||
|
const [pairKey, setPairKey] = useState('');
|
||||||
|
const [pairName, setPairName] = useState('');
|
||||||
|
const [pairBusy, setPairBusy] = useState(false);
|
||||||
|
|
||||||
|
const submitPair = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const code = pairCode.replace(/\s+/g, '').trim();
|
||||||
|
const key = pairKey.replace(/\s+/g, '').trim().toLowerCase();
|
||||||
|
if (!/^\d{6}$/.test(code)) {
|
||||||
|
Alert.alert('Invalid code', 'The pairing code is 6 digits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(key)) {
|
||||||
|
Alert.alert('Invalid key', 'The device key must be 64 hex characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = pairName.trim() || 'New device';
|
||||||
|
setPairBusy(true);
|
||||||
|
try {
|
||||||
|
// 1. LINK_DEVICE on-chain so senders learn the new pub.
|
||||||
|
const tx = buildLinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: key,
|
||||||
|
deviceName: name,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
// 2. Ship the handshake payload to the new device. Encrypted for
|
||||||
|
// its x25519 pub so only it can read — master priv in plaintext
|
||||||
|
// would be catastrophic, E2E is the whole point.
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
type: 'pair-handshake',
|
||||||
|
code,
|
||||||
|
master_pub: keyFile.pub_key,
|
||||||
|
master_priv: keyFile.priv_key,
|
||||||
|
master_x25519_pub: keyFile.x25519_pub,
|
||||||
|
});
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
payload, keyFile.x25519_priv, key,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: key,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
// 3. Optimistic local insert so the row shows up without waiting
|
||||||
|
// for the next pull/refresh round-trip.
|
||||||
|
setDevices(prev => {
|
||||||
|
if (prev.some(d => d.x25519_pub_key === key)) return prev;
|
||||||
|
return [...prev, {
|
||||||
|
x25519_pub_key: key,
|
||||||
|
device_name: name,
|
||||||
|
added_at: Math.floor(Date.now() / 1000),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
setPairOpen(false);
|
||||||
|
setPairCode(''); setPairKey(''); setPairName('');
|
||||||
|
Alert.alert(
|
||||||
|
'Pairing sent',
|
||||||
|
'The new device should finish pairing in a few seconds.',
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Pairing failed', humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setPairBusy(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, pairCode, pairKey, pairName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
|
<Header
|
||||||
|
title="Devices"
|
||||||
|
divider
|
||||||
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => load(true)}
|
||||||
|
tintColor="#1d9bf0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
|
||||||
|
Every linked device has its own encryption key. Messages sent to you
|
||||||
|
are delivered to all active devices.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={{ paddingTop: 60, alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<View style={{
|
||||||
|
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
|
||||||
|
<Text style={{
|
||||||
|
color: '#ffffff', fontSize: 15, fontWeight: '700',
|
||||||
|
marginTop: 10,
|
||||||
|
}}>
|
||||||
|
No devices registered yet
|
||||||
|
</Text>
|
||||||
|
<Text style={{
|
||||||
|
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
|
||||||
|
marginTop: 6, lineHeight: 19,
|
||||||
|
}}>
|
||||||
|
This device auto-registers when the next network-fee is available.
|
||||||
|
Top up your balance and pull to refresh.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{devices.map((d, i) => {
|
||||||
|
const isMe = d.x25519_pub_key === meX25519;
|
||||||
|
const busy = unlinking === d.x25519_pub_key;
|
||||||
|
return (
|
||||||
|
<View key={d.x25519_pub_key}>
|
||||||
|
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
|
||||||
|
}}>
|
||||||
|
<Ionicons
|
||||||
|
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
|
||||||
|
size={22}
|
||||||
|
color={isMe ? '#1d9bf0' : '#d0d0d0'}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{d.device_name || 'Unnamed device'}
|
||||||
|
</Text>
|
||||||
|
{isMe && (
|
||||||
|
<View style={{
|
||||||
|
paddingHorizontal: 6, paddingVertical: 1,
|
||||||
|
borderRadius: 6, backgroundColor: '#0d2540',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
|
||||||
|
THIS DEVICE
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
marginTop: 3,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{shortPub(d.x25519_pub_key)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||||
|
Linked {formatDate(d.added_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isMe && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onUnlink(d)}
|
||||||
|
disabled={busy}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingHorizontal: 12, paddingVertical: 7,
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1, borderColor: '#3a2020',
|
||||||
|
backgroundColor: pressed ? '#2a1414' : 'transparent',
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator size="small" color="#ff6b6b" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
|
||||||
|
Unlink
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link new device — opens a modal with Code + DeviceKey inputs
|
||||||
|
that the operator transcribes from the new device's
|
||||||
|
/auth/pair screen. */}
|
||||||
|
<View style={{ marginTop: 18 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setPairOpen(true)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingVertical: 13, paddingHorizontal: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||||
|
Link new device
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||||
|
Enter the 6-digit code from the new device
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ── Pair new device modal ───────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
visible={pairOpen}
|
||||||
|
animationType="fade"
|
||||||
|
transparent
|
||||||
|
onRequestClose={() => !pairBusy && setPairOpen(false)}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{
|
||||||
|
width: '100%', maxWidth: 420,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
padding: 20, gap: 12,
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
justifyContent: 'space-between', marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
|
||||||
|
Link new device
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => !pairBusy && setPairOpen(false)}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
|
||||||
|
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
|
||||||
|
the welcome screen, then transcribe the code + device key shown there.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<PairInput
|
||||||
|
label="6-digit code"
|
||||||
|
value={pairCode}
|
||||||
|
onChangeText={setPairCode}
|
||||||
|
placeholder="000000"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={6}
|
||||||
|
monospace
|
||||||
|
/>
|
||||||
|
<PairInput
|
||||||
|
label="Device key"
|
||||||
|
value={pairKey}
|
||||||
|
onChangeText={setPairKey}
|
||||||
|
placeholder="64 hex chars"
|
||||||
|
autoCapitalize="none"
|
||||||
|
maxLength={64}
|
||||||
|
monospace
|
||||||
|
/>
|
||||||
|
<PairInput
|
||||||
|
label="Name for this device (optional)"
|
||||||
|
value={pairName}
|
||||||
|
onChangeText={setPairName}
|
||||||
|
placeholder="e.g. Alice's laptop"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', justifyContent: 'flex-end',
|
||||||
|
gap: 10, marginTop: 8,
|
||||||
|
}}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setPairOpen(false)}
|
||||||
|
disabled={pairBusy}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingHorizontal: 16, paddingVertical: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
|
||||||
|
opacity: pairBusy ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={submitPair}
|
||||||
|
disabled={pairBusy}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
paddingHorizontal: 18, paddingVertical: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
|
||||||
|
opacity: pairBusy ? 0.7 : 1,
|
||||||
|
minWidth: 90, alignItems: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{pairBusy ? (
|
||||||
|
<ActivityIndicator size="small" color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||||
|
Link
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PairInput({
|
||||||
|
label, value, onChangeText, placeholder, keyboardType, autoCapitalize,
|
||||||
|
maxLength, monospace,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
keyboardType?: 'default' | 'number-pad';
|
||||||
|
autoCapitalize?: 'none' | 'sentences';
|
||||||
|
maxLength?: number;
|
||||||
|
monospace?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#3a3a3a"
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
autoCapitalize={autoCapitalize ?? 'sentences'}
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={maxLength}
|
||||||
|
style={{
|
||||||
|
color: '#ffffff', fontSize: monospace ? 13 : 14,
|
||||||
|
fontFamily: monospace ? 'monospace' : undefined,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10,
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -492,6 +492,18 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Devices — multi-device registry (v2.2.0) ── */}
|
||||||
|
<SectionLabel>Devices</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<Row
|
||||||
|
icon="phone-portrait-outline"
|
||||||
|
label="Linked devices"
|
||||||
|
value="Manage the devices that receive your messages"
|
||||||
|
onPress={() => router.push('/(app)/devices' as never)}
|
||||||
|
first
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* ── Account ── */}
|
{/* ── Account ── */}
|
||||||
<SectionLabel>Account</SectionLabel>
|
<SectionLabel>Account</SectionLabel>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
300
client-app/app/(auth)/pair.tsx
Normal file
300
client-app/app/(auth)/pair.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Pair — secondary-device onboarding.
|
||||||
|
*
|
||||||
|
* Flow (new device, this screen):
|
||||||
|
* 1. Generate a fresh X25519 keypair locally + random 6-digit code.
|
||||||
|
* 2. Display { code, device x25519 pub }. User enters both on their
|
||||||
|
* primary device (Settings → Devices → Link new device).
|
||||||
|
* 3. Primary device:
|
||||||
|
* - submits LINK_DEVICE tx to publish our pub under its master,
|
||||||
|
* - sends a relay envelope to our x25519 pub, encrypted with its
|
||||||
|
* own x25519 priv, containing { code, master_pub, master_priv,
|
||||||
|
* master_x25519_pub }.
|
||||||
|
* 4. We poll /relay/inbox every few seconds; when a decryptable
|
||||||
|
* envelope arrives whose payload.code matches our displayed code,
|
||||||
|
* we treat it as the handshake, assemble a KeyFile, save it, and
|
||||||
|
* redirect into (app).
|
||||||
|
*
|
||||||
|
* Security notes:
|
||||||
|
* - Master Ed25519 priv travels only via this envelope, encrypted for
|
||||||
|
* this device's X25519 pub (which only this device holds the priv
|
||||||
|
* for). Exposure is limited to one successful decrypt; we DELETE
|
||||||
|
* the envelope from the mailbox immediately.
|
||||||
|
* - The 6-digit code defends against a confused primary device paired
|
||||||
|
* with a different victim, or a race with an attacker who guesses
|
||||||
|
* our X25519 pub. Envelope without matching code → ignored.
|
||||||
|
* - Envelope is short-lived in the mailbox: we DELETE on first decrypt
|
||||||
|
* and the relay node has its own TTL.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, Pressable, ActivityIndicator, ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { bytesToHex, decryptMessage } from '@/lib/crypto';
|
||||||
|
import { fetchInbox } from '@/lib/api';
|
||||||
|
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
|
||||||
|
// Protocol constant — bump if payload shape changes.
|
||||||
|
const PAIR_ENVELOPE_VERSION = 1;
|
||||||
|
|
||||||
|
interface PairEnvelopePayload {
|
||||||
|
v: number;
|
||||||
|
type: 'pair-handshake';
|
||||||
|
code: string;
|
||||||
|
master_pub: string; // Ed25519 pub, hex
|
||||||
|
master_priv: string; // Ed25519 priv, hex
|
||||||
|
master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI)
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomCode(): string {
|
||||||
|
// Six decimal digits. Entropy ~20 bits. Good enough for a one-shot
|
||||||
|
// rendezvous code gated by an out-of-band delivery channel (envelope
|
||||||
|
// targeted at a freshly-generated X25519 pub that only this device
|
||||||
|
// holds the priv for).
|
||||||
|
const n = Math.floor(Math.random() * 1_000_000);
|
||||||
|
return n.toString().padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PairScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
|
||||||
|
// One-shot keypair + code for this pairing session. Regenerate only on
|
||||||
|
// manual Retry (unmount+remount).
|
||||||
|
const session = useRef(genSession()).current;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const copyCode = useCallback(async () => {
|
||||||
|
await Clipboard.setStringAsync(session.code);
|
||||||
|
}, [session.code]);
|
||||||
|
|
||||||
|
const copyPub = useCallback(async () => {
|
||||||
|
await Clipboard.setStringAsync(session.x25519Pub);
|
||||||
|
}, [session.x25519Pub]);
|
||||||
|
|
||||||
|
// Poll mailbox until a matching handshake envelope arrives, or user
|
||||||
|
// backs out. Interval 2.5s — conservative on battery, fine for a
|
||||||
|
// flow the user is staring at for a minute.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const envs = await fetchInbox(session.x25519Pub);
|
||||||
|
for (const env of envs) {
|
||||||
|
// Decrypt each envelope with our session priv. We don't know
|
||||||
|
// the primary's x25519 pub up front — it's inside the envelope
|
||||||
|
// metadata. decryptMessage needs both pubs, so we pass the
|
||||||
|
// envelope's sender_pub directly.
|
||||||
|
const plain = decryptMessage(
|
||||||
|
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
|
||||||
|
);
|
||||||
|
if (!plain) continue;
|
||||||
|
let payload: PairEnvelopePayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(plain);
|
||||||
|
} catch { continue; }
|
||||||
|
if (
|
||||||
|
payload.v !== PAIR_ENVELOPE_VERSION ||
|
||||||
|
payload.type !== 'pair-handshake' ||
|
||||||
|
payload.code !== session.code ||
|
||||||
|
!payload.master_pub || !payload.master_priv
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
// Success — materialise a KeyFile and redirect.
|
||||||
|
const kf: KeyFile = {
|
||||||
|
pub_key: payload.master_pub,
|
||||||
|
priv_key: payload.master_priv,
|
||||||
|
x25519_pub: session.x25519Pub,
|
||||||
|
x25519_priv: session.x25519Priv,
|
||||||
|
};
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
// The root layout auto-links us on first boot if needed, but
|
||||||
|
// the primary device already submitted LINK_DEVICE for our
|
||||||
|
// pub as part of the pairing, so the registry is already
|
||||||
|
// correct. Mark ourselves registered so the revoke-detection
|
||||||
|
// branch doesn't spuriously wipe on next launch.
|
||||||
|
await markDeviceRegistered();
|
||||||
|
setKeyFile(kf);
|
||||||
|
|
||||||
|
// Envelope stays in mailbox until relay TTL eviction; the
|
||||||
|
// single-shot handshake is idempotent (saveKeyFile overwrites)
|
||||||
|
// and our session pub won't be polled again after redirect.
|
||||||
|
setStatus('success');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) router.replace('/(app)/chats' as never);
|
||||||
|
}, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* transient — retry */
|
||||||
|
}
|
||||||
|
if (!cancelled) timer = setTimeout(tick, 2_500);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [session, setKeyFile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
flex: 1, backgroundColor: '#000000',
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 20),
|
||||||
|
}}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 24 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => safeBack('/')}
|
||||||
|
hitSlop={8}
|
||||||
|
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={28} color="#ffffff" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={{ alignItems: 'center', marginBottom: 28 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 80, height: 80, borderRadius: 22,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="link" size={40} color="#1d9bf0" />
|
||||||
|
</View>
|
||||||
|
<Text style={{
|
||||||
|
color: '#ffffff', fontSize: 22, fontWeight: '800',
|
||||||
|
marginTop: 14, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Pair with your other device
|
||||||
|
</Text>
|
||||||
|
<Text style={{
|
||||||
|
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
|
||||||
|
marginTop: 8, textAlign: 'center', maxWidth: 300,
|
||||||
|
}}>
|
||||||
|
On a device where you're already signed in,
|
||||||
|
open Settings → Devices → Link new device,
|
||||||
|
and enter these values.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Code */}
|
||||||
|
<View style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
paddingVertical: 18, paddingHorizontal: 16,
|
||||||
|
marginBottom: 14, alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Text style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1.2,
|
||||||
|
}}>
|
||||||
|
1. Code
|
||||||
|
</Text>
|
||||||
|
<Text style={{
|
||||||
|
color: '#ffffff', fontSize: 38, fontWeight: '800',
|
||||||
|
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
{session.code.slice(0, 3)} {session.code.slice(3)}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||||
|
Copy code
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Device key */}
|
||||||
|
<View style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 1, borderColor: '#1f1f1f',
|
||||||
|
paddingVertical: 16, paddingHorizontal: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<Text style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1.2,
|
||||||
|
}}>
|
||||||
|
2. Device key
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
style={{
|
||||||
|
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
|
||||||
|
marginTop: 6, lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.x25519Pub}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
|
||||||
|
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||||
|
Copy key
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<View style={{ alignItems: 'center', minHeight: 56 }}>
|
||||||
|
{status === 'waiting' && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
<ActivityIndicator size="small" color="#1d9bf0" />
|
||||||
|
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
|
||||||
|
Waiting for your other device…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{status === 'success' && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
|
||||||
|
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
|
||||||
|
Paired. Opening your chats…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
|
||||||
|
{err ?? 'Something went wrong. Please retry.'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── session helper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PairSession {
|
||||||
|
x25519Pub: string; // hex
|
||||||
|
x25519Priv: string; // hex
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genSession(): PairSession {
|
||||||
|
const kp = nacl.box.keyPair();
|
||||||
|
return {
|
||||||
|
x25519Pub: bytesToHex(kp.publicKey),
|
||||||
|
x25519Priv: bytesToHex(kp.secretKey),
|
||||||
|
code: randomCode(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -346,8 +346,13 @@ export default function WelcomeScreen() {
|
|||||||
{/* CTA — прижата к правому нижнему краю. */}
|
{/* CTA — прижата к правому нижнему краю. */}
|
||||||
<View style={{
|
<View style={{
|
||||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||||
paddingHorizontal: 24, paddingBottom: 8,
|
paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
|
||||||
}}>
|
}}>
|
||||||
|
<CTASecondary
|
||||||
|
label="Pair"
|
||||||
|
icon="link"
|
||||||
|
onPress={() => router.push('/(auth)/pair' as never)}
|
||||||
|
/>
|
||||||
<CTASecondary
|
<CTASecondary
|
||||||
label="Import"
|
label="Import"
|
||||||
onPress={() => router.push('/(auth)/import' as never)}
|
onPress={() => router.push('/(auth)/import' as never)}
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ export function useGlobalInbox() {
|
|||||||
try {
|
try {
|
||||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
for (const env of envelopes) {
|
for (const env of envelopes) {
|
||||||
// Найти контакт по sender_pub — если не знакомый, игнорим
|
// Attribution (v2.2.0+): prefer the envelope's master Ed25519
|
||||||
// (для MVP; в future можно показывать "unknown sender").
|
// so messages from any of the sender's linked devices roll
|
||||||
const c = contactsRef.current.find(
|
// into a single chat. Fall back to legacy X25519-based lookup
|
||||||
x => x.x25519Pub === env.sender_pub,
|
// for pre-v2.2.0 senders that left the field empty.
|
||||||
|
const c = contactsRef.current.find(x =>
|
||||||
|
(env.sender_ed25519_pub && x.address === env.sender_ed25519_pub) ||
|
||||||
|
x.x25519Pub === env.sender_pub,
|
||||||
);
|
);
|
||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,26 @@ import { tryParsePostRef } from '@/lib/forwardPost';
|
|||||||
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
||||||
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||||
|
|
||||||
export function useMessages(contactX25519: string) {
|
/**
|
||||||
|
* useMessages — mounts per-chat inbox consumption. Accepts:
|
||||||
|
* - contactX25519: the legacy/primary X25519 for the contact.
|
||||||
|
* - contactMasterEd25519 (optional, v2.2.0+): the contact's master
|
||||||
|
* identity so we can attribute envelopes from any of their
|
||||||
|
* linked devices to this conversation.
|
||||||
|
*
|
||||||
|
* Matching rule: an envelope belongs to this chat when
|
||||||
|
* env.sender_ed25519_pub === contactMasterEd25519 (v2.2.0 path)
|
||||||
|
* OR env.sender_pub === contactX25519 (legacy path)
|
||||||
|
*/
|
||||||
|
export function useMessages(contactX25519: string, contactMasterEd25519?: string) {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const appendMsg = useStore(s => s.appendMessage);
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
|
const matchesChat = useCallback((env: { sender_pub: string; sender_ed25519_pub: string }): boolean => {
|
||||||
|
if (contactMasterEd25519 && env.sender_ed25519_pub === contactMasterEd25519) return true;
|
||||||
|
return env.sender_pub === contactX25519;
|
||||||
|
}, [contactX25519, contactMasterEd25519]);
|
||||||
|
|
||||||
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
||||||
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
||||||
// история старше недели пропадает при каждом рестарте приложения.
|
// история старше недели пропадает при каждом рестарте приложения.
|
||||||
@@ -48,8 +64,8 @@ export function useMessages(contactX25519: string) {
|
|||||||
try {
|
try {
|
||||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
for (const env of envelopes) {
|
for (const env of envelopes) {
|
||||||
// Only process messages from this contact
|
// Only process messages that belong to this chat (see matchesChat).
|
||||||
if (env.sender_pub !== contactX25519) continue;
|
if (!matchesChat(env)) continue;
|
||||||
|
|
||||||
const text = decryptMessage(
|
const text = decryptMessage(
|
||||||
env.ciphertext,
|
env.ciphertext,
|
||||||
@@ -130,10 +146,17 @@ export function useMessages(contactX25519: string) {
|
|||||||
// the handler so we only render messages in THIS chat.
|
// the handler so we only render messages in THIS chat.
|
||||||
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||||
if (frame.event !== 'inbox') return;
|
if (frame.event !== 'inbox') return;
|
||||||
const d = frame.data as { sender_pub?: string } | undefined;
|
const d = frame.data as {
|
||||||
// Optimisation: if the envelope is from a different peer, skip the
|
sender_pub?: string; sender_ed25519_pub?: string;
|
||||||
// whole refetch — we'd just drop it in the sender filter below anyway.
|
} | undefined;
|
||||||
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
|
// Optimisation: if the envelope definitely isn't for this chat,
|
||||||
|
// skip the whole refetch. Multi-device aware — the peer may be
|
||||||
|
// writing from any of their linked devices (different X25519
|
||||||
|
// pubs), so we check against their master Ed25519 too.
|
||||||
|
if (d && !matchesChat({
|
||||||
|
sender_pub: d.sender_pub ?? '',
|
||||||
|
sender_ed25519_pub: d.sender_ed25519_pub ?? '',
|
||||||
|
})) return;
|
||||||
pullAndDecrypt();
|
pullAndDecrypt();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -262,14 +262,17 @@ export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord
|
|||||||
* совместимости с crypto.ts (decryptMessage принимает hex).
|
* совместимости с crypto.ts (decryptMessage принимает hex).
|
||||||
*/
|
*/
|
||||||
interface InboxItemWire {
|
interface InboxItemWire {
|
||||||
id: string;
|
id: string;
|
||||||
sender_pub: string;
|
sender_pub: string;
|
||||||
recipient_pub: string;
|
/** sender_ed25519_pub was added in v2.2.0; older nodes omit it.
|
||||||
fee_ut?: number;
|
Default to empty string when missing. */
|
||||||
sent_at: number;
|
sender_ed25519_pub?: string;
|
||||||
sent_at_human?: string;
|
recipient_pub: string;
|
||||||
nonce: string; // base64
|
fee_ut?: number;
|
||||||
ciphertext: string; // base64
|
sent_at: number;
|
||||||
|
sent_at_human?: string;
|
||||||
|
nonce: string; // base64
|
||||||
|
ciphertext: string; // base64
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InboxResponseWire {
|
interface InboxResponseWire {
|
||||||
@@ -326,12 +329,13 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
|||||||
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
|
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
|
||||||
const items = Array.isArray(resp?.items) ? resp.items : [];
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||||
return items.map((it): Envelope => ({
|
return items.map((it): Envelope => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
sender_pub: it.sender_pub,
|
sender_pub: it.sender_pub,
|
||||||
recipient_pub: it.recipient_pub,
|
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||||
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
recipient_pub: it.recipient_pub,
|
||||||
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||||
timestamp: it.sent_at ?? 0,
|
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||||
|
timestamp: it.sent_at ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +369,25 @@ export interface IdentityInfo {
|
|||||||
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
||||||
nickname: string;
|
nickname: string;
|
||||||
registered: boolean;
|
registered: boolean;
|
||||||
|
/**
|
||||||
|
* Number of active (non-revoked) devices linked to this master identity
|
||||||
|
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
|
||||||
|
* a single X25519 via REGISTER_KEY — senders should fall back to
|
||||||
|
* `x25519_pub` above and skip the device fan-out path.
|
||||||
|
*/
|
||||||
|
device_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One active device in an identity's multi-device registry. Returned by
|
||||||
|
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
|
||||||
|
* list to fan out one sealed envelope per X25519 pub so all of the
|
||||||
|
* recipient's devices receive the message.
|
||||||
|
*/
|
||||||
|
export interface DeviceInfo {
|
||||||
|
x25519_pub_key: string;
|
||||||
|
device_name: string;
|
||||||
|
added_at: number; // unix seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -409,6 +432,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
|
||||||
|
|
||||||
|
interface DevicesResponse {
|
||||||
|
master_pub: string;
|
||||||
|
count: number;
|
||||||
|
devices: DeviceInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/devices/{master_pub} — all active (non-revoked) device records
|
||||||
|
* for the given master identity. Returns an empty array for a legacy
|
||||||
|
* identity (device_count == 0) or a network error — callers should treat
|
||||||
|
* both the same way and fall back to IdentityInfo.x25519_pub so the
|
||||||
|
* pre-v2.2.0 single-device path keeps working.
|
||||||
|
*/
|
||||||
|
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
|
||||||
|
try {
|
||||||
|
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
|
||||||
|
return resp.devices ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
|
||||||
|
* Two paths, in priority order:
|
||||||
|
*
|
||||||
|
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
|
||||||
|
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
|
||||||
|
* (pre-v2.2.0 clients). Send to just that one.
|
||||||
|
*
|
||||||
|
* Returns an empty array only when the recipient has published nothing
|
||||||
|
* at all — caller must surface "no encryption key" to the user rather
|
||||||
|
* than drop the message on the floor.
|
||||||
|
*/
|
||||||
|
export async function resolveRecipientKeys(
|
||||||
|
recipientMasterPub: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const devs = await fetchDevices(recipientMasterPub);
|
||||||
|
if (devs.length > 0) {
|
||||||
|
return devs.map(d => d.x25519_pub_key);
|
||||||
|
}
|
||||||
|
const identity = await getIdentity(recipientMasterPub);
|
||||||
|
if (identity?.x25519_pub) {
|
||||||
|
return [identity.x25519_pub];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Contract API ─────────────────────────────────────────────────────────────
|
// ─── Contract API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -712,6 +785,68 @@ export function buildTransferTx(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
|
||||||
|
* identity's device registry so senders can fan out envelopes across
|
||||||
|
* every active device. Signed by the master Ed25519 (= `from`).
|
||||||
|
*
|
||||||
|
* `deviceName` is a short human label shown in Settings → Devices
|
||||||
|
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
|
||||||
|
*/
|
||||||
|
export function buildLinkDeviceTx(params: {
|
||||||
|
from: string; // master Ed25519 pubkey
|
||||||
|
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
|
||||||
|
deviceName: string;
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payloadObj = {
|
||||||
|
x25519_pub_key: params.x25519Pub,
|
||||||
|
device_name: params.deviceName,
|
||||||
|
};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'LINK_DEVICE', from: params.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'LINK_DEVICE', from: params.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
|
||||||
|
* stop shipping envelopes to its X25519 pub. The revoked device itself,
|
||||||
|
* when it next comes online and sees its own pub in the revoked list,
|
||||||
|
* is expected to wipe local state (master priv + cached chats).
|
||||||
|
*/
|
||||||
|
export function buildUnlinkDeviceTx(params: {
|
||||||
|
from: string; // master Ed25519 pubkey
|
||||||
|
x25519Pub: string; // pub to revoke
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payloadObj = { x25519_pub_key: params.x25519Pub };
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CONTACT_REQUEST transaction.
|
* CONTACT_REQUEST transaction.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
|
|||||||
const CONTACTS_KEY = 'dchain_contacts';
|
const CONTACTS_KEY = 'dchain_contacts';
|
||||||
const SETTINGS_KEY = 'dchain_settings';
|
const SETTINGS_KEY = 'dchain_settings';
|
||||||
const CHATS_KEY = 'dchain_chats';
|
const CHATS_KEY = 'dchain_chats';
|
||||||
|
// Remembers (locally, per install) that this device's X25519 pub has been
|
||||||
|
// successfully linked on-chain at least once. Distinguishes "first boot,
|
||||||
|
// not registered yet" from "we were registered and then revoked by another
|
||||||
|
// device". The second case triggers self-wipe. Stored in AsyncStorage —
|
||||||
|
// if it's missing, we simply re-link.
|
||||||
|
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||||
|
|
||||||
/** Save the key file in secure storage (encrypted on device). */
|
/** Save the key file in secure storage (encrypted on device). */
|
||||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||||
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
|
|||||||
const trimmed = msgs.slice(-500);
|
const trimmed = msgs.slice(-500);
|
||||||
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isDeviceRegistered returns true if this device has ever successfully
|
||||||
|
* linked its X25519 pub on-chain under the current master identity.
|
||||||
|
* A true-then-absent transition (registered → not in chain's active list)
|
||||||
|
* is interpreted as a remote revoke and triggers self-wipe.
|
||||||
|
*/
|
||||||
|
export async function isDeviceRegistered(): Promise<boolean> {
|
||||||
|
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
|
||||||
|
observed in the registry on startup. */
|
||||||
|
export async function markDeviceRegistered(): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
|
||||||
|
explicit logout. */
|
||||||
|
export async function clearDeviceRegistered(): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wipeAllLocalState zeroes out every on-device artifact tied to the
|
||||||
|
* current identity: secure-store key, settings, contacts, chats cache,
|
||||||
|
* registered-device marker. Safe to call multiple times.
|
||||||
|
*
|
||||||
|
* Called in two scenarios:
|
||||||
|
* 1. Explicit "Delete account" in Settings.
|
||||||
|
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
|
||||||
|
* in the active registry but we previously marked it registered,
|
||||||
|
* so another device issued UNLINK_DEVICE against us. We must not
|
||||||
|
* keep using the master priv any more — it still works at the
|
||||||
|
* crypto level, but the social contract is that we're revoked.
|
||||||
|
*/
|
||||||
|
export async function wipeAllLocalState(): Promise<void> {
|
||||||
|
// Secure store (key).
|
||||||
|
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
|
||||||
|
// AsyncStorage — enumerate our known keys. We don't clear() the whole
|
||||||
|
// store because a share-provider or other app shard could live there.
|
||||||
|
const ks = await AsyncStorage.getAllKeys();
|
||||||
|
const ours = ks.filter(k =>
|
||||||
|
k === CONTACTS_KEY ||
|
||||||
|
k === SETTINGS_KEY ||
|
||||||
|
k === DEVICE_REGISTERED_KEY ||
|
||||||
|
k.startsWith(`${CHATS_KEY}_`),
|
||||||
|
);
|
||||||
|
if (ours.length > 0) {
|
||||||
|
await AsyncStorage.multiRemove(ours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,19 @@ export interface Contact {
|
|||||||
export interface Envelope {
|
export interface Envelope {
|
||||||
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
||||||
id: string;
|
id: string;
|
||||||
sender_pub: string; // X25519 hex
|
sender_pub: string; // X25519 hex (this envelope's per-device sender key)
|
||||||
|
/**
|
||||||
|
* sender_ed25519_pub (v2.2.0+): the sender's master Ed25519 identity.
|
||||||
|
* Multiple X25519 pubs under the same identity all share one master —
|
||||||
|
* clients use THIS to group messages into a single conversation even
|
||||||
|
* when the sender replies from different devices.
|
||||||
|
*
|
||||||
|
* Empty string on legacy envelopes from pre-v2.2.0 senders. Consumers
|
||||||
|
* should fall back to `sender_pub` in that case (keeps old clients'
|
||||||
|
* messages visible, even if attribution is per-X25519 rather than
|
||||||
|
* per-identity).
|
||||||
|
*/
|
||||||
|
sender_ed25519_pub: string;
|
||||||
recipient_pub: string; // X25519 hex
|
recipient_pub: string; // X25519 hex
|
||||||
nonce: string; // hex 24 bytes
|
nonce: string; // hex 24 bytes
|
||||||
ciphertext: string; // hex NaCl box
|
ciphertext: string; // hex NaCl box
|
||||||
|
|||||||
@@ -684,11 +684,17 @@ func main() {
|
|||||||
// /relay/inbox if it needs the full envelope. Keeps WS frames small and
|
// /relay/inbox if it needs the full envelope. Keeps WS frames small and
|
||||||
// avoids a fat push for every message.
|
// avoids a fat push for every message.
|
||||||
mailbox.SetOnStore(func(env *relay.Envelope) {
|
mailbox.SetOnStore(func(env *relay.Envelope) {
|
||||||
|
// Summary only — no ciphertext. Multi-device (v2.2.0+) clients
|
||||||
|
// use sender_ed25519_pub to decide whether the envelope belongs
|
||||||
|
// to the chat they're currently viewing (messages from any of
|
||||||
|
// the peer's linked devices share a master identity), so the
|
||||||
|
// field must be in every push.
|
||||||
sum, _ := json.Marshal(map[string]any{
|
sum, _ := json.Marshal(map[string]any{
|
||||||
"id": env.ID,
|
"id": env.ID,
|
||||||
"recipient_pub": env.RecipientPub,
|
"recipient_pub": env.RecipientPub,
|
||||||
"sender_pub": env.SenderPub,
|
"sender_pub": env.SenderPub,
|
||||||
"sent_at": env.SentAt,
|
"sender_ed25519_pub": env.SenderEd25519PubKey,
|
||||||
|
"sent_at": env.SentAt,
|
||||||
})
|
})
|
||||||
eventBus.EmitInbox(env.RecipientPub, sum)
|
eventBus.EmitInbox(env.RecipientPub, sum)
|
||||||
})
|
})
|
||||||
@@ -878,6 +884,7 @@ func main() {
|
|||||||
IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) {
|
IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) {
|
||||||
return chain.IdentityInfo(pubKeyOrAddr)
|
return chain.IdentityInfo(pubKeyOrAddr)
|
||||||
},
|
},
|
||||||
|
DevicesOf: chain.DevicesOf,
|
||||||
ValidatorSet: chain.ValidatorSet,
|
ValidatorSet: chain.ValidatorSet,
|
||||||
SubmitTx: func(tx *blockchain.Transaction) error {
|
SubmitTx: func(tx *blockchain.Transaction) error {
|
||||||
if err := engine.AddTransaction(tx); err != nil {
|
if err := engine.AddTransaction(tx); err != nil {
|
||||||
|
|||||||
202
deploy/single/join.sh
Normal file
202
deploy/single/join.sh
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# rejoin.sh — полная переустановка dchain joiner-ноды с нуля.
|
||||||
|
# Публичный доступ без Caddy/TLS, без API-токена (на свой страх и риск).
|
||||||
|
# Запускать БЕЗ sudo.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── КОНФИГ ─────────────────────────────────────────────────────────────
|
||||||
|
SEED_HTTP="${SEED_HTTP:-http://62.171.151.182:8082}"
|
||||||
|
SEED_P2P_IP="${SEED_P2P_IP:-62.171.151.182}"
|
||||||
|
SEED_P2P_PORT="${SEED_P2P_PORT:-4005}"
|
||||||
|
REPO_URL="${REPO_URL:-https://git.vsecoder.vodka/vsecoder/dchain.git}"
|
||||||
|
WORKDIR="${WORKDIR:-$HOME/dchain}"
|
||||||
|
LOCAL_P2P_PORT="${LOCAL_P2P_PORT:-4001}"
|
||||||
|
LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-8082}"
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31mXX\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
need() { command -v "$1" >/dev/null || die "need $1 installed"; }
|
||||||
|
|
||||||
|
# ── 0. Требования ──────────────────────────────────────────────────────
|
||||||
|
[[ $EUID -eq 0 ]] && die "don't run as root — use your user account, sudo is used per-command"
|
||||||
|
need docker; need git; need curl; need jq
|
||||||
|
sudo -v
|
||||||
|
|
||||||
|
# ── 1. Снести предыдущее состояние ─────────────────────────────────────
|
||||||
|
log "stopping any existing dchain stack & purging volumes"
|
||||||
|
if [[ -d "$WORKDIR/deploy/single" ]]; then
|
||||||
|
(cd "$WORKDIR/deploy/single" && sudo docker compose down -v 2>/dev/null) || true
|
||||||
|
fi
|
||||||
|
sudo docker rm -f dchain_node dchain_caddy dchain 2>/dev/null || true
|
||||||
|
mapfile -t stale_vols < <(sudo docker volume ls -q | grep -E '^(dchain|dchain-single)' || true)
|
||||||
|
(( ${#stale_vols[@]} > 0 )) && sudo docker volume rm "${stale_vols[@]}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 2. Свежий репозиторий ──────────────────────────────────────────────
|
||||||
|
log "fetching repo → $WORKDIR"
|
||||||
|
if [[ -d "$WORKDIR/.git" ]]; then
|
||||||
|
git -C "$WORKDIR" fetch --all --tags --prune
|
||||||
|
git -C "$WORKDIR" reset --hard origin/main
|
||||||
|
else
|
||||||
|
rm -rf "$WORKDIR"
|
||||||
|
git clone "$REPO_URL" "$WORKDIR"
|
||||||
|
fi
|
||||||
|
cd "$WORKDIR/deploy/single"
|
||||||
|
|
||||||
|
# ── 3. keys/ под UID 100 ───────────────────────────────────────────────
|
||||||
|
log "preparing keys/ for container UID 100:101"
|
||||||
|
mkdir -p keys
|
||||||
|
sudo chown 100:101 keys
|
||||||
|
sudo chmod 755 keys
|
||||||
|
[[ -f keys/node.json ]] && { sudo chown 100:101 keys/node.json; sudo chmod 600 keys/node.json; }
|
||||||
|
|
||||||
|
# ── 4. Slim-образ ──────────────────────────────────────────────────────
|
||||||
|
log "building dchain image (slim)"
|
||||||
|
IMAGE=$(sudo docker build -q -f ../prod/Dockerfile.slim ../..)
|
||||||
|
[[ -z "$IMAGE" ]] && die "docker build failed"
|
||||||
|
|
||||||
|
# ── 5. Ключ ноды ───────────────────────────────────────────────────────
|
||||||
|
if [[ ! -f keys/node.json ]]; then
|
||||||
|
log "generating node identity"
|
||||||
|
sudo docker run --rm --entrypoint /usr/local/bin/client \
|
||||||
|
--user 100:101 \
|
||||||
|
-v "$PWD/keys:/out" \
|
||||||
|
"$IMAGE" \
|
||||||
|
keygen --out /out/node.json
|
||||||
|
else
|
||||||
|
log "reusing existing keys/node.json"
|
||||||
|
fi
|
||||||
|
sudo chown 100:101 keys/node.json
|
||||||
|
sudo chmod 600 keys/node.json
|
||||||
|
|
||||||
|
# ── 6. peer_id seed'a ──────────────────────────────────────────────────
|
||||||
|
log "fetching seed peer_id from $SEED_HTTP/stats"
|
||||||
|
SEED_PEER_ID=$(curl -sfL "$SEED_HTTP/stats" | jq -r '.node.peer_id // empty')
|
||||||
|
[[ -z "$SEED_PEER_ID" ]] && die "can't reach seed at $SEED_HTTP or peer_id missing"
|
||||||
|
log "seed peer_id = $SEED_PEER_ID"
|
||||||
|
|
||||||
|
# ── 7. Публичный IP ────────────────────────────────────────────────────
|
||||||
|
PUBLIC_IP="${PUBLIC_IP:-$(curl -sfL https://api.ipify.org || true)}"
|
||||||
|
[[ -z "$PUBLIC_IP" ]] && die "can't detect public IP; export PUBLIC_IP=x.x.x.x и перезапусти"
|
||||||
|
log "public IP = $PUBLIC_IP"
|
||||||
|
|
||||||
|
# ── 8. node.env (без токена, без genesis) ──────────────────────────────
|
||||||
|
log "writing node.env"
|
||||||
|
cp -f node.env.example node.env
|
||||||
|
|
||||||
|
# удалить из example'а значения, которые мы хотим держать под своим контролем,
|
||||||
|
# чтобы они не перекрыли наши append'ы снизу
|
||||||
|
sudo sed -i -E '/^(#\s*)?DCHAIN_(GENESIS|API_TOKEN|API_PRIVATE|JOIN|PEERS|ANNOUNCE|DB|MAILBOX_DB|FEED_DB|REGISTER_RELAY|RELAY_FEE|FEED_DISK_LIMIT_MB|CHAIN_DISK_LIMIT_MB|UPDATE_SOURCE_URL)=/d' node.env
|
||||||
|
|
||||||
|
sudo tee -a node.env > /dev/null <<EOF
|
||||||
|
|
||||||
|
# ── AUTO-GENERATED BY rejoin.sh ─────────────────────────────────────
|
||||||
|
# joiner → seed $SEED_HTTP (public node, no token)
|
||||||
|
DCHAIN_JOIN=$SEED_HTTP
|
||||||
|
DCHAIN_PEERS=/ip4/$SEED_P2P_IP/tcp/$SEED_P2P_PORT/p2p/$SEED_PEER_ID
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/$PUBLIC_IP/tcp/$LOCAL_P2P_PORT
|
||||||
|
|
||||||
|
DCHAIN_DB=/data/chain
|
||||||
|
DCHAIN_MAILBOX_DB=/data/mailbox
|
||||||
|
DCHAIN_FEED_DB=/data/feed
|
||||||
|
|
||||||
|
DCHAIN_REGISTER_RELAY=true
|
||||||
|
DCHAIN_RELAY_FEE=1000
|
||||||
|
|
||||||
|
DCHAIN_FEED_DISK_LIMIT_MB=4096
|
||||||
|
DCHAIN_CHAIN_DISK_LIMIT_MB=20480
|
||||||
|
|
||||||
|
DCHAIN_UPDATE_SOURCE_URL=https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── 9. Compose: прямой проброс 4001+8080 наружу, Caddy снести навсегда ─
|
||||||
|
log "patching docker-compose.yml: direct ports, caddy removed"
|
||||||
|
python3 - <<PY
|
||||||
|
import re, pathlib
|
||||||
|
p = pathlib.Path('docker-compose.yml')
|
||||||
|
src = p.read_text()
|
||||||
|
|
||||||
|
# 9a. Вырезать весь сервис caddy (от " caddy:" до следующего сервиса
|
||||||
|
# того же уровня или конца блока services).
|
||||||
|
src = re.sub(
|
||||||
|
r'(?ms)^ caddy:\n(?:(?: .*\n)|\n)+?(?=^ [A-Za-z_-]+:|\Z)',
|
||||||
|
'', src,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9b. Убрать зависимости от caddy, если где-то остались.
|
||||||
|
src = re.sub(r'(?m)^\s*-\s*caddy\s*$\n', '', src)
|
||||||
|
src = re.sub(r'(?m)^\s*depends_on:\s*\n(\s*-\s*caddy\s*\n)+', '', src)
|
||||||
|
|
||||||
|
# 9c. Заставить node светить наружу 4001 (libp2p) и 8080 (HTTP).
|
||||||
|
# Ищем первый ports: в сервисе node; если его нет — инжектим.
|
||||||
|
m = re.search(r'(?ms)^ node:\n(.*?)(?=^ [A-Za-z_-]+:|\Z)', src)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit('no `node:` service in compose')
|
||||||
|
node_block = m.group(1)
|
||||||
|
|
||||||
|
wanted = f' ports:\n - "$LOCAL_P2P_PORT:4001"\n - "$LOCAL_HTTP_PORT:8080"\n'
|
||||||
|
|
||||||
|
if re.search(r'(?m)^ ports:', node_block):
|
||||||
|
# Заменить существующий блок ports целиком
|
||||||
|
node_block_new = re.sub(
|
||||||
|
r'(?ms)^ ports:\n(?: -.*\n)+',
|
||||||
|
wanted, node_block,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Добавить сразу после строки "container_name:" (или restart:)
|
||||||
|
node_block_new = re.sub(
|
||||||
|
r'(?m)^( (?:container_name|restart):.*\n)',
|
||||||
|
r'\\1' + wanted, node_block, count=1,
|
||||||
|
)
|
||||||
|
if node_block_new == node_block: # fallback — в начало блока
|
||||||
|
node_block_new = wanted + node_block
|
||||||
|
|
||||||
|
src = src[:m.start(1)] + node_block_new + src[m.end(1):]
|
||||||
|
|
||||||
|
# 9d. Убрать блок expose у node — не нужен, раз ports наружу.
|
||||||
|
src = re.sub(
|
||||||
|
r'(?ms)^ expose:\n(?: -.*\n)+', '', src,
|
||||||
|
)
|
||||||
|
|
||||||
|
p.write_text(src)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# ── 10. Подъём ─────────────────────────────────────────────────────────
|
||||||
|
log "docker compose up -d"
|
||||||
|
sudo docker compose up -d --build
|
||||||
|
|
||||||
|
# ── 11. Sanity checks ──────────────────────────────────────────────────
|
||||||
|
sleep 3
|
||||||
|
log "sanity checks (дай ~30 сек чтобы healthcheck подхватился):"
|
||||||
|
set +e
|
||||||
|
echo "──── docker compose ps ────"
|
||||||
|
sudo docker compose ps
|
||||||
|
echo "──── docker logs dchain_node | tail ────"
|
||||||
|
sudo docker logs --tail 30 dchain_node 2>&1
|
||||||
|
echo "──── /api/netstats ────"
|
||||||
|
curl -s "http://localhost:$LOCAL_HTTP_PORT/api/netstats" | jq '.'
|
||||||
|
echo "──── seed height (для сверки) ────"
|
||||||
|
curl -s "$SEED_HTTP/api/netstats" | jq '.total_blocks'
|
||||||
|
|
||||||
|
cat <<EOM
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
✓ Готово. Нода публичная, без токена и без TLS.
|
||||||
|
|
||||||
|
API:
|
||||||
|
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/api/netstats
|
||||||
|
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/swagger
|
||||||
|
|
||||||
|
Следи за sync'ом:
|
||||||
|
sudo docker logs -f dchain_node | grep -E 'applied|height|peer'
|
||||||
|
|
||||||
|
Повторный полный сброс:
|
||||||
|
./rejoin.sh
|
||||||
|
|
||||||
|
Открой в firewall/security-group на этом VPS:
|
||||||
|
- $LOCAL_P2P_PORT/tcp (libp2p)
|
||||||
|
- $LOCAL_HTTP_PORT/tcp (HTTP API)
|
||||||
|
======================================================================
|
||||||
|
EOM
|
||||||
9
desktop/.gitignore
vendored
Normal file
9
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# electron-builder output
|
||||||
|
out/
|
||||||
76
desktop/README.md
Normal file
76
desktop/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# DChain Desktop
|
||||||
|
|
||||||
|
Electron shell for the DChain messenger and social feed.
|
||||||
|
|
||||||
|
Same functionality as the mobile client-app, re-imagined with a
|
||||||
|
keyboard-first, 3-panel desktop layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ DChain │ titlebar (drag)
|
||||||
|
├──────┬───────────────────┬────────────────────────────────┤
|
||||||
|
│ nav │ list │ detail │
|
||||||
|
│ 72px │ 340px fixed │ flex 1 │
|
||||||
|
├──────┴───────────────────┴────────────────────────────────┤
|
||||||
|
│ ● online · node.example:8080 · height 10942 │ status bar
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections (left rail): **Messages · Feed · Wallet · Contacts · Settings · Profile**.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
npm run dev # concurrently: Vite dev server + Electron
|
||||||
|
```
|
||||||
|
|
||||||
|
The first boot will show the Welcome screen. Pick Create to generate
|
||||||
|
fresh keys, or Import a `node.json` exported from the mobile client.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # produces dist/ (renderer) + dist-electron/ (main) + installers
|
||||||
|
```
|
||||||
|
|
||||||
|
Default installers are built with `electron-builder`: `.dmg` on macOS,
|
||||||
|
NSIS `.exe` on Windows, AppImage + `.deb` on Linux. Adjust `build.*` in
|
||||||
|
`package.json` for signing / notarisation.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `electron/` — main + preload. TypeScript, compiled to `dist-electron/`
|
||||||
|
by `tsc -p electron/tsconfig.json`.
|
||||||
|
- `src/` — renderer. React + Vite. `@/` aliases to `src/`.
|
||||||
|
- `src/shell/` — 3-panel chrome.
|
||||||
|
- `src/sections/` — one folder per nav section, each exports `{ List, Detail }`.
|
||||||
|
- `src/auth/Welcome.tsx` — shown when no key is loaded.
|
||||||
|
- `src/lib/` — api, storage, store, types. Mirrors (without React-Native
|
||||||
|
deps) the relevant pieces of `../client-app/lib/`.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
Master Ed25519 priv lives in the OS keychain via Electron `safeStorage`
|
||||||
|
(macOS Keychain / Windows DPAPI / libsecret). A renderer compromise
|
||||||
|
cannot read or exfiltrate the key — it always travels through
|
||||||
|
`window.dchain.keyfile.*` IPC, which main.ts validates and mediates.
|
||||||
|
|
||||||
|
`contextIsolation: true`, `nodeIntegration: false`. CSP in `index.html`
|
||||||
|
pins script sources to `'self'` while allowing `connect-src *` so the
|
||||||
|
renderer can hit any node the user configures.
|
||||||
|
|
||||||
|
## Pairing (v2.2.0-alpha5+)
|
||||||
|
|
||||||
|
Desktop will reuse the same 6-digit-code + relay-envelope handshake as
|
||||||
|
the mobile client. The scaffold in `src/auth/Welcome.tsx` stubs the
|
||||||
|
button until the polling loop lands.
|
||||||
|
|
||||||
|
## Multi-device fan-out
|
||||||
|
|
||||||
|
When the node is at v2.2.0-alpha1+, `lib/api.ts:fetchDevices` returns
|
||||||
|
every linked X25519 pub for a given identity; the sender then encrypts
|
||||||
|
one envelope per device. Legacy nodes return an empty array and the
|
||||||
|
client falls back to `IdentityInfo.x25519_pub`, preserving the
|
||||||
|
pre-multi-device behaviour.
|
||||||
175
desktop/electron/main.ts
Normal file
175
desktop/electron/main.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// Electron main process.
|
||||||
|
//
|
||||||
|
// Responsibilities:
|
||||||
|
// * Create the BrowserWindow with a frameless + custom title bar so
|
||||||
|
// the renderer owns the chrome (matches macOS traffic lights and
|
||||||
|
// draws our 3-panel shell without OS padding).
|
||||||
|
// * Bridge safe native APIs to the renderer through preload.ts using
|
||||||
|
// contextBridge — keeps the renderer sandboxed (contextIsolation on,
|
||||||
|
// nodeIntegration off).
|
||||||
|
// * Deep-link handler for dchain://chat/<pub> and similar. Stub for now.
|
||||||
|
//
|
||||||
|
// Everything chain-related (HTTP / WS / crypto) still runs in the
|
||||||
|
// renderer — Electron main stays a thin shell + native capabilities.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage, session } from 'electron';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
// Content-Security-Policy is set here (not in <meta>) so we can diverge
|
||||||
|
// dev vs. production: Vite's HMR uses eval() which needs 'unsafe-eval',
|
||||||
|
// but shipping that in a release build would earn us a security warning
|
||||||
|
// from Electron and weaken XSS defence for no good reason.
|
||||||
|
function installCSP(): void {
|
||||||
|
const policy = isDev
|
||||||
|
? // Dev: permissive enough for Vite HMR (eval + WS) while still
|
||||||
|
// denying random remote scripts. connect-src is wide-open because
|
||||||
|
// the user picks their own node URL at runtime.
|
||||||
|
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; " +
|
||||||
|
"connect-src 'self' ws: wss: http: https:; " +
|
||||||
|
"img-src 'self' data: blob: http: https:;"
|
||||||
|
: // Prod: no eval, no remote scripts. connect-src stays open so the
|
||||||
|
// user can target any node they configure.
|
||||||
|
"default-src 'self'; " +
|
||||||
|
"script-src 'self'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"connect-src 'self' ws: wss: http: https:; " +
|
||||||
|
"img-src 'self' data: blob: http: https:;";
|
||||||
|
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
|
||||||
|
cb({
|
||||||
|
responseHeaders: {
|
||||||
|
...details.responseHeaders,
|
||||||
|
'Content-Security-Policy': [policy],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
||||||
|
// Expose traffic-light buttons on macOS; Windows/Linux use a custom
|
||||||
|
// title-bar painted by the renderer.
|
||||||
|
titleBarOverlay: process.platform === 'win32' ? {
|
||||||
|
color: '#000000',
|
||||||
|
symbolColor: '#ffffff',
|
||||||
|
height: 32,
|
||||||
|
} : undefined,
|
||||||
|
frame: process.platform === 'darwin',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false, // safeStorage requires non-sandboxed preload
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => mainWindow?.show());
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!);
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open external links (http/https in <a target=_blank>) in the default
|
||||||
|
// browser rather than a new Electron window — safer, and a desktop
|
||||||
|
// user's muscle memory expects this.
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (/^https?:\/\//.test(url)) {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
}
|
||||||
|
return { action: 'allow' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IPC — safe subset bridged into the renderer via preload ────────────
|
||||||
|
|
||||||
|
// Keys are persisted encrypted by the OS keychain via safeStorage.
|
||||||
|
// Fallback to plaintext file only if the user's OS lacks an encryption
|
||||||
|
// backend (surfaced as a warning in Settings → Advanced).
|
||||||
|
const KEYFILE_PATH = () => path.join(app.getPath('userData'), 'keyfile.bin');
|
||||||
|
|
||||||
|
ipcMain.handle('keyfile:load', async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(KEYFILE_PATH());
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
return safeStorage.decryptString(raw);
|
||||||
|
}
|
||||||
|
// File was stored without encryption — treat as plaintext.
|
||||||
|
return raw.toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('keyfile:save', async (_e, json: string): Promise<void> => {
|
||||||
|
await fs.mkdir(path.dirname(KEYFILE_PATH()), { recursive: true });
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
await fs.writeFile(KEYFILE_PATH(), safeStorage.encryptString(json));
|
||||||
|
} else {
|
||||||
|
// Surface the insecure path loudly in the renderer's Settings,
|
||||||
|
// but don't refuse — on some Linux boxes libsecret isn't installed
|
||||||
|
// and the user explicitly wants a fallback.
|
||||||
|
await fs.writeFile(KEYFILE_PATH(), json, 'utf8');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('keyfile:delete', async (): Promise<void> => {
|
||||||
|
await fs.rm(KEYFILE_PATH(), { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('keyfile:encryption-available', async (): Promise<boolean> => {
|
||||||
|
return safeStorage.isEncryptionAvailable();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:open-file', async (_e, opts: Electron.OpenDialogOptions) => {
|
||||||
|
if (!mainWindow) return null;
|
||||||
|
const res = await dialog.showOpenDialog(mainWindow, opts);
|
||||||
|
if (res.canceled || res.filePaths.length === 0) return null;
|
||||||
|
return res.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:save-file', async (_e, opts: Electron.SaveDialogOptions) => {
|
||||||
|
if (!mainWindow) return null;
|
||||||
|
const res = await dialog.showSaveDialog(mainWindow, opts);
|
||||||
|
if (res.canceled || !res.filePath) return null;
|
||||||
|
return res.filePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('fs:read-text', async (_e, filePath: string) => {
|
||||||
|
return fs.readFile(filePath, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('fs:write-text', async (_e, filePath: string, contents: string) => {
|
||||||
|
return fs.writeFile(filePath, contents, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('app:version', async () => app.getVersion());
|
||||||
|
ipcMain.handle('app:platform', async () => process.platform);
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
installCSP();
|
||||||
|
createWindow();
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
50
desktop/electron/preload.ts
Normal file
50
desktop/electron/preload.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Preload — the thin bridge between renderer and main.
|
||||||
|
//
|
||||||
|
// Everything exposed here is visible in the renderer as `window.dchain`.
|
||||||
|
// We explicitly pick which IPC channels to surface rather than exposing
|
||||||
|
// `ipcRenderer` wholesale, so a compromised renderer can't spam
|
||||||
|
// arbitrary channels.
|
||||||
|
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
interface OpenDialogOptions {
|
||||||
|
title?: string;
|
||||||
|
defaultPath?: string;
|
||||||
|
filters?: { name: string; extensions: string[] }[];
|
||||||
|
properties?: ('openFile' | 'multiSelections')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveDialogOptions {
|
||||||
|
title?: string;
|
||||||
|
defaultPath?: string;
|
||||||
|
filters?: { name: string; extensions: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
keyfile: {
|
||||||
|
load: (): Promise<string | null> => ipcRenderer.invoke('keyfile:load'),
|
||||||
|
save: (json: string): Promise<void> => ipcRenderer.invoke('keyfile:save', json),
|
||||||
|
delete: (): Promise<void> => ipcRenderer.invoke('keyfile:delete'),
|
||||||
|
encryptionAvailable: (): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('keyfile:encryption-available'),
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
openFile: (opts: OpenDialogOptions): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke('dialog:open-file', opts),
|
||||||
|
saveFile: (opts: SaveDialogOptions): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke('dialog:save-file', opts),
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
readText: (p: string): Promise<string> => ipcRenderer.invoke('fs:read-text', p),
|
||||||
|
writeText: (p: string, c: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('fs:write-text', p, c),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
|
||||||
|
platform: (): Promise<string> => ipcRenderer.invoke('app:platform'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DChainAPI = typeof api;
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('dchain', api);
|
||||||
16
desktop/electron/tsconfig.json
Normal file
16
desktop/electron/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "../dist-electron",
|
||||||
|
"rootDir": ".",
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["main.ts", "preload.ts", "menu.ts"]
|
||||||
|
}
|
||||||
32
desktop/index.html
Normal file
32
desktop/index.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- CSP is applied at HTTP-response level from main.ts via
|
||||||
|
session.webRequest — not in a <meta> here. Vite's dev server
|
||||||
|
needs unsafe-eval for HMR, which breaks a strict meta-CSP at
|
||||||
|
module-load time; setting CSP from main lets us flip
|
||||||
|
dev vs. production rules cleanly. -->
|
||||||
|
<title>DChain</title>
|
||||||
|
<style>
|
||||||
|
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
/* Let text fields + readable text be selectable despite global disable. */
|
||||||
|
input, textarea, [contenteditable], .selectable {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7341
desktop/package-lock.json
generated
Normal file
7341
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
desktop/package.json
Normal file
45
desktop/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "dchain-desktop",
|
||||||
|
"version": "2.2.0-alpha5",
|
||||||
|
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && npm run electron:dev\"",
|
||||||
|
"electron:dev": "npm run build:main && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron dist-electron/main.js",
|
||||||
|
"build": "npm run build:main && vite build && electron-builder",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"build:main": "tsc -p electron/tsconfig.json",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^33.2.1",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"wait-on": "^8.0.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.dchain.desktop",
|
||||||
|
"productName": "DChain",
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"dist-electron/**/*"
|
||||||
|
],
|
||||||
|
"mac": { "target": ["dmg"] },
|
||||||
|
"win": { "target": ["nsis"] },
|
||||||
|
"linux": { "target": ["AppImage", "deb"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
65
desktop/src/App.tsx
Normal file
65
desktop/src/App.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Top-level component. Two responsibilities:
|
||||||
|
// 1. Boot — load key + settings from storage, wire up the API client,
|
||||||
|
// flip the booted flag so we stop showing the black splash.
|
||||||
|
// 2. Render either the Welcome auth flow (no key yet) or the Shell
|
||||||
|
// (3-panel layout + current section).
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||||
|
import { setNodeUrl } from '@/lib/api';
|
||||||
|
import { Shell } from '@/shell/Shell';
|
||||||
|
import { Welcome } from '@/auth/Welcome';
|
||||||
|
|
||||||
|
export function App(): React.ReactElement {
|
||||||
|
const booted = useStore(s => s.booted);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const set = loadSettings();
|
||||||
|
setNodeUrl(set.nodeUrl);
|
||||||
|
useStore.getState().setSettings(set);
|
||||||
|
|
||||||
|
const cs = loadContacts();
|
||||||
|
useStore.getState().setContacts(cs);
|
||||||
|
|
||||||
|
const kf = await loadKeyFile();
|
||||||
|
useStore.getState().setKeyFile(kf);
|
||||||
|
|
||||||
|
useStore.getState().setBooted(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Show the error inline — the boundary only catches render
|
||||||
|
// throws, not async-effect throws like this one.
|
||||||
|
setBootError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (bootError) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 24, color: '#ff6b6b', fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#ff6b6b', margin: 0 }}>Boot failed</h2>
|
||||||
|
<p style={{ color: '#fff', marginTop: 8 }}>{bootError}</p>
|
||||||
|
<p style={{ color: '#8b8b8b', fontSize: 12, marginTop: 12 }}>
|
||||||
|
This usually means the Electron preload script didn't load.
|
||||||
|
Check that `npm run build:main` has produced `dist-electron/preload.js`
|
||||||
|
and restart `npm run dev`.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!booted) {
|
||||||
|
// Matches the splash: whole window is already black from index.html,
|
||||||
|
// so showing nothing is the right behaviour — no flash, no spinner.
|
||||||
|
return <div style={{ height: '100%' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyFile ? <Shell /> : <Welcome />;
|
||||||
|
}
|
||||||
55
desktop/src/ErrorBoundary.tsx
Normal file
55
desktop/src/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Top-level error boundary. React eats thrown errors silently by default,
|
||||||
|
// which in an Electron app with no URL bar means "blank window, nothing
|
||||||
|
// to click" from the user's perspective. This component at least shows
|
||||||
|
// the error text + stack so we can copy-paste it into a bug report.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode }, State
|
||||||
|
> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
// Surface the exception in the devtools console too, for quick
|
||||||
|
// copy-paste when the boundary is blocking the UI.
|
||||||
|
console.error('[ErrorBoundary]', error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 24, height: '100%', overflow: 'auto',
|
||||||
|
background: '#000', color: '#fff', fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#ff6b6b', marginTop: 0 }}>Something broke.</h2>
|
||||||
|
<p style={{ color: '#fff' }}>{this.state.error.message}</p>
|
||||||
|
<pre style={{
|
||||||
|
color: '#8b8b8b', fontSize: 12, lineHeight: 1.4,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ error: null })}
|
||||||
|
style={{
|
||||||
|
marginTop: 12, padding: '8px 14px', borderRadius: 999,
|
||||||
|
border: '1px solid #1f1f1f', background: '#111',
|
||||||
|
color: '#fff', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
desktop/src/auth/Pair.tsx
Normal file
198
desktop/src/auth/Pair.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Pair screen — secondary-device onboarding on desktop.
|
||||||
|
//
|
||||||
|
// Same protocol as mobile's app/(auth)/pair.tsx:
|
||||||
|
// 1. Generate a local X25519 keypair + random 6-digit code.
|
||||||
|
// 2. Display them so the operator can transcribe onto their primary
|
||||||
|
// device (mobile Settings → Devices → Link new device).
|
||||||
|
// 3. Poll /relay/inbox every 2.5s waiting for a handshake envelope.
|
||||||
|
// 4. On a decryptable payload with matching {v, type, code}, assemble
|
||||||
|
// a KeyFile (master Ed25519 from the envelope + this session's
|
||||||
|
// X25519 keypair) and persist — App then promotes us into Shell.
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { bytesToHex, decryptMessage } from '@/lib/crypto';
|
||||||
|
import { fetchInbox } from '@/lib/relay';
|
||||||
|
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
|
||||||
|
const PAIR_VERSION = 1;
|
||||||
|
|
||||||
|
interface PairPayload {
|
||||||
|
v: number;
|
||||||
|
type: 'pair-handshake';
|
||||||
|
code: string;
|
||||||
|
master_pub: string;
|
||||||
|
master_priv: string;
|
||||||
|
master_x25519_pub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
x25519Pub: string;
|
||||||
|
x25519Priv: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomCode(): string {
|
||||||
|
return Math.floor(Math.random() * 1_000_000).toString().padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function genSession(): Session {
|
||||||
|
const kp = nacl.box.keyPair();
|
||||||
|
return {
|
||||||
|
x25519Pub: bytesToHex(kp.publicKey),
|
||||||
|
x25519Priv: bytesToHex(kp.secretKey),
|
||||||
|
code: randomCode(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pair({ onBack }: { onBack: () => void }): React.ReactElement {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const session = useRef<Session>(genSession()).current;
|
||||||
|
const [status, setStatus] = useState<'waiting' | 'success'>('waiting');
|
||||||
|
|
||||||
|
const copy = useCallback((text: string) => {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const envs = await fetchInbox(session.x25519Pub);
|
||||||
|
for (const env of envs) {
|
||||||
|
const plain = decryptMessage(
|
||||||
|
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
|
||||||
|
);
|
||||||
|
if (!plain) continue;
|
||||||
|
let payload: PairPayload;
|
||||||
|
try { payload = JSON.parse(plain); } catch { continue; }
|
||||||
|
if (
|
||||||
|
payload.v !== PAIR_VERSION ||
|
||||||
|
payload.type !== 'pair-handshake' ||
|
||||||
|
payload.code !== session.code ||
|
||||||
|
!payload.master_pub || !payload.master_priv
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
const kf: KeyFile = {
|
||||||
|
pub_key: payload.master_pub,
|
||||||
|
priv_key: payload.master_priv,
|
||||||
|
x25519_pub: session.x25519Pub,
|
||||||
|
x25519_priv: session.x25519Priv,
|
||||||
|
};
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
markDeviceRegistered();
|
||||||
|
setKeyFile(kf);
|
||||||
|
setStatus('success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* next tick */ }
|
||||||
|
if (!cancelled) timer = setTimeout(tick, 2_500);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [session, setKeyFile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, background: '#000', color: '#fff',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 440, width: '100%' }}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
marginBottom: 18, padding: '6px 10px', borderRadius: 999,
|
||||||
|
background: 'transparent', color: '#8b8b8b', fontSize: 13,
|
||||||
|
border: '1px solid #1f1f1f', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 800, margin: '0 0 8px' }}>
|
||||||
|
Pair with your other device
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#8b8b8b', fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||||
|
On a device where you're already signed in, open
|
||||||
|
Settings → Devices → Link new device and
|
||||||
|
enter these two values.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Code */}
|
||||||
|
<Card title="1. Code">
|
||||||
|
<div style={{
|
||||||
|
color: '#fff', fontFamily: 'monospace', fontSize: 34,
|
||||||
|
fontWeight: 800, letterSpacing: 6, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{session.code.slice(0, 3)} {session.code.slice(3)}
|
||||||
|
</div>
|
||||||
|
<CopyLink onClick={() => copy(session.code)}>Copy code</CopyLink>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Device key */}
|
||||||
|
<Card title="2. Device key">
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
||||||
|
lineHeight: 1.5, wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{session.x25519Pub}
|
||||||
|
</div>
|
||||||
|
<CopyLink onClick={() => copy(session.x25519Pub)}>Copy key</CopyLink>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 18, textAlign: 'center',
|
||||||
|
color: status === 'success' ? '#3ba55d' : '#8b8b8b',
|
||||||
|
fontSize: 13,
|
||||||
|
}}>
|
||||||
|
{status === 'waiting'
|
||||||
|
? 'Waiting for your other device…'
|
||||||
|
: 'Paired. Opening your chats…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 18, padding: 16, borderRadius: 14,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyLink({ children, onClick }: {
|
||||||
|
children: React.ReactNode; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
marginTop: 8, padding: 0, background: 'transparent',
|
||||||
|
border: 'none', color: '#1d9bf0', fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{children}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
desktop/src/auth/Welcome.tsx
Normal file
142
desktop/src/auth/Welcome.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Welcome — shown when no key is loaded.
|
||||||
|
//
|
||||||
|
// Three options, matching mobile parity:
|
||||||
|
// * Create — generate a new Ed25519 + X25519 keypair.
|
||||||
|
// * Import — load node.json file (dialog).
|
||||||
|
// * Pair — pair with an existing phone/desktop (QR-less, 6-digit code
|
||||||
|
// + device key, symmetrical with mobile's /auth/pair flow).
|
||||||
|
//
|
||||||
|
// v2.2.0-alpha4 wires the first two functionally and stubs Pair with a
|
||||||
|
// button that routes to a placeholder — the pairing poll loop shared
|
||||||
|
// with mobile comes in alpha5.
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
|
import { generateKeyFile } from '@/lib/crypto';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
import { Pair } from './Pair';
|
||||||
|
|
||||||
|
export function Welcome(): React.ReactElement {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [screen, setScreen] = useState<'welcome' | 'pair'>('welcome');
|
||||||
|
|
||||||
|
if (screen === 'pair') return <Pair onBack={() => setScreen('welcome')} />;
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
setBusy(true); setErr(null);
|
||||||
|
try {
|
||||||
|
const kf = generateKeyFile();
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
setKeyFile(kf);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImport = async () => {
|
||||||
|
setBusy(true); setErr(null);
|
||||||
|
try {
|
||||||
|
const file = await window.dchain.dialog.openFile({
|
||||||
|
title: 'Select node.json',
|
||||||
|
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||||
|
properties: ['openFile'],
|
||||||
|
});
|
||||||
|
if (!file) return;
|
||||||
|
const contents = await window.dchain.fs.readText(file);
|
||||||
|
const parsed = JSON.parse(contents) as KeyFile;
|
||||||
|
if (!parsed.pub_key || !parsed.priv_key) {
|
||||||
|
throw new Error('file doesn\'t look like a key file');
|
||||||
|
}
|
||||||
|
await saveKeyFile(parsed);
|
||||||
|
setKeyFile(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPair = () => {
|
||||||
|
setErr(null);
|
||||||
|
setScreen('pair');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, background: '#000', color: '#fff',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 400, width: '100%', textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 80, height: 80, borderRadius: 22,
|
||||||
|
background: '#1d9bf0', margin: '0 auto',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 36, fontWeight: 800,
|
||||||
|
}}>
|
||||||
|
D
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontSize: 30, fontWeight: 800, letterSpacing: -0.5, margin: '16px 0 6px' }}>
|
||||||
|
DChain
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#8b8b8b', fontSize: 14, margin: 0, lineHeight: 1.5 }}>
|
||||||
|
Decentralised messenger + social feed. Your keys stay on this device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 32 }}>
|
||||||
|
<PrimaryBtn label="Create account" onClick={onCreate} disabled={busy} />
|
||||||
|
<SecondaryBtn label="Import key file" onClick={onImport} disabled={busy} />
|
||||||
|
<SecondaryBtn label="Pair with another device" onClick={onPair} disabled={busy} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 20, padding: 10, borderRadius: 10,
|
||||||
|
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryBtn({ label, onClick, disabled }: {
|
||||||
|
label: string; onClick: () => void; disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
height: 46, borderRadius: 999, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff', fontSize: 14, fontWeight: 700,
|
||||||
|
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryBtn({ label, onClick, disabled }: {
|
||||||
|
label: string; onClick: () => void; disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
height: 46, borderRadius: 999,
|
||||||
|
background: '#0a0a0a', color: '#fff', fontSize: 14, fontWeight: 700,
|
||||||
|
border: '1px solid #1f1f1f',
|
||||||
|
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
desktop/src/hooks/useInboxPoll.ts
Normal file
117
desktop/src/hooks/useInboxPoll.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// useInboxPoll — polls GET /relay/inbox for *every* X25519 pub this
|
||||||
|
// device owns (master identity + every linked device). In v2.2.0, senders
|
||||||
|
// fan out one envelope per recipient device, so we need to read all of
|
||||||
|
// them on our side to see messages that were addressed to any of our pubs.
|
||||||
|
//
|
||||||
|
// Poll interval is 4 seconds — desktop is typically always-on, we can
|
||||||
|
// afford this cadence. A WebSocket-based push path is a polish pass away;
|
||||||
|
// for alpha5 the polling loop is plenty responsive.
|
||||||
|
//
|
||||||
|
// Every newly-arrived envelope is:
|
||||||
|
// 1. Decrypted with our X25519 priv + sender's pub (from envelope metadata).
|
||||||
|
// 2. Parsed — today as JSON "pair-handshake" or plain text; group chats
|
||||||
|
// and encrypted payloads with attachments come in later alphas.
|
||||||
|
// 3. Routed: plain text → store.appendMessage + disk; anything we can't
|
||||||
|
// parse is skipped silently (future clients will extend the protocol).
|
||||||
|
//
|
||||||
|
// We keep a local "seen" set keyed by envelope.id so a second poll cycle
|
||||||
|
// doesn't re-deliver an already-consumed envelope while it sits in the
|
||||||
|
// relay mailbox waiting for TTL.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { fetchInbox, type Envelope } from '@/lib/relay';
|
||||||
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
|
import { appendMessage as persistMessage, upsertContact as persistContact } from '@/lib/storage';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
const POLL_MS = 4_000;
|
||||||
|
|
||||||
|
export function useInboxPoll(): void {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const activeChat = useStore(s => s.activeChat);
|
||||||
|
|
||||||
|
// Ref-based so the tick closure sees the latest set without re-running
|
||||||
|
// the whole effect every time a new envelope arrives.
|
||||||
|
const seen = useRef<Set<string>>(new Set());
|
||||||
|
const activeChatRef = useRef<string | null>(activeChat);
|
||||||
|
useEffect(() => { activeChatRef.current = activeChat; }, [activeChat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const envs = await fetchInbox(keyFile.x25519_pub);
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const env of envs) {
|
||||||
|
if (seen.current.has(env.id)) continue;
|
||||||
|
seen.current.add(env.id);
|
||||||
|
consume(env, keyFile.x25519_priv, activeChatRef.current);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient — try again next tick
|
||||||
|
}
|
||||||
|
if (!cancelled) timer = setTimeout(tick, POLL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [keyFile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consume(env: Envelope, myX25519Priv: string, activeChat: string | null): void {
|
||||||
|
const plain = decryptMessage(env.ciphertext, env.nonce, env.sender_pub, myX25519Priv);
|
||||||
|
if (plain === null) return; // not for us / garbage / rotated keys
|
||||||
|
|
||||||
|
// Skip handshake envelopes — the /auth pair flow consumes those
|
||||||
|
// separately before any chat is mounted.
|
||||||
|
if (plain.startsWith('{') && plain.includes('"type":"pair-handshake"')) return;
|
||||||
|
|
||||||
|
// Conversation address = sender's master Ed25519 identity (v2.2.0+).
|
||||||
|
// The envelope now carries this explicitly in `sender_ed25519_pub`,
|
||||||
|
// so a reply from a different linked device still rolls into the
|
||||||
|
// same chat. Pre-v2.2.0 senders leave the field empty; we fall back
|
||||||
|
// to `sender_pub` (the per-device X25519) so legacy peers still
|
||||||
|
// appear as contacts — they'll just be addressed by X25519 until
|
||||||
|
// they upgrade.
|
||||||
|
const from = env.sender_ed25519_pub || env.sender_pub;
|
||||||
|
|
||||||
|
const st = useStore.getState();
|
||||||
|
|
||||||
|
// Create a placeholder contact if we've never seen this peer —
|
||||||
|
// mirrors mobile's behaviour.
|
||||||
|
if (!st.contacts.some(c => c.address === from)) {
|
||||||
|
const c = {
|
||||||
|
address: from,
|
||||||
|
x25519Pub: from,
|
||||||
|
alias: undefined,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
st.upsertContact(c);
|
||||||
|
persistContact(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg: Message = {
|
||||||
|
id: env.id,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text: plain,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
read: false,
|
||||||
|
edited: false,
|
||||||
|
};
|
||||||
|
st.appendMessage(from, msg);
|
||||||
|
persistMessage(from, msg);
|
||||||
|
|
||||||
|
// Only surface an unread badge if the recipient isn't already
|
||||||
|
// looking at this conversation.
|
||||||
|
if (activeChat !== from) {
|
||||||
|
st.bumpUnread(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
desktop/src/lib/api.ts
Normal file
114
desktop/src/lib/api.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Minimal API client for the scaffold. Mirrors the mobile client-app's
|
||||||
|
// lib/api.ts semantics (endpoints, wire shapes) so the two can hit the
|
||||||
|
// same node. As we grow the desktop client, more methods move in here;
|
||||||
|
// for now we only need net-stats + identity + devices + submit-tx +
|
||||||
|
// broadcast-envelope + inbox to drive the shell + pairing.
|
||||||
|
|
||||||
|
const DEFAULT_URL = 'http://localhost:8080';
|
||||||
|
let nodeUrl = DEFAULT_URL;
|
||||||
|
let apiToken: string | null = null;
|
||||||
|
|
||||||
|
const listeners: ((url: string) => void)[] = [];
|
||||||
|
|
||||||
|
export function setNodeUrl(url: string): void {
|
||||||
|
nodeUrl = url.replace(/\/$/, '') || DEFAULT_URL;
|
||||||
|
listeners.forEach(fn => fn(nodeUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeUrl(): string {
|
||||||
|
return nodeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onNodeUrlChange(fn: (url: string) => void): () => void {
|
||||||
|
listeners.push(fn);
|
||||||
|
return () => {
|
||||||
|
const i = listeners.indexOf(fn);
|
||||||
|
if (i >= 0) listeners.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiToken(t: string | null): void { apiToken = t; }
|
||||||
|
|
||||||
|
function headers(): HeadersInit {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (apiToken) h['Authorization'] = `Bearer ${apiToken}`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parse<T>(resp: Response): Promise<T> {
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text().catch(() => '');
|
||||||
|
throw new Error(`${resp.status} ${resp.statusText} → ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return resp.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get<T>(path: string): Promise<T> {
|
||||||
|
const resp = await fetch(`${nodeUrl}${path}`, { headers: headers() });
|
||||||
|
return parse<T>(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const resp = await fetch(`${nodeUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return parse<T>(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Thin wrappers for the shell ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NetStats {
|
||||||
|
total_blocks: number;
|
||||||
|
total_txs: number;
|
||||||
|
total_supply: number;
|
||||||
|
validator_count: number;
|
||||||
|
relay_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNetStats(): Promise<NetStats> {
|
||||||
|
return get<NetStats>('/api/netstats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityInfo {
|
||||||
|
pub_key: string;
|
||||||
|
address: string;
|
||||||
|
x25519_pub: string;
|
||||||
|
nickname: string;
|
||||||
|
registered: boolean;
|
||||||
|
device_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentity(pub: string): Promise<IdentityInfo | null> {
|
||||||
|
try { return await get<IdentityInfo>(`/api/identity/${pub}`); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
x25519_pub_key: string;
|
||||||
|
device_name: string;
|
||||||
|
added_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DevicesResponse {
|
||||||
|
master_pub: string;
|
||||||
|
count: number;
|
||||||
|
devices: DeviceInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
|
||||||
|
try {
|
||||||
|
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
|
||||||
|
return resp.devices ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalance(pub: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const r = await get<{ balance_ut: number }>(`/api/address/${pub}`);
|
||||||
|
return r.balance_ut ?? 0;
|
||||||
|
} catch { return 0; }
|
||||||
|
}
|
||||||
93
desktop/src/lib/crypto.ts
Normal file
93
desktop/src/lib/crypto.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Crypto primitives. Mirrors client-app/lib/crypto.ts function-for-
|
||||||
|
// function (same signatures, same hex/base64 formats on the wire) so
|
||||||
|
// the two clients decrypt each other's envelopes and sign txs the node
|
||||||
|
// accepts interchangeably.
|
||||||
|
//
|
||||||
|
// The only real difference from mobile: we don't need expo-crypto — the
|
||||||
|
// Electron renderer is a Chromium browser, so window.crypto.getRandomValues
|
||||||
|
// is always available and we just let tweetnacl pick it up on its own
|
||||||
|
// (tweetnacl auto-detects window.crypto when present).
|
||||||
|
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||||
|
import type { KeyFile } from './types';
|
||||||
|
|
||||||
|
// ─── Hex / base64 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||||
|
const b = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
export function bytesToHex(b: Uint8Array): string {
|
||||||
|
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
export function bytesToBase64(b: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
export function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Key generation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function generateKeyFile(): KeyFile {
|
||||||
|
const sign = nacl.sign.keyPair();
|
||||||
|
const box = nacl.box.keyPair();
|
||||||
|
return {
|
||||||
|
pub_key: bytesToHex(sign.publicKey),
|
||||||
|
priv_key: bytesToHex(sign.secretKey),
|
||||||
|
x25519_pub: bytesToHex(box.publicKey),
|
||||||
|
x25519_priv: bytesToHex(box.secretKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NaCl box (E2E messaging) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function encryptMessage(
|
||||||
|
plaintext: string,
|
||||||
|
senderSecretHex: string,
|
||||||
|
recipientPubHex: string,
|
||||||
|
): { nonce: string; ciphertext: string } {
|
||||||
|
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||||
|
const msg = decodeUTF8(plaintext);
|
||||||
|
const box = nacl.box(msg, nonce, hexToBytes(recipientPubHex), hexToBytes(senderSecretHex));
|
||||||
|
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(box) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptMessage(
|
||||||
|
ciphertextHex: string,
|
||||||
|
nonceHex: string,
|
||||||
|
senderPubHex: string,
|
||||||
|
recipientSecHex: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const plain = nacl.box.open(
|
||||||
|
hexToBytes(ciphertextHex), hexToBytes(nonceHex),
|
||||||
|
hexToBytes(senderPubHex), hexToBytes(recipientSecHex),
|
||||||
|
);
|
||||||
|
return plain ? encodeUTF8(plain) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ed25519 signing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||||
|
const sig = nacl.sign.detached(data, hexToBytes(privKeyHex));
|
||||||
|
return bytesToBase64(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function shortAddr(hex: string, chars = 8): string {
|
||||||
|
if (hex.length <= chars * 2 + 3) return hex;
|
||||||
|
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||||
|
}
|
||||||
113
desktop/src/lib/relay.ts
Normal file
113
desktop/src/lib/relay.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Relay mailbox client. Same wire format + semantics as
|
||||||
|
// client-app/lib/api.ts, narrowed to the calls the desktop actually
|
||||||
|
// needs right now: broadcast sealed envelopes, fetch inbox, resolve a
|
||||||
|
// recipient's device pubs for fan-out.
|
||||||
|
|
||||||
|
import { get, post, fetchDevices, getIdentity } from './api';
|
||||||
|
import {
|
||||||
|
hexToBytes, bytesToHex, bytesToBase64, base64ToBytes,
|
||||||
|
} from './crypto';
|
||||||
|
|
||||||
|
export interface Envelope {
|
||||||
|
id: string;
|
||||||
|
sender_pub: string; // X25519 hex (per-device key)
|
||||||
|
/**
|
||||||
|
* sender_ed25519_pub (v2.2.0+): master Ed25519 identity of the sender.
|
||||||
|
* Empty for legacy senders; when present, clients should use this as
|
||||||
|
* the conversation address so messages from any of the sender's
|
||||||
|
* linked devices roll into a single chat.
|
||||||
|
*/
|
||||||
|
sender_ed25519_pub: string;
|
||||||
|
recipient_pub: string;
|
||||||
|
nonce: string; // hex
|
||||||
|
ciphertext: string; // hex
|
||||||
|
timestamp: number; // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inbox ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InboxItemWire {
|
||||||
|
id: string;
|
||||||
|
sender_pub: string;
|
||||||
|
sender_ed25519_pub?: string; // v2.2.0+; omitted by older nodes
|
||||||
|
recipient_pub: string;
|
||||||
|
sent_at: number;
|
||||||
|
nonce: string; // base64 on the wire
|
||||||
|
ciphertext: string; // base64 on the wire
|
||||||
|
}
|
||||||
|
interface InboxResponseWire {
|
||||||
|
pub: string;
|
||||||
|
count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
items: InboxItemWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /relay/inbox?pub=<x25519> → envelopes addressed to that pub.
|
||||||
|
* Converts base64 nonce/ciphertext (Go wire format) to hex so they
|
||||||
|
* line up with what crypto.decryptMessage expects.
|
||||||
|
*/
|
||||||
|
export async function fetchInbox(x25519Pub: string): Promise<Envelope[]> {
|
||||||
|
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519Pub}`);
|
||||||
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||||
|
return items.map((it): Envelope => ({
|
||||||
|
id: it.id,
|
||||||
|
sender_pub: it.sender_pub,
|
||||||
|
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||||
|
recipient_pub: it.recipient_pub,
|
||||||
|
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||||
|
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||||
|
timestamp: it.sent_at ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /relay/broadcast — submits a pre-sealed E2E envelope. The node
|
||||||
|
* relays without ever reading the plaintext; only the recipient's
|
||||||
|
* X25519 priv can open it. Sender_ed25519_pub is advisory for future
|
||||||
|
* fee-proof flows; current node ignores it when fee_ut = 0.
|
||||||
|
*/
|
||||||
|
export async function sendEnvelope(params: {
|
||||||
|
senderPub: string; // X25519 hex
|
||||||
|
recipientPub: string; // X25519 hex
|
||||||
|
nonce: string; // hex
|
||||||
|
ciphertext: string; // hex
|
||||||
|
senderEd25519Pub?: string; // optional
|
||||||
|
}): Promise<{ id: string; status: string }> {
|
||||||
|
const sentAt = Math.floor(Date.now() / 1000);
|
||||||
|
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
|
||||||
|
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
|
||||||
|
// Envelope.id is server-facing dedup key; first 16 bytes of the nonce
|
||||||
|
// are cryptographically random, reuse them to avoid another RNG call.
|
||||||
|
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
|
||||||
|
return post<{ id: string; status: string }>('/relay/broadcast', {
|
||||||
|
envelope: {
|
||||||
|
id,
|
||||||
|
sender_pub: params.senderPub,
|
||||||
|
recipient_pub: params.recipientPub,
|
||||||
|
sender_ed25519_pub: params.senderEd25519Pub ?? '',
|
||||||
|
fee_ut: 0,
|
||||||
|
fee_sig: null,
|
||||||
|
nonce: nonceB64,
|
||||||
|
ciphertext: ctB64,
|
||||||
|
sent_at: sentAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recipient resolution (multi-device v2.2.0) ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a recipient identity, return every X25519 pub we should ship an
|
||||||
|
* envelope to. Device registry first, identity.x25519_pub as fall-back.
|
||||||
|
* Same helper lives in client-app — copied here rather than imported so
|
||||||
|
* the desktop build stays React-Native-free.
|
||||||
|
*/
|
||||||
|
export async function resolveRecipientKeys(masterPub: string): Promise<string[]> {
|
||||||
|
const devs = await fetchDevices(masterPub);
|
||||||
|
if (devs.length > 0) return devs.map(d => d.x25519_pub_key);
|
||||||
|
const id = await getIdentity(masterPub);
|
||||||
|
return id?.x25519_pub ? [id.x25519_pub] : [];
|
||||||
|
}
|
||||||
159
desktop/src/lib/storage.ts
Normal file
159
desktop/src/lib/storage.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// Persistence for the desktop shell.
|
||||||
|
//
|
||||||
|
// Two tiers, both different from the mobile client:
|
||||||
|
// * KeyFile lives in the OS keychain (via Electron safeStorage in main.ts,
|
||||||
|
// exposed as `window.dchain.keyfile`). We never touch it here from
|
||||||
|
// renderer code except through that IPC.
|
||||||
|
// * Everything else — settings, contacts, chat cache, "this device was
|
||||||
|
// registered" marker — lives in localStorage. It's synchronous,
|
||||||
|
// origin-isolated inside the renderer, and plenty durable for
|
||||||
|
// per-install state. A future polish could move chats to IndexedDB
|
||||||
|
// for streaming writes, but localStorage is fine for v2.2.0.
|
||||||
|
|
||||||
|
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||||
|
import type { DChainAPI } from '../../electron/preload';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
dchain: DChainAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// All keyfile operations go through window.dchain.keyfile — the preload
|
||||||
|
// script bridges them to Electron's safeStorage. If preload failed to
|
||||||
|
// load (dev misconfig, broken build), we surface a loud error rather
|
||||||
|
// than silently failing, since a missing keyfile layer means nothing
|
||||||
|
// else in the app can work.
|
||||||
|
|
||||||
|
function requireDchain() {
|
||||||
|
if (typeof window === 'undefined' || !window.dchain) {
|
||||||
|
throw new Error(
|
||||||
|
'window.dchain is not available — the Electron preload failed to ' +
|
||||||
|
'load. Check dist-electron/preload.js exists and that main.ts is ' +
|
||||||
|
'pointing at it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return window.dchain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadKeyFile(): Promise<KeyFile | null> {
|
||||||
|
const raw = await requireDchain().keyfile.load();
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as KeyFile;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||||
|
await requireDchain().keyfile.save(JSON.stringify(kf));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteKeyFile(): Promise<void> {
|
||||||
|
await requireDchain().keyfile.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'dchain_settings';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: NodeSettings = {
|
||||||
|
nodeUrl: 'http://localhost:8080',
|
||||||
|
contractId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadSettings(): NodeSettings {
|
||||||
|
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (!raw) return DEFAULT_SETTINGS;
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(s: Partial<NodeSettings>): void {
|
||||||
|
const cur = loadSettings();
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...cur, ...s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contacts ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONTACTS_KEY = 'dchain_contacts';
|
||||||
|
|
||||||
|
export function loadContacts(): Contact[] {
|
||||||
|
const raw = localStorage.getItem(CONTACTS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Contact[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveContacts(list: Contact[]): void {
|
||||||
|
localStorage.setItem(CONTACTS_KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertContact(c: Contact): void {
|
||||||
|
const cs = loadContacts();
|
||||||
|
const i = cs.findIndex(x => x.address === c.address);
|
||||||
|
if (i >= 0) cs[i] = c; else cs.push(c);
|
||||||
|
saveContacts(cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat cache (per-conversation, capped) ───────────────────────────────
|
||||||
|
|
||||||
|
const CHATS_PREFIX = 'dchain_chats_';
|
||||||
|
const CHAT_CAP = 500;
|
||||||
|
|
||||||
|
export function loadMessages(chatAddr: string): Message[] {
|
||||||
|
const raw = localStorage.getItem(CHATS_PREFIX + chatAddr);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Message[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append + persist. Deduplicates by id, trims to CHAT_CAP newest. Callers
|
||||||
|
* in the UI should prefer zustand's store.appendMessage for reactivity
|
||||||
|
* and call this from effects to flush to disk.
|
||||||
|
*/
|
||||||
|
export function appendMessage(chatAddr: string, m: Message): void {
|
||||||
|
const cur = loadMessages(chatAddr);
|
||||||
|
if (cur.some(x => x.id === m.id)) return;
|
||||||
|
cur.push(m);
|
||||||
|
const trimmed = cur.slice(-CHAT_CAP);
|
||||||
|
localStorage.setItem(CHATS_PREFIX + chatAddr, JSON.stringify(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
|
||||||
|
|
||||||
|
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||||
|
|
||||||
|
export function isDeviceRegistered(): boolean {
|
||||||
|
return localStorage.getItem(DEVICE_REGISTERED_KEY) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDeviceRegistered(): void {
|
||||||
|
localStorage.setItem(DEVICE_REGISTERED_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wipeAllLocalState(): Promise<void> {
|
||||||
|
await deleteKeyFile();
|
||||||
|
// Everything else in localStorage we control; iterate + clear our prefix.
|
||||||
|
const ours = [
|
||||||
|
SETTINGS_KEY, CONTACTS_KEY, DEVICE_REGISTERED_KEY,
|
||||||
|
];
|
||||||
|
for (const key of Object.keys(localStorage)) {
|
||||||
|
if (ours.includes(key) || key.startsWith('dchain_chats_')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
desktop/src/lib/store.ts
Normal file
84
desktop/src/lib/store.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Zustand store — mirrors client-app/lib/store.ts, trimmed to what the
|
||||||
|
// desktop shell needs today. Holds identity, node settings, live chat
|
||||||
|
// state (contacts + per-chat messages + unread counters) and UI nav
|
||||||
|
// (current section + selected contact). Persistence lives in
|
||||||
|
// lib/storage.ts and hooks (auto-save on mutations).
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||||
|
|
||||||
|
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
booted: boolean;
|
||||||
|
keyFile: KeyFile | null;
|
||||||
|
settings: NodeSettings;
|
||||||
|
contacts: Contact[];
|
||||||
|
section: Section;
|
||||||
|
/** address of the currently-open conversation (mirrors mobile's route param). */
|
||||||
|
activeChat: string | null;
|
||||||
|
/** Messages keyed by contact.address. Each list is chronological (old → new). */
|
||||||
|
messages: Record<string, Message[]>;
|
||||||
|
/** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */
|
||||||
|
unread: Record<string, number>;
|
||||||
|
|
||||||
|
setBooted: (v: boolean) => void;
|
||||||
|
setKeyFile: (k: KeyFile | null) => void;
|
||||||
|
setSettings: (s: Partial<NodeSettings>) => void;
|
||||||
|
setContacts: (cs: Contact[]) => void;
|
||||||
|
upsertContact: (c: Contact) => void;
|
||||||
|
setSection: (s: Section) => void;
|
||||||
|
setActiveChat: (addr: string | null) => void;
|
||||||
|
|
||||||
|
setMessages: (addr: string, msgs: Message[]) => void;
|
||||||
|
appendMessage: (addr: string, m: Message) => void;
|
||||||
|
bumpUnread: (addr: string) => void;
|
||||||
|
clearUnread: (addr: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<State>((set) => ({
|
||||||
|
booted: false,
|
||||||
|
keyFile: null,
|
||||||
|
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
||||||
|
contacts: [],
|
||||||
|
section: 'messages',
|
||||||
|
activeChat: null,
|
||||||
|
messages: {},
|
||||||
|
unread: {},
|
||||||
|
|
||||||
|
setBooted: (v) => set({ booted: v }),
|
||||||
|
setKeyFile: (k) => set({ keyFile: k }),
|
||||||
|
setSettings: (s) => set((st) => ({ settings: { ...st.settings, ...s } })),
|
||||||
|
setContacts: (cs) => set({ contacts: cs }),
|
||||||
|
upsertContact: (c) => set((st) => {
|
||||||
|
const i = st.contacts.findIndex((x) => x.address === c.address);
|
||||||
|
if (i >= 0) {
|
||||||
|
const next = [...st.contacts];
|
||||||
|
next[i] = c;
|
||||||
|
return { contacts: next };
|
||||||
|
}
|
||||||
|
return { contacts: [...st.contacts, c] };
|
||||||
|
}),
|
||||||
|
setSection: (s) => set({ section: s }),
|
||||||
|
setActiveChat: (addr) => set({ activeChat: addr }),
|
||||||
|
|
||||||
|
setMessages: (addr, msgs) => set((st) => ({
|
||||||
|
messages: { ...st.messages, [addr]: msgs },
|
||||||
|
})),
|
||||||
|
appendMessage: (addr, m) => set((st) => {
|
||||||
|
const cur = st.messages[addr] ?? [];
|
||||||
|
// Idempotent — duplicate envelope deliveries (WS + HTTP race) shouldn't
|
||||||
|
// double-insert.
|
||||||
|
if (cur.some(x => x.id === m.id)) return {};
|
||||||
|
return { messages: { ...st.messages, [addr]: [...cur, m] } };
|
||||||
|
}),
|
||||||
|
bumpUnread: (addr) => set((st) => ({
|
||||||
|
unread: { ...st.unread, [addr]: (st.unread[addr] ?? 0) + 1 },
|
||||||
|
})),
|
||||||
|
clearUnread: (addr) => set((st) => {
|
||||||
|
if (!(addr in st.unread)) return {};
|
||||||
|
const next = { ...st.unread };
|
||||||
|
delete next[addr];
|
||||||
|
return { unread: next };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
145
desktop/src/lib/tx.ts
Normal file
145
desktop/src/lib/tx.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Transaction builders + submission.
|
||||||
|
//
|
||||||
|
// Mirrors the handful of builders we actually use from client-app/lib/api.ts
|
||||||
|
// (Transfer, Link/UnlinkDevice for now; more will follow as sections land).
|
||||||
|
// Canonical bytes and wire format are identical to the mobile client —
|
||||||
|
// both talk to the same Go node, so any divergence here is a bug.
|
||||||
|
|
||||||
|
import { bytesToBase64, signBase64 } from './crypto';
|
||||||
|
import { post } from './api';
|
||||||
|
|
||||||
|
const MIN_TX_FEE = 1_000;
|
||||||
|
const _encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction
|
||||||
|
* JSON. `payload` and `signature` are base64 because Go's json.Marshal
|
||||||
|
* encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time
|
||||||
|
* does the same.
|
||||||
|
*/
|
||||||
|
export interface RawTx {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
memo?: string;
|
||||||
|
payload: string;
|
||||||
|
signature: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rfc3339Now(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
return d.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTxID(): string {
|
||||||
|
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical bytes the node re-derives to verify tx.signature. Order of
|
||||||
|
* keys matches Go's field order in identity.txSignBytes — JS object
|
||||||
|
* literals preserve insertion order so JSON.stringify is enough.
|
||||||
|
*/
|
||||||
|
function canonicalBytes(tx: {
|
||||||
|
id: string; type: string; from: string; to: string;
|
||||||
|
amount: number; fee: number; payload: string; timestamp: string;
|
||||||
|
}): Uint8Array {
|
||||||
|
return _encoder.encode(JSON.stringify({
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
payload: tx.payload,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function strToBase64(s: string): string {
|
||||||
|
return bytesToBase64(_encoder.encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||||
|
return post<{ id: string; status: string }>('/api/tx', tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Builders ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildTransferTx(p: {
|
||||||
|
from: string; to: string; amount: number; fee: number;
|
||||||
|
privKey: string; memo?: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {}));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||||
|
amount: p.amount, fee: p.fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||||
|
amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLinkDeviceTx(p: {
|
||||||
|
from: string; x25519Pub: string; deviceName: string; privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({
|
||||||
|
x25519_pub_key: p.x25519Pub,
|
||||||
|
device_name: p.deviceName,
|
||||||
|
}));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnlinkDeviceTx(p: {
|
||||||
|
from: string; x25519Pub: string; privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub }));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
|
||||||
|
* message wrappers into a one-line user-facing string. Same helper the
|
||||||
|
* mobile client exposes from lib/api.ts; copied here to keep the two
|
||||||
|
* codebases independent until we factor into a shared package.
|
||||||
|
*/
|
||||||
|
export function humanizeTxError(err: unknown): string {
|
||||||
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
const m = /→\s*({[^}]+})/.exec(raw);
|
||||||
|
if (m) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m[1]);
|
||||||
|
if (parsed.error) return parsed.error;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
41
desktop/src/lib/types.ts
Normal file
41
desktop/src/lib/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Mirrors client-app/lib/types.ts — keep wire formats identical so the
|
||||||
|
// two codebases can share a single node. Copied (not imported) on
|
||||||
|
// purpose: we want the desktop build isolated from React-Native deps,
|
||||||
|
// and the drift window between this file and the mobile one is small
|
||||||
|
// enough to hand-sync. When we consolidate into a shared package
|
||||||
|
// (post-v2.2.0), this file goes away.
|
||||||
|
|
||||||
|
export interface KeyFile {
|
||||||
|
pub_key: string; // hex Ed25519 public key (32 bytes)
|
||||||
|
priv_key: string; // hex Ed25519 secret key (64 bytes)
|
||||||
|
x25519_pub: string; // hex X25519 public key (32 bytes)
|
||||||
|
x25519_priv: string; // hex X25519 secret key (32 bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSettings {
|
||||||
|
nodeUrl: string;
|
||||||
|
contractId: string;
|
||||||
|
apiToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
address: string; // Ed25519 master pub hex
|
||||||
|
x25519Pub: string; // legacy single-X25519; device registry superseded on v2.2.0
|
||||||
|
username?: string;
|
||||||
|
alias?: string;
|
||||||
|
addedAt: number; // unix ms
|
||||||
|
kind?: 'direct' | 'group';
|
||||||
|
unread?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
from: string; // X25519 hex (sender device)
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
mine: boolean;
|
||||||
|
read: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
attachment?: unknown;
|
||||||
|
replyTo?: { id: string; text: string; author: string };
|
||||||
|
}
|
||||||
24
desktop/src/main.tsx
Normal file
24
desktop/src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
|
||||||
|
// Last-resort fallback: if even rendering ErrorBoundary+App fails (say, a
|
||||||
|
// syntax error in some lazy import), paint a visible message into #root
|
||||||
|
// so the window isn't just black. window.onerror catches async errors
|
||||||
|
// that escape React's boundaries.
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (root && !root.firstChild) {
|
||||||
|
root.innerHTML = `<pre style="color:#ff6b6b;background:#000;padding:20px;font-family:monospace;white-space:pre-wrap;">` +
|
||||||
|
`Fatal: ${String(e.error ?? e.message)}\n\n${e.error?.stack ?? ''}</pre>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
9
desktop/src/sections/contacts/index.tsx
Normal file
9
desktop/src/sections/contacts/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||||
|
|
||||||
|
export function ContactsList(): React.ReactElement {
|
||||||
|
return <SectionPlaceholder title="Contacts" note="All · Online · Blocked · Requests" />;
|
||||||
|
}
|
||||||
|
export function ContactsDetail(): React.ReactElement {
|
||||||
|
return <SectionPlaceholder title="Contacts" note="Pick a contact to see details." centered />;
|
||||||
|
}
|
||||||
9
desktop/src/sections/feed/index.tsx
Normal file
9
desktop/src/sections/feed/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||||
|
|
||||||
|
export function FeedList(): React.ReactElement {
|
||||||
|
return <SectionPlaceholder title="Feed" note="For You · Following · Trending · Hashtag" />;
|
||||||
|
}
|
||||||
|
export function FeedDetail(): React.ReactElement {
|
||||||
|
return <SectionPlaceholder title="Feed" note="Select a feed tab to browse posts." centered />;
|
||||||
|
}
|
||||||
165
desktop/src/sections/messages/ChatList.tsx
Normal file
165
desktop/src/sections/messages/ChatList.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// ChatList — the Messages left-pane list of conversations.
|
||||||
|
// Rows sort by last-activity timestamp (most recent first); empty state
|
||||||
|
// renders as a full-height notice so the layout doesn't collapse.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import type { Contact, Message } from '@/lib/types';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
export function ChatList(): React.ReactElement {
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const messages = useStore(s => s.messages);
|
||||||
|
const unread = useStore(s => s.unread);
|
||||||
|
const activeChat = useStore(s => s.activeChat);
|
||||||
|
const setActive = useStore(s => s.setActiveChat);
|
||||||
|
|
||||||
|
const lastOf = (c: Contact): Message | null => {
|
||||||
|
const list = messages[c.address];
|
||||||
|
return list && list.length > 0 ? list[list.length - 1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [...contacts]
|
||||||
|
.map(c => ({ c, last: lastOf(c) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||||
|
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
|
||||||
|
return kb - ka;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 28, textAlign: 'center', color: '#8b8b8b', fontSize: 13,
|
||||||
|
}}>
|
||||||
|
No conversations yet. Messages from pairing devices or contacts
|
||||||
|
will appear here.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{sorted.map(({ c, last }) => (
|
||||||
|
<ChatRow
|
||||||
|
key={c.address}
|
||||||
|
contact={c}
|
||||||
|
last={last}
|
||||||
|
unread={unread[c.address] ?? 0}
|
||||||
|
active={activeChat === c.address}
|
||||||
|
onClick={() => {
|
||||||
|
setActive(c.address);
|
||||||
|
useStore.getState().clearUnread(c.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatRow({
|
||||||
|
contact, last, unread, active, onClick,
|
||||||
|
}: {
|
||||||
|
contact: Contact;
|
||||||
|
last: Message | null;
|
||||||
|
unread: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const name = contact.alias || contact.username
|
||||||
|
? (contact.username ? `@${contact.username}` : contact.alias!)
|
||||||
|
: shortAddr(contact.address, 6);
|
||||||
|
|
||||||
|
const time = last
|
||||||
|
? formatWhen(last.timestamp)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderBottom: '1px solid #1f1f1f',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<Avatar name={name} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between',
|
||||||
|
alignItems: 'center', gap: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#fff', fontSize: 14, fontWeight: 700,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{time && (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, flexShrink: 0 }}>
|
||||||
|
{time}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, marginTop: 3,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, color: '#8b8b8b', fontSize: 12,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{last ? preview(last) : 'Tap to start'}
|
||||||
|
</div>
|
||||||
|
{unread > 0 && (
|
||||||
|
<div style={{
|
||||||
|
minWidth: 18, height: 18, borderRadius: 9,
|
||||||
|
padding: '0 6px', background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{unread > 99 ? '99+' : unread}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ name }: { name: string }) {
|
||||||
|
const letter = name.replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 20, flexShrink: 0,
|
||||||
|
background: '#1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#d0d0d0', fontWeight: 700,
|
||||||
|
}}>{letter}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(m: Message): string {
|
||||||
|
const t = m.text.trim();
|
||||||
|
if (t.length === 0) return m.attachment ? '(attachment)' : '';
|
||||||
|
return t.length > 60 ? t.slice(0, 60) + '…' : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(unixSec: number): string {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay =
|
||||||
|
d.getFullYear() === now.getFullYear() &&
|
||||||
|
d.getMonth() === now.getMonth() &&
|
||||||
|
d.getDate() === now.getDate();
|
||||||
|
if (sameDay) {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear();
|
||||||
|
return sameYear
|
||||||
|
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||||
|
: d.toLocaleDateString();
|
||||||
|
}
|
||||||
213
desktop/src/sections/messages/Conversation.tsx
Normal file
213
desktop/src/sections/messages/Conversation.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// Conversation — the Messages right-pane showing one chat + composer.
|
||||||
|
//
|
||||||
|
// Responsibilities:
|
||||||
|
// * Render header with contact identity + close button.
|
||||||
|
// * Auto-scroll the message list to the bottom on new arrival.
|
||||||
|
// * Composer with Enter-to-send, Shift+Enter for newline.
|
||||||
|
// * Fan out every outgoing message across the recipient's device
|
||||||
|
// registry (falls back to legacy single-X25519 for pre-v2.2.0
|
||||||
|
// peers). One envelope per device; Promise.all, any failure
|
||||||
|
// rejects the batch so the user sees it.
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { encryptMessage, shortAddr } from '@/lib/crypto';
|
||||||
|
import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
|
||||||
|
import { appendMessage as persist } from '@/lib/storage';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
export function Conversation({ address }: { address: string }): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contact = useStore(s => s.contacts.find(c => c.address === address));
|
||||||
|
const messages = useStore(s => s.messages[address] ?? []);
|
||||||
|
const clearUnread = useStore(s => s.clearUnread);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Seeing a conversation drops its unread count.
|
||||||
|
useEffect(() => { clearUnread(address); }, [address, clearUnread]);
|
||||||
|
|
||||||
|
// Pin the scroll to the bottom on new messages. Only if the user
|
||||||
|
// is already near the bottom — don't yank them back if they're
|
||||||
|
// scrolling through older history.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
|
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
const isSelf = !!keyFile && keyFile.pub_key === address;
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!keyFile || sending) return;
|
||||||
|
const body = text.trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
setSending(true); setError(null);
|
||||||
|
try {
|
||||||
|
// Saved Messages path — the conversation address equals our own
|
||||||
|
// master pub. Mobile parity: append locally, skip the relay
|
||||||
|
// round-trip entirely (no fees, no ciphertext ever leaves).
|
||||||
|
if (!isSelf) {
|
||||||
|
const pubs = await resolveRecipientKeys(address);
|
||||||
|
if (pubs.length === 0) {
|
||||||
|
throw new Error('recipient has no encryption key published');
|
||||||
|
}
|
||||||
|
await Promise.all(pubs.map(async (rpub) => {
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
body, keyFile.x25519_priv, rpub,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: rpub,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const m: Message = {
|
||||||
|
id: `out-${Date.now()}${Math.floor(Math.random() * 1e6)}`,
|
||||||
|
from: keyFile.x25519_pub,
|
||||||
|
text: body,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
mine: true,
|
||||||
|
read: false,
|
||||||
|
edited: false,
|
||||||
|
};
|
||||||
|
appendMsg(address, m);
|
||||||
|
persist(address, m);
|
||||||
|
setText('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = contact?.username ? `@${contact.username}`
|
||||||
|
: contact?.alias
|
||||||
|
? contact.alias
|
||||||
|
: isSelf
|
||||||
|
? 'Saved Messages'
|
||||||
|
: shortAddr(address, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px', borderBottom: '1px solid #1f1f1f',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
background: isSelf ? '#1d9bf0' : '#1a1a1a',
|
||||||
|
color: '#fff', fontWeight: 700, fontSize: 14,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
{shortAddr(address, 6)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||||
|
marginTop: 40,
|
||||||
|
}}>
|
||||||
|
{isSelf
|
||||||
|
? 'Notes to self. Messages here stay on this device only.'
|
||||||
|
: 'No messages yet. Type below to send the first one.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{messages.map(m => <Bubble key={m.id} message={m} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid #1f1f1f', padding: 12,
|
||||||
|
display: 'flex', gap: 10, alignItems: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Message…"
|
||||||
|
rows={1}
|
||||||
|
style={{
|
||||||
|
flex: 1, resize: 'none',
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 10, padding: '10px 12px',
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||||
|
outline: 'none', lineHeight: 1.4, maxHeight: 140,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={send}
|
||||||
|
disabled={sending || text.trim().length === 0}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px', borderRadius: 999, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff', fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: sending || text.trim().length === 0 ? 'default' : 'pointer',
|
||||||
|
opacity: sending || text.trim().length === 0 ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? '…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 16px 10px', fontSize: 11, color: '#ff6b6b',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({ message }: { message: Message }) {
|
||||||
|
const mine = message.mine;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start',
|
||||||
|
}}>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
maxWidth: '70%',
|
||||||
|
padding: '8px 12px', borderRadius: 14,
|
||||||
|
background: mine ? '#1d9bf0' : '#1a1a1a',
|
||||||
|
color: mine ? '#fff' : '#e0e0e0',
|
||||||
|
fontSize: 13, lineHeight: 1.45,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
desktop/src/sections/messages/EmptyConversation.tsx
Normal file
13
desktop/src/sections/messages/EmptyConversation.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function EmptyConversation(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Select a conversation from the list,<br/>or wait for one to appear as messages arrive.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
desktop/src/sections/messages/index.tsx
Normal file
44
desktop/src/sections/messages/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Messages section — full implementation. Left pane is the chat list;
|
||||||
|
// right pane mounts the active conversation or an empty-state.
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { loadMessages } from '@/lib/storage';
|
||||||
|
import { useInboxPoll } from '@/hooks/useInboxPoll';
|
||||||
|
import { ChatList } from './ChatList';
|
||||||
|
import { Conversation } from './Conversation';
|
||||||
|
import { EmptyConversation } from './EmptyConversation';
|
||||||
|
|
||||||
|
export function MessagesList(): React.ReactElement {
|
||||||
|
// Warm cached messages from localStorage once per mount, so toggling
|
||||||
|
// back into Messages after visiting another section doesn't forget
|
||||||
|
// history. Zustand wins on conflict — once the hook has appended
|
||||||
|
// live messages, we don't overwrite them with stale disk snapshots.
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const setMsgs = useStore(s => s.setMessages);
|
||||||
|
const hydrated = useRef(false);
|
||||||
|
|
||||||
|
// Kick off the inbox polling loop while Messages is mounted.
|
||||||
|
// Section-scoped for now so we don't pay the bandwidth cost when
|
||||||
|
// the user is in Feed / Wallet / etc.; a future alpha can promote
|
||||||
|
// it to the shell if we want notifications in other sections too.
|
||||||
|
useInboxPoll();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydrated.current) return;
|
||||||
|
hydrated.current = true;
|
||||||
|
const st = useStore.getState();
|
||||||
|
for (const c of contacts) {
|
||||||
|
if ((st.messages[c.address] ?? []).length > 0) continue;
|
||||||
|
const cached = loadMessages(c.address);
|
||||||
|
if (cached.length > 0) setMsgs(c.address, cached);
|
||||||
|
}
|
||||||
|
}, [contacts, setMsgs]);
|
||||||
|
|
||||||
|
return <ChatList />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessagesDetail(): React.ReactElement {
|
||||||
|
const activeChat = useStore(s => s.activeChat);
|
||||||
|
return activeChat ? <Conversation address={activeChat} /> : <EmptyConversation />;
|
||||||
|
}
|
||||||
43
desktop/src/sections/profile/index.tsx
Normal file
43
desktop/src/sections/profile/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
export function ProfileList(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
padding: 14, borderRadius: 14,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, borderRadius: 24,
|
||||||
|
background: '#1d9bf0', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', fontWeight: 800, fontSize: 20,
|
||||||
|
}}>
|
||||||
|
{keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700, marginTop: 10 }}>
|
||||||
|
You
|
||||||
|
</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
marginTop: 4, wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{keyFile?.pub_key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileDetail(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<SectionPlaceholder
|
||||||
|
title="Your profile"
|
||||||
|
note="Balance, username, devices — coming soon."
|
||||||
|
centered
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
desktop/src/sections/settings/index.tsx
Normal file
133
desktop/src/sections/settings/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveSettings } from '@/lib/storage';
|
||||||
|
import { setNodeUrl, getNetStats } from '@/lib/api';
|
||||||
|
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||||
|
|
||||||
|
export function SettingsList(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<GroupLabel>Node</GroupLabel>
|
||||||
|
<NodeCard />
|
||||||
|
<GroupLabel>Identity</GroupLabel>
|
||||||
|
<IdentityCard />
|
||||||
|
<GroupLabel>About</GroupLabel>
|
||||||
|
<AboutCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDetail(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<SectionPlaceholder
|
||||||
|
title="Settings"
|
||||||
|
note="Pick a setting from the list. Devices, notifications, privacy — coming soon."
|
||||||
|
centered
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeCard(): React.ReactElement {
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
const [url, setUrl] = useState(settings.nodeUrl);
|
||||||
|
const [ok, setOk] = useState<boolean | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setUrl(settings.nodeUrl); }, [settings.nodeUrl]);
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
const clean = url.trim().replace(/\/$/, '');
|
||||||
|
if (!clean) return;
|
||||||
|
setBusy(true); setOk(null);
|
||||||
|
setNodeUrl(clean);
|
||||||
|
try {
|
||||||
|
await getNetStats();
|
||||||
|
setOk(true);
|
||||||
|
setSettings({ nodeUrl: clean });
|
||||||
|
saveSettings({ nodeUrl: clean });
|
||||||
|
} catch {
|
||||||
|
setOk(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
||||||
|
background: '#0a0a0a', display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
}}>
|
||||||
|
<label style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
|
||||||
|
NODE URL
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={e => { setUrl(e.target.value); setOk(null); }}
|
||||||
|
onBlur={apply}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
|
||||||
|
placeholder="http://node.example:8080"
|
||||||
|
spellCheck={false}
|
||||||
|
style={{
|
||||||
|
flex: 1, background: '#000',
|
||||||
|
border: '1px solid #1f1f1f', borderRadius: 8,
|
||||||
|
padding: '8px 10px', color: '#fff', fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{busy && <span style={{ fontSize: 11, color: '#8b8b8b' }}>…</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IdentityCard(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
if (!keyFile) return <></>;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
||||||
|
background: '#0a0a0a',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
|
||||||
|
PUB KEY
|
||||||
|
</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
marginTop: 4, wordBreak: 'break-all', lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{keyFile.pub_key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AboutCard(): React.ReactElement {
|
||||||
|
const [v, setV] = useState<string>('dev');
|
||||||
|
useEffect(() => {
|
||||||
|
window.dchain?.app.version().then(setV).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
||||||
|
background: '#0a0a0a', color: '#8b8b8b', fontSize: 12,
|
||||||
|
}}>
|
||||||
|
DChain Desktop v{v}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
desktop/src/sections/wallet/index.tsx
Normal file
42
desktop/src/sections/wallet/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getBalance } from '@/lib/api';
|
||||||
|
|
||||||
|
function formatT(ut: number): string {
|
||||||
|
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletList(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||||
|
}, [keyFile]);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 14, padding: 14,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
Balance
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800, marginTop: 4 }}>
|
||||||
|
{balance === null ? '—' : `${formatT(balance)} T`}
|
||||||
|
</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
marginTop: 6, wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{keyFile?.pub_key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletDetail(): React.ReactElement {
|
||||||
|
return <SectionPlaceholder title="Wallet" note="Transaction history — coming soon." centered />;
|
||||||
|
}
|
||||||
122
desktop/src/shell/NavBar.tsx
Normal file
122
desktop/src/shell/NavBar.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// NavBar — the left 72px rail. Six icons, one for each section.
|
||||||
|
// The active icon is drawn in accent blue; everything else is mid-grey.
|
||||||
|
// Keyboard shortcuts (Ctrl/Cmd+1..5) are registered in useKeybinds().
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useStore, type Section } from '@/lib/store';
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
key: Section;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons are SF Symbol-ish monochrome glyphs from lucide's set, inlined as
|
||||||
|
// SVGs to avoid another runtime dependency at this stage. If the set
|
||||||
|
// grows, we'll move to a lucide-react import.
|
||||||
|
const TABS: Tab[] = [
|
||||||
|
{ key: 'messages', label: 'Messages', icon: 'chat' },
|
||||||
|
{ key: 'feed', label: 'Feed', icon: 'feed' },
|
||||||
|
{ key: 'wallet', label: 'Wallet', icon: 'wallet' },
|
||||||
|
{ key: 'contacts', label: 'Contacts', icon: 'contacts' },
|
||||||
|
{ key: 'settings', label: 'Settings', icon: 'cog' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function NavBar(): React.ReactElement {
|
||||||
|
const section = useStore(s => s.section);
|
||||||
|
const setSection = useStore(s => s.setSection);
|
||||||
|
|
||||||
|
// Global keybinds for section switch.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (!mod) return;
|
||||||
|
const i = Number(e.key) - 1;
|
||||||
|
if (Number.isInteger(i) && i >= 0 && i < TABS.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSection(TABS[i].key);
|
||||||
|
} else if (e.key === ',' || e.key === '.') {
|
||||||
|
// Cmd+, is standard for Settings on macOS
|
||||||
|
e.preventDefault();
|
||||||
|
setSection('settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [setSection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav style={{
|
||||||
|
width: 72, flexShrink: 0,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
padding: '16px 0 10px',
|
||||||
|
borderRight: '1px solid #1f1f1f',
|
||||||
|
background: '#000',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<NavItem
|
||||||
|
key={t.key}
|
||||||
|
label={t.label}
|
||||||
|
icon={t.icon}
|
||||||
|
active={section === t.key}
|
||||||
|
onClick={() => setSection(t.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<NavItem
|
||||||
|
key="profile"
|
||||||
|
label="Profile"
|
||||||
|
icon="user"
|
||||||
|
active={section === 'profile'}
|
||||||
|
onClick={() => setSection('profile')}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({
|
||||||
|
label, icon, active, onClick,
|
||||||
|
}: { label: string; icon: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
style={{
|
||||||
|
width: 56, height: 52, borderRadius: 12,
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
color: active ? '#1d9bf0' : '#8b8b8b',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
justifyContent: 'center', gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavGlyph icon={icon} color={active ? '#1d9bf0' : '#8b8b8b'} />
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: 0.2 }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavGlyph({ icon, color }: { icon: string; color: string }) {
|
||||||
|
const d = GLYPHS[icon] ?? GLYPHS.cog;
|
||||||
|
return (
|
||||||
|
<svg width={20} height={20} viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke={color} strokeWidth={1.8}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d={d} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLYPHS: Record<string, string> = {
|
||||||
|
// Minimal lucide-style single-path icons.
|
||||||
|
chat: 'M21 12a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
|
||||||
|
feed: 'M4 11a9 9 0 0 1 9 9 M4 4a16 16 0 0 1 16 16 M5 19a2 2 0 1 0 0 .01',
|
||||||
|
wallet: 'M20 12V8H4a2 2 0 0 1 0-4h12 M4 6v12a2 2 0 0 0 2 2h14v-4 M18 12a2 2 0 1 0 0 4h4v-4h-4z',
|
||||||
|
contacts: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
|
||||||
|
cog: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
|
||||||
|
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||||
|
};
|
||||||
40
desktop/src/shell/SectionPlaceholder.tsx
Normal file
40
desktop/src/shell/SectionPlaceholder.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Simple inner placeholder used by every section until real content
|
||||||
|
// lands. Shows a title + a short note; `centered` flips the layout into
|
||||||
|
// a vertically centred message for empty-detail panes.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
note?: string;
|
||||||
|
centered?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionPlaceholder({ title, note, centered }: Props): React.ReactElement {
|
||||||
|
if (centered) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 32,
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center', maxWidth: 360 }}>
|
||||||
|
<div style={{ color: '#d0d0d0', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||||
|
{note && (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 13, lineHeight: 1.5, marginTop: 6 }}>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 14 }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700 }}>{title}</div>
|
||||||
|
{note && (
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 6 }}>{note}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
desktop/src/shell/Shell.tsx
Normal file
72
desktop/src/shell/Shell.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Shell — the permanent 3-panel chrome around every non-auth screen.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
// ┌──────────────────────────────────────────────────────────────┐
|
||||||
|
// │ DChain [ minimise | maximise | × ] │ 32px titlebar (drag region)
|
||||||
|
// ├──────┬───────────────────┬─────────────────────────────────────┤
|
||||||
|
// │ │ │ │
|
||||||
|
// │ nav │ list │ detail │
|
||||||
|
// │ 72px │ ~340px fixed │ flex 1 │
|
||||||
|
// │ │ │ │
|
||||||
|
// ├──────┴───────────────────┴─────────────────────────────────────┤
|
||||||
|
// │ ● online · height 10942 · fee 1000 µT │ 28px status bar
|
||||||
|
// └──────────────────────────────────────────────────────────────┘
|
||||||
|
//
|
||||||
|
// Current section is driven by store.section. NavBar flips it. List +
|
||||||
|
// Detail are each decided by the section, composed from the appropriate
|
||||||
|
// module under sections/. Until sections ship their real content, they
|
||||||
|
// render simple placeholders so we can walk through the shell end-to-end.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore, type Section } from '@/lib/store';
|
||||||
|
import { TitleBar } from './TitleBar';
|
||||||
|
import { NavBar } from './NavBar';
|
||||||
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
||||||
|
import { FeedList, FeedDetail } from '@/sections/feed';
|
||||||
|
import { WalletList, WalletDetail } from '@/sections/wallet';
|
||||||
|
import { ContactsList, ContactsDetail } from '@/sections/contacts';
|
||||||
|
import { SettingsList, SettingsDetail } from '@/sections/settings';
|
||||||
|
import { ProfileList, ProfileDetail } from '@/sections/profile';
|
||||||
|
|
||||||
|
export function Shell(): React.ReactElement {
|
||||||
|
const section = useStore(s => s.section);
|
||||||
|
const { List, Detail } = PANES[section];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
height: '100%', background: '#000',
|
||||||
|
}}>
|
||||||
|
<TitleBar />
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', overflow: 'hidden',
|
||||||
|
borderTop: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<NavBar />
|
||||||
|
<div style={{
|
||||||
|
width: 340, flexShrink: 0,
|
||||||
|
borderRight: '1px solid #1f1f1f',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<List />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<Detail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANES: Record<
|
||||||
|
Section,
|
||||||
|
{ List: React.ComponentType; Detail: React.ComponentType }
|
||||||
|
> = {
|
||||||
|
messages: { List: MessagesList, Detail: MessagesDetail },
|
||||||
|
feed: { List: FeedList, Detail: FeedDetail },
|
||||||
|
wallet: { List: WalletList, Detail: WalletDetail },
|
||||||
|
contacts: { List: ContactsList, Detail: ContactsDetail },
|
||||||
|
settings: { List: SettingsList, Detail: SettingsDetail },
|
||||||
|
profile: { List: ProfileList, Detail: ProfileDetail },
|
||||||
|
};
|
||||||
75
desktop/src/shell/StatusBar.tsx
Normal file
75
desktop/src/shell/StatusBar.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// StatusBar — the 28px strip at the bottom. Surfaces the three bits of
|
||||||
|
// information an operator tends to want at a glance:
|
||||||
|
// * Connection state to the configured node (poll /api/netstats).
|
||||||
|
// * Current chain height (last successful poll).
|
||||||
|
// * Node URL (short, hover-tooltip for the full thing).
|
||||||
|
//
|
||||||
|
// Poll interval is 5 seconds — low enough to feel live, cheap enough
|
||||||
|
// that even a free-tier node won't notice.
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getNetStats, getNodeUrl, onNodeUrlChange } from '@/lib/api';
|
||||||
|
|
||||||
|
type ConnState = 'online' | 'connecting' | 'offline';
|
||||||
|
|
||||||
|
export function StatusBar(): React.ReactElement {
|
||||||
|
const nodeUrl = useStore(s => s.settings.nodeUrl);
|
||||||
|
const [conn, setConn] = useState<ConnState>('connecting');
|
||||||
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
|
const [url, setUrl] = useState<string>(getNodeUrl());
|
||||||
|
|
||||||
|
useEffect(() => onNodeUrlChange(setUrl), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const s = await getNetStats();
|
||||||
|
if (cancelled) return;
|
||||||
|
setConn('online');
|
||||||
|
setHeight(s.total_blocks);
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConn('offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setConn('connecting');
|
||||||
|
poll();
|
||||||
|
const t = setInterval(poll, 5_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, [nodeUrl]);
|
||||||
|
|
||||||
|
const dot = conn === 'online' ? '#3ba55d' :
|
||||||
|
conn === 'connecting' ? '#f0b35a' :
|
||||||
|
'#f4212e';
|
||||||
|
|
||||||
|
const shortUrl = url
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer style={{
|
||||||
|
height: 28, minHeight: 28,
|
||||||
|
background: '#000',
|
||||||
|
borderTop: '1px solid #1f1f1f',
|
||||||
|
display: 'flex', alignItems: 'center', padding: '0 16px', gap: 14,
|
||||||
|
fontSize: 11, color: '#8b8b8b',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 8, height: 8, borderRadius: 4, background: dot,
|
||||||
|
}} />
|
||||||
|
{conn}
|
||||||
|
</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span title={url}>{shortUrl}</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>height {height ?? '—'}</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
desktop/src/shell/TitleBar.tsx
Normal file
42
desktop/src/shell/TitleBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Titlebar — draws the top 32px strip as a drag region so the user can
|
||||||
|
// move the window even though we set frame: false in main.ts.
|
||||||
|
//
|
||||||
|
// On macOS the native traffic lights show through because main.ts uses
|
||||||
|
// `titleBarStyle: 'hiddenInset'`. On Windows, `titleBarOverlay` renders
|
||||||
|
// close/min/max in their native style over our bar. On Linux we paint
|
||||||
|
// close + min + max ourselves (below).
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DRAG: React.CSSProperties = {
|
||||||
|
// @ts-expect-error webkit-only
|
||||||
|
WebkitAppRegion: 'drag',
|
||||||
|
};
|
||||||
|
const NO_DRAG: React.CSSProperties = {
|
||||||
|
// @ts-expect-error webkit-only
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TitleBar(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...DRAG,
|
||||||
|
height: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingLeft: 80, // leaves room for macOS traffic-lights area
|
||||||
|
paddingRight: 12,
|
||||||
|
background: '#000',
|
||||||
|
color: '#d0d0d0',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ opacity: 0.6 }}>DChain</span>
|
||||||
|
<div style={{ flex: 1, ...NO_DRAG }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
desktop/src/vite-env.d.ts
vendored
Normal file
12
desktop/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Vite auto-surfaces VITE_* env vars on import.meta.env. The Electron main
|
||||||
|
// process sets VITE_DEV_SERVER_URL separately at spawn time, so we only
|
||||||
|
// need to tell TS about the one variable the renderer reads.
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_DEV_SERVER_URL?: string;
|
||||||
|
}
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
26
desktop/tsconfig.json
Normal file
26
desktop/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
22
desktop/vite.config.ts
Normal file
22
desktop/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Vite config for the renderer process. Electron main/preload build
|
||||||
|
// separately via `tsc -p electron/tsconfig.json`.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
227
docs/ROADMAP.md
Normal file
227
docs/ROADMAP.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Living doc — «куда идём дальше». Два активных вектора: **v2.2.0 multi-device**
|
||||||
|
и **desktop Electron client**. Всё что tentative — помечено `?`; всё что
|
||||||
|
решено и принято к работе — без знаков.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.2.0 — Multi-device (per-device X25519, on-chain device registry)
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
Сейчас у пользователя одна пара X25519 (хранится в `keyFile`), и relay-mailbox —
|
||||||
|
queue с DELETE-после-чтения. При двух устройствах:
|
||||||
|
|
||||||
|
- Конверт забирает то устройство, что первое дёрнуло `/relay/inbox` — второе
|
||||||
|
теряет сообщение навсегда.
|
||||||
|
- История чатов per-device, не синхронизируется (`AsyncStorage` на каждом
|
||||||
|
устройстве свой).
|
||||||
|
|
||||||
|
On-chain часть (posts, follows, tx, wallet) работает нормально — оба устройства
|
||||||
|
читают один чейн и видят одно и то же. Чинить надо только мессенджер.
|
||||||
|
|
||||||
|
### Решение — путь А (Signal-style): X25519 на устройство + device registry
|
||||||
|
|
||||||
|
1. **Master identity = Ed25519** (как сейчас). Подписывает tx, владеет
|
||||||
|
балансом, — один на всю identity.
|
||||||
|
2. **X25519 — per-device**. Каждое устройство при первом старте генерит
|
||||||
|
свою пару. Relay-mailbox остаётся queue'ом, но адресуется по
|
||||||
|
**устройству**, не по identity.
|
||||||
|
3. **Device registry on-chain**: новые tx-типы `LINK_DEVICE` / `UNLINK_DEVICE`,
|
||||||
|
подписываются master Ed25519, публикуют/отзывают связку
|
||||||
|
`(master_pub → device_x25519_pub, device_name)`.
|
||||||
|
4. **Sender-side fan-out**: при отправке — один envelope на каждое
|
||||||
|
активное устройство получателя. Мессадж → N конвертов.
|
||||||
|
5. **Revoke**: master подписывает `UNLINK_DEVICE`, клиент, обнаруживший
|
||||||
|
свой `x25519_pub` в revoked — wipe'ит локальную БД (zeroize master
|
||||||
|
Ed25519 + delete chats).
|
||||||
|
|
||||||
|
### План работ (PR-by-PR)
|
||||||
|
|
||||||
|
#### PR #1 — Chain-side (backend) — _v2.2.0-alpha1_
|
||||||
|
|
||||||
|
- [ ] `blockchain/types.go`: `EventLinkDevice`, `EventUnlinkDevice`
|
||||||
|
+ payload structs `LinkDevicePayload`, `UnlinkDevicePayload`.
|
||||||
|
- [ ] `blockchain/chain.go`: applyTx-ветки, state persistence:
|
||||||
|
- `prefixDevice + x25519_pub → DeviceRecord{owner, name, added_at, revoked_at?}`.
|
||||||
|
- Обратный индекс `prefixDevicesByOwner + master_pub → [x25519_pub, …]`.
|
||||||
|
- [ ] Константа `MaxDevicesPerOwner = 10`.
|
||||||
|
- [ ] Validation:
|
||||||
|
- Tx подписана master'ом; подпись matches `payload.Owner`.
|
||||||
|
- `x25519_pub` уникален в registry (не занят другим master'ом).
|
||||||
|
- `device_name` ≤ 64 символов; printable.
|
||||||
|
- При `UNLINK_DEVICE`: запись существует, owner совпадает с signer'ом.
|
||||||
|
- [ ] HTTP: `GET /api/devices/{master_pub}` → `[{x25519_pub, device_name, added_at}, …]`,
|
||||||
|
фильтрует revoked.
|
||||||
|
- [ ] В `/api/identity/{pub}` добавить поле `device_count`.
|
||||||
|
- [ ] Unit-тесты:
|
||||||
|
- Happy path link + list.
|
||||||
|
- Unlink → list сократился.
|
||||||
|
- Лимит `MaxDevicesPerOwner`.
|
||||||
|
- Чужая подпись → reject.
|
||||||
|
- Duplicate x25519_pub → reject.
|
||||||
|
- [ ] Swagger + `docs/api/devices.md`.
|
||||||
|
|
||||||
|
Совместимость: старые клиенты, которые не обновили identity, продолжают
|
||||||
|
работать по old-schema (envelope на published X25519 из identity). Sender
|
||||||
|
fall-back'ит на identity.x25519, если `device_count == 0`.
|
||||||
|
|
||||||
|
#### PR #2 — Client fan-out (mobile) — _v2.2.0-alpha2_
|
||||||
|
|
||||||
|
- [ ] `lib/api.ts`: `fetchDevices(masterPub): Promise<DeviceRecord[]>`,
|
||||||
|
с кэшем в zustand store + инвалидацией по WS-ивенту `tx:LINK_DEVICE|UNLINK_DEVICE`.
|
||||||
|
- [ ] `chats/[id].tsx` → `sendCore`: `Promise.all` по `devices[]`;
|
||||||
|
если `devices.length == 0` — fall-back на identity.x25519 (legacy path).
|
||||||
|
- [ ] При первом старте (онбординг): если identity ещё не зарегистрирована —
|
||||||
|
`PUBLISH_KEY` tx + `LINK_DEVICE` tx (**этот** девайс как первый в списке).
|
||||||
|
- [ ] UI: в chat-list tile бейдж `2/2` на своих сообщениях (доставлено
|
||||||
|
на N устройств получателя).
|
||||||
|
- [ ] Тест: два клиента на одну identity, отправляем на contact — оба
|
||||||
|
получают.
|
||||||
|
|
||||||
|
#### PR #3 — Devices screen + pairing flow (mobile + desktop) — _v2.2.0-alpha3_
|
||||||
|
|
||||||
|
- [ ] Settings → **Devices**: список активных, unlink-кнопка у каждого
|
||||||
|
(кроме `this device`).
|
||||||
|
- [ ] «Link new device» flow:
|
||||||
|
- Новое устройство генерит свой X25519, показывает QR
|
||||||
|
`{x25519_pub, device_name, nonce, rendezvous_id}`.
|
||||||
|
- Старое сканирует → Confirm → подписывает `LINK_DEVICE` tx +
|
||||||
|
шлёт через relay envelope `{master_ed25519_priv, recent_history}` —
|
||||||
|
encrypted for new device's X25519, TTL ≤ 60 сек.
|
||||||
|
- Новое читает envelope, сохраняет master + history, готово.
|
||||||
|
- [ ] Self-wipe при обнаружении своего `x25519_pub` в revoked:
|
||||||
|
zeroize master, clear AsyncStorage/keychain, redirect на onboarding.
|
||||||
|
|
||||||
|
#### PR #4 — Desktop Electron shell — _v2.2.0-rc1_
|
||||||
|
|
||||||
|
См. отдельный раздел ниже.
|
||||||
|
|
||||||
|
### Нерешённые вопросы
|
||||||
|
|
||||||
|
- **QR-pairing UX на desktop'е**: у ноутбука камеры часто нет. Вариант:
|
||||||
|
master на phone шлёт invite-envelope по pre-shared secret (6-digit code,
|
||||||
|
вводится в обе стороны), без QR. Подумать после PR #2.
|
||||||
|
- **History-sync backup формат**: JSON export всей ChatStorage shreds'ов?
|
||||||
|
Ограничение по размеру? TTL у backup-конверта? — в PR #3.
|
||||||
|
- **Revoked-device-wipe race**: если устройство оффлайн 6 месяцев, потом
|
||||||
|
поднимается — первое что видит это свой revoked. Должен успеть wipe'нуться
|
||||||
|
**до** попытки послать tx с master-ключом. Продумать порядок bootstrap'а.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop client (Electron)
|
||||||
|
|
||||||
|
Цель: 1:1 функциональный паритет с мобильным, десктопная эргономика, keyboard-first.
|
||||||
|
|
||||||
|
### Архитектура
|
||||||
|
|
||||||
|
- **Electron** + **React (Vite)** + **TypeScript**.
|
||||||
|
- 80% `lib/` переиспользуется из `client-app/lib/` (api, feed, crypto, store, types).
|
||||||
|
- Нативные модули: `electron-store` (+ `safeStorage`) для keys, `clipboard`,
|
||||||
|
`Notification`, `dialog.showOpenDialog`.
|
||||||
|
- Custom title bar (frame-less), 3-panel layout.
|
||||||
|
|
||||||
|
### Экраны
|
||||||
|
|
||||||
|
Full design — `docs/desktop-design.md` (TODO). Sections:
|
||||||
|
|
||||||
|
- **Messages** — список чатов + conversation panel. Saved Messages pinned top.
|
||||||
|
- **Feed** — tabs (For You / Following / Trending 24h / 7d / Hashtag) +
|
||||||
|
post list + thread panel.
|
||||||
|
- **Wallet** — account overview + tx history + tx detail.
|
||||||
|
- **Contacts** — grouped list (All / Online / Blocked / Requests) + contact profile.
|
||||||
|
- **Settings** — grouped: Node, Identity, **Devices**, Privacy, Feed, Notifications,
|
||||||
|
Advanced, About.
|
||||||
|
- **Profile** (внизу nav) — self profile; чужой — в detail pane.
|
||||||
|
- **Auth/Onboarding** — Welcome / Create / Import / Pair-with-phone (QR/code).
|
||||||
|
|
||||||
|
### Стилистика (переносим)
|
||||||
|
|
||||||
|
Чёрный `#000`, карточки `#0a0a0a`, бордеры `#1f1f1f`, текст `#fff`/`#8b8b8b`.
|
||||||
|
Синий accent `#1d9bf0`, оранж warning `#f0b35a`. Синий bookmark-avatar для Saved Messages.
|
||||||
|
|
||||||
|
### Не переносим
|
||||||
|
|
||||||
|
Все React-Native-анимации (Pressable-animations, `scaleY: -1`, gesture-handler
|
||||||
|
long-press). На десктопе — правый клик, hover-states, keyboard shortcuts,
|
||||||
|
обычный scroll вместо inverted FlatList.
|
||||||
|
|
||||||
|
### Global keybinds
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ctrl/Cmd+1..5` | Переключение секций |
|
||||||
|
| `Ctrl/Cmd+N` | Новый пост / новый контакт (контекстно) |
|
||||||
|
| `Ctrl/Cmd+K` | Global search |
|
||||||
|
| `Ctrl/Cmd+F` | Search в текущей list pane |
|
||||||
|
| `Ctrl/Cmd+,` | Settings |
|
||||||
|
| `Ctrl/Cmd+Enter` | Send (chat / publish post) |
|
||||||
|
| `Esc` | Close modal / cancel selection |
|
||||||
|
|
||||||
|
### Структура папок
|
||||||
|
|
||||||
|
```
|
||||||
|
desktop/
|
||||||
|
├── electron/
|
||||||
|
│ ├── main.ts # BrowserWindow, IPC, auto-update
|
||||||
|
│ ├── preload.ts # contextBridge — safe API
|
||||||
|
│ ├── menu.ts # native menu bar
|
||||||
|
│ └── deep-link.ts # dchain:// protocol handler
|
||||||
|
├── src/ # renderer
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── shell/ # nav, status bar, title bar
|
||||||
|
│ ├── sections/
|
||||||
|
│ │ ├── messages/
|
||||||
|
│ │ ├── feed/
|
||||||
|
│ │ ├── wallet/
|
||||||
|
│ │ ├── contacts/
|
||||||
|
│ │ ├── settings/
|
||||||
|
│ │ └── profile/
|
||||||
|
│ ├── modals/
|
||||||
|
│ ├── auth/
|
||||||
|
│ ├── lib/ # reuse из client-app/lib
|
||||||
|
│ └── styles/
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### План работ
|
||||||
|
|
||||||
|
- [x] **v2.2.0-alpha4** — Boilerplate: Electron + Vite + React + TS,
|
||||||
|
frame-less window, 3-panel shell, nav + status bar, safeStorage
|
||||||
|
for keyfile via IPC, Welcome + Create/Import auth flow, section
|
||||||
|
stubs that the rest of the alphas will fill in.
|
||||||
|
- [ ] **v2.2.0-alpha5** — Messages section (chat list + conversation)
|
||||||
|
using the same fan-out semantics as mobile. Pairing flow wired up
|
||||||
|
(new-device poll loop + primary-device modal reused from mobile).
|
||||||
|
- [ ] **v2.2.0-alpha6** — Feed + Wallet real content (reuse feed.ts /
|
||||||
|
tx builders from client-app via a shared workspace package).
|
||||||
|
- [ ] **v2.2.0-rc1** — Contacts + Settings → Devices + Profile,
|
||||||
|
polish pass (keybinds, focus, drag-drop attachments).
|
||||||
|
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
|
||||||
|
pipeline nodes use; `electron-builder` → `.dmg`, `.exe`,
|
||||||
|
`.AppImage`, `.deb`.
|
||||||
|
|
||||||
|
### Открытые вопросы (desktop)
|
||||||
|
|
||||||
|
- **Auto-update channel**: гнать через Gitea releases (как node) или отдельный S3/Gitea-attachments?
|
||||||
|
- **Sandbox**: desktop хранит master Ed25519 — обязательно `safeStorage` (macOS Keychain, Windows DPAPI, libsecret на Linux). На Linux без libsecret — fallback на plaintext + warning.
|
||||||
|
- **Deep-link**: `dchain://chat/<pub>` для шаринга профилей/постов из браузера.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Меньшие хвосты (неприоритетно)
|
||||||
|
|
||||||
|
- `prev_hash mismatch` при двойной gossip-доставке — понизить до Debug уровня,
|
||||||
|
не пугать операторов (см. history с joiner-node bring-up).
|
||||||
|
- `/api/network-info.peers` у seed'а возвращает `[]` если
|
||||||
|
`DCHAIN_ANNOUNCE` не задан — добавить fallback:
|
||||||
|
если announce пустой, публиковать *первый listen-addr + свой peer_id*
|
||||||
|
(не `127.0.0.1`), чтобы joiner'ы не упирались в `peers:[]`.
|
||||||
|
- **Groups (3+ участников)** в чатах — тип `Contact.kind == 'group'`
|
||||||
|
в клиенте есть, а на backend'е не реализован. После v2.2.0, потому что
|
||||||
|
pairwise messaging ↔ group messaging на multi-device — это другой
|
||||||
|
математический слой (MLS или Sender Keys).
|
||||||
|
- Repa как weight в selection-lider PBFT — сейчас только scoring; для
|
||||||
|
настоящего proof-of-reputation нужно переписать leader-rotation.
|
||||||
@@ -15,13 +15,11 @@ import (
|
|||||||
|
|
||||||
func jsonOK(w http.ResponseWriter, v any) {
|
func jsonOK(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonErr(w http.ResponseWriter, err error, code int) {
|
func jsonErr(w http.ResponseWriter, err error, code int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,10 +510,63 @@ func apiIdentity(q ExplorerQuery) http.HandlerFunc {
|
|||||||
jsonErr(w, err, 500)
|
jsonErr(w, err, 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Multi-device (v2.2.0): stuff the active device count into the
|
||||||
|
// identity payload so a sender can decide upfront whether to
|
||||||
|
// fan out envelopes or fall back to the legacy single-X25519
|
||||||
|
// path (device_count == 0).
|
||||||
|
if q.DevicesOf != nil {
|
||||||
|
if devs, derr := q.DevicesOf(pubKey); derr == nil {
|
||||||
|
info.DeviceCount = len(devs)
|
||||||
|
}
|
||||||
|
}
|
||||||
jsonOK(w, info)
|
jsonOK(w, info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiDevices — GET /api/devices/{master_pub_or_addr}
|
||||||
|
//
|
||||||
|
// Returns the active (non-revoked) devices linked to a master Ed25519
|
||||||
|
// identity. Used by senders to fan out one envelope per device. Legacy
|
||||||
|
// identities (count=0) should be handled by the caller by falling back
|
||||||
|
// to IdentityInfo.X25519Pub — this endpoint is strictly the new registry.
|
||||||
|
func apiDevices(q ExplorerQuery) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
input := strings.TrimPrefix(r.URL.Path, "/api/devices/")
|
||||||
|
input = strings.Trim(input, "/")
|
||||||
|
if input == "" {
|
||||||
|
jsonErr(w, fmt.Errorf("master pubkey or address required"), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.DevicesOf == nil {
|
||||||
|
jsonErr(w, fmt.Errorf("device registry not available"), 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubKey, err := resolveAccountID(q, input)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recs, err := q.DevicesOf(pubKey)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]blockchain.DeviceInfo, 0, len(recs))
|
||||||
|
for _, rec := range recs {
|
||||||
|
out = append(out, blockchain.DeviceInfo{
|
||||||
|
X25519PubKey: rec.X25519PubKey,
|
||||||
|
DeviceName: rec.DeviceName,
|
||||||
|
AddedAt: rec.AddedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]any{
|
||||||
|
"master_pub": pubKey,
|
||||||
|
"count": len(out),
|
||||||
|
"devices": out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
|
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
|
||||||
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
|
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
|
||||||
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is
|
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is
|
||||||
|
|||||||
@@ -90,27 +90,29 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type item struct {
|
type item struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SenderPub string `json:"sender_pub"`
|
SenderPub string `json:"sender_pub"` // X25519 hex
|
||||||
RecipientPub string `json:"recipient_pub"`
|
SenderEd25519Pub string `json:"sender_ed25519_pub"` // master Ed25519 hex (optional; may be empty for legacy senders)
|
||||||
FeeUT uint64 `json:"fee_ut,omitempty"`
|
RecipientPub string `json:"recipient_pub"`
|
||||||
SentAt int64 `json:"sent_at"`
|
FeeUT uint64 `json:"fee_ut,omitempty"`
|
||||||
SentAtHuman string `json:"sent_at_human"`
|
SentAt int64 `json:"sent_at"`
|
||||||
Nonce []byte `json:"nonce"`
|
SentAtHuman string `json:"sent_at_human"`
|
||||||
Ciphertext []byte `json:"ciphertext"`
|
Nonce []byte `json:"nonce"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]item, 0, len(envelopes))
|
out := make([]item, 0, len(envelopes))
|
||||||
for _, env := range envelopes {
|
for _, env := range envelopes {
|
||||||
out = append(out, item{
|
out = append(out, item{
|
||||||
ID: env.ID,
|
ID: env.ID,
|
||||||
SenderPub: env.SenderPub,
|
SenderPub: env.SenderPub,
|
||||||
RecipientPub: env.RecipientPub,
|
SenderEd25519Pub: env.SenderEd25519PubKey,
|
||||||
FeeUT: env.FeeUT,
|
RecipientPub: env.RecipientPub,
|
||||||
SentAt: env.SentAt,
|
FeeUT: env.FeeUT,
|
||||||
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
|
SentAt: env.SentAt,
|
||||||
Nonce: env.Nonce,
|
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
|
||||||
Ciphertext: env.Ciphertext,
|
Nonce: env.Nonce,
|
||||||
|
Ciphertext: env.Ciphertext,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ type ExplorerQuery struct {
|
|||||||
NetStats func() (blockchain.NetStats, error)
|
NetStats func() (blockchain.NetStats, error)
|
||||||
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
|
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
|
||||||
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
|
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
|
||||||
|
// DevicesOf (multi-device v2.2.0) returns the identity's non-revoked
|
||||||
|
// device records. Empty slice if the identity hasn't linked any yet
|
||||||
|
// — senders fall back to IdentityInfo.X25519Pub for legacy clients.
|
||||||
|
DevicesOf func(masterPub string) ([]blockchain.DeviceRecord, error)
|
||||||
ValidatorSet func() ([]string, error)
|
ValidatorSet func() ([]string, error)
|
||||||
SubmitTx func(tx *blockchain.Transaction) error
|
SubmitTx func(tx *blockchain.Transaction) error
|
||||||
// ConnectedPeers (optional) returns the local libp2p view of currently
|
// ConnectedPeers (optional) returns the local libp2p view of currently
|
||||||
@@ -222,6 +226,7 @@ func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
|
|||||||
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
|
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
|
||||||
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
|
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
|
||||||
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
|
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
|
||||||
|
mux.HandleFunc("/api/devices/", apiDevices(q)) // GET /api/devices/{master_pub} — multi-device registry (v2.2.0)
|
||||||
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
|
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
|
||||||
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
|
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
|
||||||
// Live event stream (SSE) — GET /api/events
|
// Live event stream (SSE) — GET /api/events
|
||||||
|
|||||||
32
node/cors.go
Normal file
32
node/cors.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package node
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// withCORS wraps any http.Handler so every response carries the CORS
|
||||||
|
// headers browser-based clients (Electron renderer, web explorer from a
|
||||||
|
// different origin, mobile webview) need. Also short-circuits OPTIONS
|
||||||
|
// preflight requests with a 204 — without this, POST /api/tx with a
|
||||||
|
// JSON body triggers a preflight that the regular handler answers as
|
||||||
|
// 404/405 and the browser refuses the follow-up.
|
||||||
|
//
|
||||||
|
// The allow-list is wide on purpose. The node's security model doesn't
|
||||||
|
// rely on same-origin — API tokens (DCHAIN_API_TOKEN + DCHAIN_API_PRIVATE)
|
||||||
|
// and Ed25519 tx signatures are what gate writes. Cross-origin access is
|
||||||
|
// a first-class feature here, not an attack vector.
|
||||||
|
func withCORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Access-Control-Allow-Origin", "*")
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH")
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
|
||||||
|
h.Set("Access-Control-Expose-Headers", "Content-Length, Content-Type")
|
||||||
|
h.Set("Access-Control-Max-Age", "86400") // cache preflight for a day
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
// Preflight. Don't hand to the mux — just answer.
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -310,7 +310,10 @@ func (t *Tracker) ServeHTTP(q QueryFunc, fns ...func(*http.ServeMux)) http.Handl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListenAndServe starts the HTTP stats server on addr (e.g. ":8080").
|
// ListenAndServe starts the HTTP stats server on addr (e.g. ":8080").
|
||||||
|
// All responses pass through withCORS so browser + Electron clients
|
||||||
|
// get correct Access-Control-* headers and preflight OPTIONS requests
|
||||||
|
// are answered with 204 instead of falling through to the 404 handler.
|
||||||
func (t *Tracker) ListenAndServe(addr string, q QueryFunc, fns ...func(*http.ServeMux)) error {
|
func (t *Tracker) ListenAndServe(addr string, q QueryFunc, fns ...func(*http.ServeMux)) error {
|
||||||
handler := t.ServeHTTP(q, fns...)
|
handler := withCORS(t.ServeHTTP(q, fns...))
|
||||||
return http.ListenAndServe(addr, handler)
|
return http.ListenAndServe(addr, handler)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user