diff --git a/desktop/package.json b/desktop/package.json index 67253b3..3b43e1f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "dchain-desktop", - "version": "2.2.0-rc1", + "version": "2.2.0", "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", @@ -36,25 +36,40 @@ "build": { "appId": "com.dchain.desktop", "productName": "DChain", + "copyright": "Copyright © 2026 DChain contributors", + "asar": true, + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "files": [ "dist/**/*", - "dist-electron/**/*" + "dist-electron/**/*", + "!**/*.map", + "!**/node_modules/**/test/**", + "!**/node_modules/**/tests/**" ], + "directories": { + "output": "release", + "buildResources": "resources" + }, "mac": { - "target": [ - "dmg" - ] + "target": ["dmg", "zip"], + "category": "public.app-category.social-networking", + "hardenedRuntime": true, + "gatekeeperAssess": false }, "win": { - "target": [ - "nsis" - ] + "target": ["nsis", "portable"] + }, + "nsis": { + "oneClick": false, + "allowElevation": true, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true }, "linux": { - "target": [ - "AppImage", - "deb" - ] - } + "target": ["AppImage", "deb"], + "category": "Network" + }, + "publish": null } } diff --git a/desktop/src/hooks/useUpdateCheck.ts b/desktop/src/hooks/useUpdateCheck.ts new file mode 100644 index 0000000..de694e8 --- /dev/null +++ b/desktop/src/hooks/useUpdateCheck.ts @@ -0,0 +1,100 @@ +// useUpdateCheck — polls the configured node's /api/update-check once +// per launch (+ every 6h while the window stays open), compares the +// Gitea release tag against this client's app version, and exposes +// { latest, url } when ours is older. +// +// Why reuse the node endpoint? The DChain node already fetches Gitea +// releases on behalf of its operator; piggybacking on the same cached +// JSON means the desktop client doesn't need a direct Gitea token or +// a separate update feed. One source of truth, no new infra. + +import { useEffect, useState } from 'react'; +import { get } from '@/lib/api'; + +interface UpdateCheck { + current?: { tag?: string }; + latest?: { tag?: string; commit?: string; url?: string; published_at?: string }; + update_available?: boolean; + source?: string; + checked_at?: string; +} + +export interface UpdateInfo { + latestTag: string; + url: string; + publishedAt: string; +} + +export function useUpdateCheck(): UpdateInfo | null { + const [info, setInfo] = useState(null); + + useEffect(() => { + let cancelled = false; + + const tick = async () => { + try { + // Our version (set in package.json, baked into Electron at build time). + const myVersion = (await window.dchain.app.version()).trim(); + const r = await get('/api/update-check'); + if (cancelled) return; + const latest = r.latest?.tag?.trim() ?? ''; + if (!latest || !r.latest?.url) { setInfo(null); return; } + // Compare semver-ish. The node's own `update_available` flag + // compares vs. the NODE's version, not ours, so we re-derive. + if (isNewer(latest, myVersion)) { + setInfo({ + latestTag: latest, + url: r.latest.url, + publishedAt: r.latest.published_at ?? '', + }); + } else { + setInfo(null); + } + } catch { + // Node doesn't have the endpoint configured, or offline — quiet fail. + } + }; + + tick(); + const t = setInterval(tick, 6 * 60 * 60 * 1000); + return () => { cancelled = true; clearInterval(t); }; + }, []); + + return info; +} + +/** + * isNewer — loose semver compare for strings like `v2.2.0` / `2.2.0-rc1`. + * Strips leading `v`, splits on dots and the first `-` (pre-release + * suffix), compares numerically left-to-right. Pre-release tags are + * considered OLDER than the bare version (so `2.2.0 > 2.2.0-rc1`). + * Not a full semver implementation — good enough to decide whether to + * show the "update available" badge. If our parse fails, we assume no + * update (safer than nagging users with false positives). + */ +export function isNewer(candidate: string, reference: string): boolean { + const a = parseVersion(candidate); + const b = parseVersion(reference); + if (!a || !b) return false; + for (let i = 0; i < Math.max(a.nums.length, b.nums.length); i++) { + const x = a.nums[i] ?? 0; + const y = b.nums[i] ?? 0; + if (x !== y) return x > y; + } + // All numeric parts equal → compare pre-release. `""` (stable) beats any suffix. + if (a.pre === b.pre) return false; + if (a.pre === '') return true; // stable > prerelease + if (b.pre === '') return false; // prerelease < stable + return a.pre > b.pre; // alpha6 > alpha5 lexically, fine in practice +} + +function parseVersion(v: string): { nums: number[]; pre: string } | null { + if (!v) return null; + const clean = v.trim().replace(/^v/i, ''); + const dash = clean.indexOf('-'); + const head = dash >= 0 ? clean.slice(0, dash) : clean; + const pre = dash >= 0 ? clean.slice(dash + 1) : ''; + const nums = head.split('.').map(s => parseInt(s, 10)); + if (nums.some(n => !Number.isFinite(n))) return null; + return { nums, pre }; +} diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index 7edf6a7..d697519 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -175,6 +175,30 @@ export async function getTxDetail(txID: string): Promise { } } +// ─── Contact requests (on-chain, via /relay/contacts) ─────────────────── + +export interface ContactRequestRaw { + requester_pub: string; + requester_addr: string; + status: string; // "pending" | "accepted" | "blocked" + intro: string; + fee_ut: number; + tx_id: string; + created_at: number; +} + +/** + * GET /relay/contacts?pub= — returns every on-chain + * CONTACT_REQUEST addressed to `pub`, regardless of status. The UI + * filters by pending before showing. + */ +export async function fetchContactRequests(edPub: string): Promise { + try { + const r = await get<{ contacts?: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPub}`); + return r.contacts ?? []; + } catch { return []; } +} + /** Resolve a DC address or @username into an Ed25519 pub (hex). */ export async function resolveAccount(input: string): Promise { const trimmed = input.trim(); diff --git a/desktop/src/lib/tx.ts b/desktop/src/lib/tx.ts index d6c8cba..0acd5c0 100644 --- a/desktop/src/lib/tx.ts +++ b/desktop/src/lib/tx.ts @@ -126,6 +126,77 @@ export function buildUnlinkDeviceTx(p: { }; } +/** + * CONTACT_REQUEST — paid first-contact tx. `amount` carries the + * anti-spam fee (≥ MinContactFee = 5000 µT on the node), credited to + * the recipient's balance as an incentive to accept; `fee` is the + * regular network fee. Optional `intro` plaintext is embedded in the + * payload so the receiver sees "who is this" before accepting. + */ +export function buildContactRequestTx(p: { + from: string; + to: string; + contactFee: number; // µT — ≥ 5000, paid to recipient + privKey: string; + intro?: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify(p.intro ? { intro: p.intro } : {})); + const canon = canonicalBytes({ + id, type: 'CONTACT_REQUEST', from: p.from, to: p.to, + amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp, + }); + return { + id, type: 'CONTACT_REQUEST', from: p.from, to: p.to, + amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canon, p.privKey), + }; +} + +/** + * ACCEPT_CONTACT — recipient side, empties the pending request and + * publishes the peer's X25519 key so the requester can start sending + * encrypted envelopes. Tx.to = original requester's pub. + */ +export function buildAcceptContactTx(p: { + from: string; to: string; privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64('{}'); + const canon = canonicalBytes({ + id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to, + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + return { + id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to, + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canon, p.privKey), + }; +} + +/** + * BLOCK_CONTACT — sticky rejection. Subsequent CONTACT_REQUEST txs + * from the same sender are dropped at applyTx level on the node. + */ +export function buildBlockContactTx(p: { + from: string; to: string; privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64('{}'); + const canon = canonicalBytes({ + id, type: 'BLOCK_CONTACT', from: p.from, to: p.to, + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + return { + id, type: 'BLOCK_CONTACT', from: p.from, to: p.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 diff --git a/desktop/src/sections/contacts/ContactsList.tsx b/desktop/src/sections/contacts/ContactsList.tsx index 38505bb..df04124 100644 --- a/desktop/src/sections/contacts/ContactsList.tsx +++ b/desktop/src/sections/contacts/ContactsList.tsx @@ -3,17 +3,40 @@ // WS presence + request inbox plumbing; placeholder headers are left // in the UI so the shape is visible. -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useStore } from '@/lib/store'; import { shortAddr } from '@/lib/crypto'; +import { fetchContactRequests, type ContactRequestRaw } from '@/lib/api'; import type { Contact } from '@/lib/types'; +import { NewContactModal } from './NewContactModal'; +import { RequestsList } from './RequestsList'; export function ContactsList(): React.ReactElement { const contacts = useStore(s => s.contacts); + const keyFile = useStore(s => s.keyFile); const sel = useStore(s => s.selectedContact); const setSel = useStore(s => s.setSelectedContact); const [q, setQ] = useState(''); + const [tab, setTab] = useState<'list' | 'requests'>('list'); + const [newOpen, setNewOpen] = useState(false); + const [requests, setRequests] = useState([]); + + // Load pending contact requests (on-chain inbox). Refreshes when the + // tab is opened and after a new request is sent so the counter moves. + const refreshRequests = async () => { + if (!keyFile) return; + const list = await fetchContactRequests(keyFile.pub_key); + // Filter to pending only — accepted ones turn into contacts. + const knownContacts = new Set(contacts.map(c => c.address)); + setRequests(list.filter(r => + r.status === 'pending' && !knownContacts.has(r.requester_pub), + )); + }; + + useEffect(() => { refreshRequests(); const t = setInterval(refreshRequests, 15_000); return () => clearInterval(t); }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [keyFile, contacts]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); if (!needle) return contacts; @@ -34,41 +57,111 @@ export function ContactsList(): React.ReactElement { return (
- {/* Search */} + {/* Sticky header: tab switcher + search / action row */}
- setQ(e.target.value)} - placeholder="Filter…" - style={{ - width: '100%', boxSizing: 'border-box', - background: '#0a0a0a', border: '1px solid #1f1f1f', - borderRadius: 8, padding: '8px 10px', - color: '#fff', fontSize: 13, outline: 'none', - }} - /> +
+ setTab('list')} + /> + setTab('requests')} + /> +
+ {tab === 'list' && ( +
+ setQ(e.target.value)} + placeholder="Filter…" + style={{ + flex: 1, boxSizing: 'border-box', + background: '#0a0a0a', border: '1px solid #1f1f1f', + borderRadius: 8, padding: '8px 10px', + color: '#fff', fontSize: 13, outline: 'none', + }} + /> + +
+ )}
- {sorted.length === 0 ? ( + {tab === 'requests' ? ( + + ) : sorted.length === 0 ? (
- No contacts yet. They appear as chats start, or as peers pair - their own devices with yours. + No contacts yet. Tap + New above to send a contact request, + or pair another of your own devices via Settings → Devices.
) : ( sorted.map(c => ( setSel(c.address)} /> )) )} + + {newOpen && ( + setNewOpen(false)} + onSent={() => { setNewOpen(false); refreshRequests(); }} + /> + )}
); } +function TabBtn({ + label, active, onClick, badge, +}: { + label: string; active: boolean; onClick: () => void; badge?: number; +}) { + return ( + + ); +} + function Row({ c, active, onClick }: { c: Contact; active: boolean; onClick: () => void; }) { diff --git a/desktop/src/sections/contacts/NewContactModal.tsx b/desktop/src/sections/contacts/NewContactModal.tsx new file mode 100644 index 0000000..38027fe --- /dev/null +++ b/desktop/src/sections/contacts/NewContactModal.tsx @@ -0,0 +1,323 @@ +// NewContactModal — send an on-chain CONTACT_REQUEST to a new peer. +// +// Flow: +// 1. Enter @username / DC / hex → resolve into an Ed25519 pub. +// 2. Optional intro + fee-tier pick (5k / 10k / 50k µT). +// 3. Submit CONTACT_REQUEST tx with amount = contactFee. +// The peer sees the request in their Contacts → Requests tab and can +// Accept / Reject. After acceptance an encrypted chat becomes possible +// via the existing /relay/broadcast pipeline. + +import React, { useMemo, useState } from 'react'; +import { useStore } from '@/lib/store'; +import { + resolveAccount, getIdentity, getBalance, + type IdentityInfo, +} from '@/lib/api'; +import { buildContactRequestTx, submitTx, humanizeTxError } from '@/lib/tx'; +import { shortAddr } from '@/lib/crypto'; + +const FEE_TIERS = [ + { value: 5_000, label: 'Min', hint: 'enough for a low-spam node' }, + { value: 10_000, label: 'Standard', hint: 'default' }, + { value: 50_000, label: 'Priority', hint: 'more attention-grabbing' }, +]; +const MIN_NETWORK_FEE = 1_000; + +export function NewContactModal({ onClose, onSent }: { + onClose: () => void; + onSent: () => void; +}): React.ReactElement { + const keyFile = useStore(s => s.keyFile); + + const [query, setQuery] = useState(''); + const [resolved, setResolved] = useState<{ + pub: string; identity: IdentityInfo | null; + } | null>(null); + const [intro, setIntro] = useState(''); + const [fee, setFee] = useState(FEE_TIERS[1].value); + const [searching, setSearching] = useState(false); + const [sending, setSending] = useState(false); + const [err, setErr] = useState(null); + const [balance, setBalance] = useState(null); + + const totalCost = fee + MIN_NETWORK_FEE; + const insufficient = balance !== null && balance < totalCost; + + React.useEffect(() => { + if (!keyFile) return; + getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null)); + }, [keyFile]); + + const search = async () => { + const q = query.trim(); + if (!q) return; + setSearching(true); setErr(null); setResolved(null); + try { + const pub = await resolveAccount(q); + if (!pub) { setErr(`Couldn't resolve "${q}"`); return; } + if (keyFile && pub.toLowerCase() === keyFile.pub_key.toLowerCase()) { + setErr('That\'s you — open Saved Messages in the chat list instead.'); + return; + } + const id = await getIdentity(pub); + setResolved({ pub, identity: id }); + } catch (e) { + setErr(String(e)); + } finally { + setSearching(false); + } + }; + + const send = async () => { + if (!keyFile || !resolved || sending) return; + setSending(true); setErr(null); + try { + const tx = buildContactRequestTx({ + from: keyFile.pub_key, + to: resolved.pub, + contactFee: fee, + intro: intro.trim() || undefined, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + onSent(); + } catch (e) { + setErr(humanizeTxError(e)); + } finally { + setSending(false); + } + }; + + const peerName = useMemo(() => { + if (!resolved) return ''; + if (resolved.identity?.nickname) return `@${resolved.identity.nickname}`; + return shortAddr(resolved.pub, 8); + }, [resolved]); + + return ( + {} : onClose}> +
+
+ + {/* Search */} + +
+ setQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') search(); }} + placeholder="@username, DC-address, or hex pub" + spellCheck={false} + autoFocus + style={{ + flex: 1, background: '#000', border: '1px solid #1f1f1f', + borderRadius: 8, padding: '10px 12px', + color: '#fff', fontSize: 13, fontFamily: 'monospace', + outline: 'none', + }} + /> + +
+ + {/* Resolved peer preview */} + {resolved && ( +
+
{peerName.replace(/^@/, '').charAt(0).toUpperCase()}
+
+
+ {peerName} +
+
+ {resolved.pub} +
+
+ {resolved.identity?.x25519_pub + ? '✓ has encryption key published' + : '⚠ no encryption key on chain yet (messaging disabled until they register)'} +
+
+
+ )} + + {/* Intro */} + {resolved && ( + <> + +