feat(desktop): let desktop users turn the relay off

The relay was on with no way to turn it off on Mac, Windows and Linux (TODO
#134). #130 landed it in the shared src/bare.js and desktop picks the ENGINE up
automatically - prepack.js vendors src/ - but the desktop renderer is a separate
UI tree, so the control that shipped alongside it on mobile was never wired.

That is the one setting a privacy-minded user is most likely to look for, so it
should not have stayed mobile-only.

Two pieces, as the item described:

- getRelayStatus / setUseRelay added to the db map in src/ui-desktop/main.jsx
- a Connection section in the desktop SettingsModal, gated on
  `relayStatus.configured` so a build with no relay wired shows nothing rather
  than a switch that controls nothing

The copy is deliberately the same as mobile's, down to the sentence about the
relay only ever carrying scrambled data it cannot read: same feature, same
promise, and no reason for two devices to describe it differently. Both the
in-use counters and the off-state explanation are carried over too.

Verified on the running desktop app over CDP, driving the real UI rather than
the IPC alone:
  - getRelayStatus returns configured:true, so the section renders
  - the CONNECTION heading, the toggle and the promise copy are all present in
    the Settings modal
  - clicking the toggle in the UI flips the worklet from useRelay=true to
    useRelay=false, and the off-state copy ("strictly device to device")
    appears
  - setUseRelay round-trips both ways and the status reflects it

336 unit tests still pass; this adds no pure decision to test, so the evidence
is the round trip above rather than new unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HG8ayyquJuDMPSVLKDQKVh
This commit is contained in:
Your Name
2026-07-27 10:12:30 -05:00
co-authored by Claude Opus 5
parent e2ff3cbba3
commit 3dc72b1ade
2 changed files with 64 additions and 0 deletions
@@ -43,6 +43,10 @@ export function SettingsModal ({ tokens, profile, updateProfile, db, sync, event
// auto-started at login. Default off; the truth lives in the OS login-item,
// read here on open via db.getLaunchAtLogin (electron main intercept).
const [launchAtLogin, setLaunchAtLogin] = useState(false)
// TODO #134 - null until loaded; `configured: false` means this build has no
// relay wired at all, in which case the section stays hidden rather than
// offering a switch that controls nothing.
const [relayStatus, setRelayStatus] = useState(null)
useEffect(() => {
function onKey (e) { if (e.key === 'Escape') onClose() }
@@ -56,6 +60,25 @@ export function SettingsModal ({ tokens, profile, updateProfile, db, sync, event
return () => { alive = false }
}, [db])
useEffect(() => {
let alive = true
db?.getRelayStatus?.().then(s => { if (alive) setRelayStatus(s ?? null) }).catch(() => {})
return () => { alive = false }
}, [db])
// Optimistic like the toggle above, and re-read afterwards so the counters
// below reflect what the worklet actually did rather than what we asked for.
async function handleUseRelayChange (next) {
setRelayStatus(prev => prev ? { ...prev, useRelay: next } : prev)
try {
await db?.setUseRelay?.(next)
const fresh = await db?.getRelayStatus?.()
if (fresh) setRelayStatus(fresh)
} catch (e) {
setRelayStatus(prev => prev ? { ...prev, useRelay: !next } : prev)
}
}
async function handleLaunchAtLoginChange (next) {
setLaunchAtLogin(next) // optimistic
try {
@@ -295,6 +318,40 @@ export function SettingsModal ({ tokens, profile, updateProfile, db, sync, event
</div>
</div>
{/* Connection — the off-LAN relay backstop (TODO #130 engine, #134 control).
Wording deliberately mirrors mobile's: same feature, same promise. */}
{relayStatus?.configured && (
<div style={{ marginBottom: 18 }}>
<div style={label}>Connection</div>
<div style={row}>
<div style={{ flex: 1, fontSize: 13 }}>Use a relay when direct fails</div>
<ToggleSwitch tokens={tokens} value={relayStatus.useRelay !== false}
onChange={handleUseRelayChange} />
</div>
<div style={{ fontSize: 12, color: tokens.muted, lineHeight: 1.5, paddingTop: 2 }}>
Some networks block devices from connecting straight to each other. When
that happens, PearCal routes through a relay run by PeerLoom. It only ever
carries scrambled data it cant read, and its only used after a direct
connection has already failed.
</div>
{relayStatus.useRelay !== false
&& (relayStatus.offers > 0 || (relayStatus.relaying?.successes ?? 0) > 0) && (
<div style={{ fontSize: 12, color: tokens.muted, lineHeight: 1.5, paddingTop: 6 }}>
Used since the app started: {relayStatus.offers} outgoing
{(relayStatus.relaying?.successes ?? 0) > 0
? `, ${relayStatus.relaying.successes} incoming`
: ''}
</div>
)}
{relayStatus.useRelay === false && (
<div style={{ fontSize: 12, color: tokens.muted, lineHeight: 1.5, paddingTop: 6 }}>
Off connections stay strictly device to device. On a network that
blocks them, syncing may not work at all.
</div>
)}
</div>
)}
<div style={{ marginBottom: 18 }}>
<div style={label}>Holidays</div>
<div style={{ opacity: holidayWorking ? 0.6 : 1, transition: 'opacity 0.2s' }}>
+7
View File
@@ -101,6 +101,13 @@ const db = {
putRsvp: (eid, mid, s, gids) => window.__pearDB.call('putRsvp', eid, mid, s, gids),
getPrivateNote: (id) => window.__pearDB.call('getPrivateNote', id),
putPrivateNote: (id, text) => window.__pearDB.call('putPrivateNote', id, text),
// TODO #134 - the relay ENGINE was already here (prepack.js vendors src/, so
// desktop picks up the shared worklet automatically), but the renderer is a
// separate UI tree, so the relay was on with no way to turn it off. It is the
// one control a privacy-minded user might actually want, so it should not have
// stayed mobile-only.
getRelayStatus: () => window.__pearDB.call('getRelayStatus'),
setUseRelay: (on) => window.__pearDB.call('setUseRelay', on),
getBlindPeerKey: () => window.__pearDB.call('getBlindPeerKey'),
setBlindPeerKey: (k) => window.__pearDB.call('setBlindPeerKey', k),
removeBlindPeerKey: () => window.__pearDB.call('removeBlindPeerKey'),