@leftoff/cli 0.9.1 → 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.
@@ -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
- const want = pickedMac()
59
- relay.mac = want && online.includes(want) ? want : relay.status.default
60
- cookieMac(relay.mac)
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
- await refreshStatus()
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 env = await sealRequest(relay.key, { device: relay.device, machine: relay.mac, body: { m: method, p: url.pathname + url.search, b, h } })
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
- if (j.default && j.default !== relay.mac) { relay.mac = j.default; cookieMac(relay.mac); window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return tunnel(url, init, true) }
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() },