@leftoff/cli 0.9.0 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/bin/leftoff.js +273 -273
- package/docs/SECURITY.md +4 -0
- package/docs/peer-protocol.md +3 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +7 -0
- package/mod/hooks/register.js +4 -2
- package/package.json +1 -1
- package/ui/app.css +29 -0
- package/ui/app.js +132 -29
- package/ui/direct.js +114 -0
- package/ui/icons.js +3 -0
- package/ui/picker.js +10 -1
- package/ui/relay-client.js +105 -10
package/ui/relay-client.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// your Macs through /_relay/api. The relay passes ciphertext. The live-update
|
|
8
8
|
// stream becomes a long-poll over the same tunnel. Which Mac: the one you
|
|
9
9
|
// picked in the status popover, else the most recently active one.
|
|
10
|
+
//
|
|
11
|
+
// 0.9.3: on a Mac that runs Leftoff itself, the same sealed calls go straight
|
|
12
|
+
// to its daemon on 127.0.0.1 (ui/direct.js), and back through the relay the
|
|
13
|
+
// moment that path fails.
|
|
10
14
|
import { sealRequest, openResponse } from './relay-crypto.js'
|
|
11
15
|
|
|
12
16
|
const meta = typeof document !== 'undefined' && document.querySelector('meta[name="leftoff-relay"]')
|
|
@@ -41,6 +45,18 @@ export async function forgetDeviceKey() {
|
|
|
41
45
|
// ------------------------------------------------------------ state
|
|
42
46
|
|
|
43
47
|
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
|
|
48
|
+
// ui/direct.js loads only on the board itself (never on pair.html, which the
|
|
49
|
+
// relay serves before a browser is paired and where this file is imported too).
|
|
50
|
+
let route = null, directOrRelay = null
|
|
51
|
+
async function loadDirect() {
|
|
52
|
+
try {
|
|
53
|
+
const d = await import('./direct.js')
|
|
54
|
+
route = new d.DirectRoute({ user: RELAY_USER, fetchImpl: realFetch, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0 })
|
|
55
|
+
directOrRelay = d.directOrRelay
|
|
56
|
+
} catch { route = null }
|
|
57
|
+
}
|
|
58
|
+
const INIT_WAIT_MS = 1200 // how long the first paint waits for this Mac's own answer
|
|
59
|
+
const RELOAD_KEY = 'leftoff.direct.reloaded'
|
|
44
60
|
|
|
45
61
|
function pickedMac() { try { return localStorage.getItem(MAC_KEY) } catch { return null } }
|
|
46
62
|
function remember(mac) {
|
|
@@ -55,9 +71,12 @@ async function refreshStatus() {
|
|
|
55
71
|
relay.status = await res.json()
|
|
56
72
|
relay.latencyMs = Math.round(performance.now() - t0)
|
|
57
73
|
const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
// on this Mac's direct path the board stays on this Mac, whatever the relay sees
|
|
75
|
+
if (!onDirect()) {
|
|
76
|
+
const want = pickedMac()
|
|
77
|
+
relay.mac = want && online.includes(want) ? want : relay.status.default
|
|
78
|
+
cookieMac(relay.mac)
|
|
79
|
+
}
|
|
61
80
|
return relay.status
|
|
62
81
|
}
|
|
63
82
|
|
|
@@ -65,8 +84,60 @@ async function init() {
|
|
|
65
84
|
const rec = await loadDeviceKey()
|
|
66
85
|
if (!rec || !rec.key || (rec.user && rec.user !== RELAY_USER)) { needsKey(); throw new Error('no device key') }
|
|
67
86
|
relay.device = rec.device; relay.key = rec.key
|
|
68
|
-
|
|
87
|
+
// Ask this computer's own Leftoff while the relay answers; a quick yes goes direct.
|
|
88
|
+
await loadDirect()
|
|
89
|
+
const probing = probeDirect()
|
|
90
|
+
const [, quick] = await Promise.all([refreshStatus(), Promise.race([probing, new Promise((r) => setTimeout(() => r(undefined), INIT_WAIT_MS))])])
|
|
91
|
+
if (quick) goDirect(quick)
|
|
92
|
+
else if (quick === undefined) probing.then((m) => { if (m) lateDirect(m) })
|
|
69
93
|
if (!relay.mac) { asleep(); throw new Error('asleep') }
|
|
94
|
+
watchDirect()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ------------------------------------------------------------ direct (0.9.3)
|
|
98
|
+
|
|
99
|
+
function probeDirect() {
|
|
100
|
+
return route ? route.probe({ picked: pickedMac(), verify: verifyDirect }) : Promise.resolve(null)
|
|
101
|
+
}
|
|
102
|
+
// A sealed round trip through this Mac's daemon: only our own Mac can answer it.
|
|
103
|
+
async function verifyDirect(machine) {
|
|
104
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine, body: { m: 'GET', p: '/v1/remote/ping', b: null, h: {} } })
|
|
105
|
+
const out = await openResponse(relay.key, env, await route.send(env))
|
|
106
|
+
return out.s === 200
|
|
107
|
+
}
|
|
108
|
+
function onDirect() { return !!(route && route.on && route.machine === relay.mac) }
|
|
109
|
+
function routeChanged() { try { window.dispatchEvent(new CustomEvent('leftoff-route', { detail: { route: onDirect() ? 'direct' : 'relay', mac: relay.mac } })) } catch {} }
|
|
110
|
+
// A sealed response -> a fetch Response (204/304 carry no body).
|
|
111
|
+
function toResponse(out) { return new Response(out.s === 204 || out.s === 205 || out.s === 304 ? null : out.b, { status: out.s, headers: out.h }) }
|
|
112
|
+
function goDirect(machine) {
|
|
113
|
+
route.use(machine)
|
|
114
|
+
if (relay.mac !== machine) { relay.mac = machine; cookieMac(machine) }
|
|
115
|
+
routeChanged()
|
|
116
|
+
}
|
|
117
|
+
// The first answer came after the board had loaded. Same Mac: just switch.
|
|
118
|
+
// Another Mac (the relay's pick), and you didn't choose it: load once more so
|
|
119
|
+
// the whole board comes from this one (once per tab, so a slow daemon can't loop).
|
|
120
|
+
function lateDirect(machine) {
|
|
121
|
+
if (!route || route.on) return
|
|
122
|
+
if (machine === relay.mac) return goDirect(machine)
|
|
123
|
+
if (pickedMac()) return
|
|
124
|
+
try { if (sessionStorage.getItem(RELOAD_KEY)) return; sessionStorage.setItem(RELOAD_KEY, '1') } catch { return }
|
|
125
|
+
location.reload()
|
|
126
|
+
}
|
|
127
|
+
function dropDirect() { routeChanged() }
|
|
128
|
+
// Back on the relay: look again every minute and when the tab comes back.
|
|
129
|
+
// A later yes only switches the path for the Mac already on screen.
|
|
130
|
+
let watching = false
|
|
131
|
+
function watchDirect() {
|
|
132
|
+
if (watching || !route || route.never) return
|
|
133
|
+
watching = true
|
|
134
|
+
const again = (focus) => {
|
|
135
|
+
if (!route.due({ focus })) return
|
|
136
|
+
probeDirect().then((m) => { if (m && !route.on && m === relay.mac) goDirect(m) })
|
|
137
|
+
}
|
|
138
|
+
setInterval(() => again(false), 15000)
|
|
139
|
+
window.addEventListener('focus', () => again(true))
|
|
140
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
|
|
70
141
|
}
|
|
71
142
|
|
|
72
143
|
// ------------------------------------------------------------ the tunnel
|
|
@@ -79,17 +150,38 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
79
150
|
const h = {}
|
|
80
151
|
const hdrs = new Headers(init.headers || {})
|
|
81
152
|
if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
|
|
82
|
-
const
|
|
153
|
+
const body = { m: method, p: url.pathname + url.search, b, h }
|
|
154
|
+
if (!onDirect()) return viaRelay(body, init, retried)
|
|
155
|
+
return directOrRelay({
|
|
156
|
+
route,
|
|
157
|
+
signal: init.signal,
|
|
158
|
+
direct: async () => {
|
|
159
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine: route.machine, body })
|
|
160
|
+
return toResponse(await openResponse(relay.key, env, await route.send(env, { signal: init.signal })))
|
|
161
|
+
},
|
|
162
|
+
relay: () => viaRelay(body, init, retried),
|
|
163
|
+
onDrop: dropDirect,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function viaRelay(body, init, retried) {
|
|
168
|
+
const gone = relay.mac
|
|
169
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
83
170
|
const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
|
|
84
|
-
if (res.status === 200)
|
|
85
|
-
const out = await openResponse(relay.key, env, await res.json())
|
|
86
|
-
return new Response(out.b, { status: out.s, headers: out.h })
|
|
87
|
-
}
|
|
171
|
+
if (res.status === 200) return toResponse(await openResponse(relay.key, env, await res.json()))
|
|
88
172
|
const j = await res.clone().json().catch(() => ({}))
|
|
89
173
|
if (res.status === 401) { notPaired(j.code === 'unknown_device'); return res }
|
|
90
174
|
if (res.status === 503 && j.code === 'mac_offline' && !retried) {
|
|
91
175
|
// the Mac you were on went to sleep: another one takes over, or the asleep page
|
|
92
|
-
|
|
176
|
+
// another call already moved the board to the Mac that took over: follow it
|
|
177
|
+
if (relay.mac && relay.mac !== gone) return viaRelay(body, init, true)
|
|
178
|
+
if (j.default && j.default !== gone) { relay.mac = j.default; cookieMac(relay.mac); window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
179
|
+
// 0.9.3: the relay can still list a Mac that just went away (its socket
|
|
180
|
+
// closing as we asked, e.g. this Mac's daemon stopping under the direct
|
|
181
|
+
// path): look again in a moment before calling it asleep.
|
|
182
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
183
|
+
await refreshStatus().catch(() => {})
|
|
184
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
93
185
|
asleep()
|
|
94
186
|
}
|
|
95
187
|
return res
|
|
@@ -176,6 +268,9 @@ export const leftoffRelay = {
|
|
|
176
268
|
get mac() { return relay.mac },
|
|
177
269
|
get device() { return relay.device },
|
|
178
270
|
get latencyMs() { return relay.latencyMs },
|
|
271
|
+
// 0.9.3: 'direct' (this Mac's own daemon over 127.0.0.1) or 'relay'
|
|
272
|
+
get route() { return onDirect() ? 'direct' : 'relay' },
|
|
273
|
+
get directLatencyMs() { return route ? route.latencyMs : null },
|
|
179
274
|
status: () => refreshStatus(),
|
|
180
275
|
// Switch Macs: the board reloads from the other one.
|
|
181
276
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|