@leftoff/cli 0.9.1 → 0.9.4
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 +27 -0
- package/bin/leftoff.js +270 -270
- 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 +58 -0
- package/ui/app.js +135 -29
- package/ui/direct.js +114 -0
- package/ui/icons.js +3 -0
- package/ui/picker.js +85 -10
- package/ui/relay-client.js +105 -10
package/ui/picker.js
CHANGED
|
@@ -283,8 +283,9 @@ function onKey(host, e) {
|
|
|
283
283
|
// choosing one closes the menu. Same look as the selects' menu: under (or
|
|
284
284
|
// above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
|
|
285
285
|
// Enter/Space, Escape (focus back on the ⋯), Tab, and a tap outside closes.
|
|
286
|
-
// items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls }]
|
|
287
|
-
|
|
286
|
+
// items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls, sub, on }]
|
|
287
|
+
// plus { note } (a plain line) and { …item, more: { title, items } } (a › to a second page)
|
|
288
|
+
let act = null // { anchor, menu, backdrop, items, page, up }
|
|
288
289
|
export function actionMenuOpen() { return !!act }
|
|
289
290
|
export function closeActionMenu({ focus = false } = {}) {
|
|
290
291
|
if (!act) return
|
|
@@ -294,18 +295,22 @@ export function closeActionMenu({ focus = false } = {}) {
|
|
|
294
295
|
anchor.setAttribute('aria-expanded', 'false')
|
|
295
296
|
if (focus && anchor.isConnected) anchor.focus()
|
|
296
297
|
}
|
|
297
|
-
|
|
298
|
+
// h: the height to place for (a page change animates to it); keep: stay on the
|
|
299
|
+
// side of the ⋯ the menu opened on, so a second page doesn't make it jump.
|
|
300
|
+
function placeAction({ h: hTo, keep = false } = {}) {
|
|
298
301
|
if (!act) return
|
|
299
302
|
const { menu, anchor } = act
|
|
300
303
|
const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
|
|
301
304
|
if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
|
|
302
305
|
const r = anchor.getBoundingClientRect()
|
|
303
306
|
const pad = 8
|
|
304
|
-
const w = menu.offsetWidth, h = menu.offsetHeight
|
|
305
|
-
const up = vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
|
|
307
|
+
const w = menu.offsetWidth, h = hTo || menu.offsetHeight
|
|
308
|
+
const up = keep && act.up != null ? act.up : vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
|
|
309
|
+
act.up = up
|
|
306
310
|
// right edges line up (the ⋯ sits at the end of the row), never past the viewport
|
|
307
311
|
menu.style.left = Math.max(pad, Math.min(r.right - w, vw - pad - w)) + 'px'
|
|
308
|
-
|
|
312
|
+
// never past the bottom either (a taller second page can't fall off the screen)
|
|
313
|
+
menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : Math.max(pad, Math.min(r.bottom + 6, vh - pad - h))) + 'px'
|
|
309
314
|
menu.classList.toggle('up', up)
|
|
310
315
|
}
|
|
311
316
|
function actFocus(k) {
|
|
@@ -314,6 +319,44 @@ function actFocus(k) {
|
|
|
314
319
|
if (!n) return
|
|
315
320
|
act.items[((k % n) + n) % n].focus()
|
|
316
321
|
}
|
|
322
|
+
const SVG = (d, cls = 'ic') => '<svg class="' + cls + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
|
|
323
|
+
const ACT_NEXT = SVG('<path d="m9 18 6-6-6-6"/>', 'ic lo-act-chev')
|
|
324
|
+
const ACT_BACK = SVG('<path d="m15 18-6-6 6-6"/>', 'ic lo-act-chev')
|
|
325
|
+
const ACT_CHECK = SVG('<path d="M20 6 9 17l-5-5"/>', 'ic lo-act-check')
|
|
326
|
+
const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
327
|
+
// 0.9.4: show page p of the menu (0 is the main one). The new page slides in
|
|
328
|
+
// from the side it lives on and the menu's height eases to the new one (its
|
|
329
|
+
// width is fixed), unless you asked for less motion.
|
|
330
|
+
function actPage(p, { focus } = {}) {
|
|
331
|
+
if (!act) return
|
|
332
|
+
const { menu } = act
|
|
333
|
+
const to = menu.querySelector('.lo-act-page[data-page="' + p + '"]'), from = act.page
|
|
334
|
+
if (!to || !from || to === from) return
|
|
335
|
+
const back = p < Number(from.dataset.page)
|
|
336
|
+
const h0 = menu.offsetHeight
|
|
337
|
+
from.hidden = true; to.hidden = false
|
|
338
|
+
from.classList.remove('lo-in-fwd', 'lo-in-back')
|
|
339
|
+
act.page = to
|
|
340
|
+
act.items = [...to.querySelectorAll('[role="menuitem"]')]
|
|
341
|
+
menu.classList.toggle('on-sub', p > 0)
|
|
342
|
+
const h1 = menu.offsetHeight
|
|
343
|
+
if (!reducedMotion()) {
|
|
344
|
+
to.classList.remove('lo-in-fwd', 'lo-in-back'); void to.offsetWidth
|
|
345
|
+
to.classList.add(back ? 'lo-in-back' : 'lo-in-fwd')
|
|
346
|
+
if (h1 !== h0) {
|
|
347
|
+
menu.classList.remove('lo-act-sizing')
|
|
348
|
+
menu.style.height = h0 + 'px'; void menu.offsetHeight
|
|
349
|
+
menu.classList.add('lo-act-sizing')
|
|
350
|
+
menu.style.height = h1 + 'px'
|
|
351
|
+
let t = 0
|
|
352
|
+
const done = (e) => { if (e && e.target !== menu) return; clearTimeout(t); menu.removeEventListener('transitionend', done); if (act && act.menu === menu) { menu.style.height = ''; menu.classList.remove('lo-act-sizing') } }
|
|
353
|
+
menu.addEventListener('transitionend', done); t = setTimeout(done, 320)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
placeAction({ h: h1, keep: true })
|
|
357
|
+
const el = focus || (p > 0 ? (to.querySelector('.lo-act.on') || act.items[1] || act.items[0]) : act.items[0])
|
|
358
|
+
if (el) el.focus({ preventScroll: true })
|
|
359
|
+
}
|
|
317
360
|
export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
318
361
|
if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
|
|
319
362
|
closeActionMenu(); closeMenu()
|
|
@@ -324,30 +367,62 @@ export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
|
324
367
|
menu.id = id
|
|
325
368
|
menu.setAttribute('role', 'menu')
|
|
326
369
|
if (title) menu.setAttribute('aria-label', title)
|
|
370
|
+
// 0.9.2: an item may carry a sub line ({ sub }) or be a plain line ({ note }:
|
|
371
|
+
// what the menu is about, read out as its description).
|
|
372
|
+
// 0.9.4: an item may open a second page ({ more: { title, items } }), the iOS
|
|
373
|
+
// way: the row itself does its action, and a › at its right edge (its own
|
|
374
|
+
// target) slides the menu over to that page, which starts with a ‹ title row
|
|
375
|
+
// (back). Items there may be { on }: a ✓, e.g. your default length.
|
|
376
|
+
const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.on ? ' on' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
|
|
377
|
+
(it.sub ? '<span class="lo-act-txt"><span>' + esc(it.label) + '</span><span class="lo-act-sub">' + esc(it.sub) + '</span></span>' : '<span>' + esc(it.label) + '</span>') +
|
|
378
|
+
(it.on && it.onLabel ? '<span class="lo-vh">' + esc(it.onLabel) + '</span>' : '') + '</button>'
|
|
379
|
+
const notes = [], subs = []
|
|
380
|
+
const split = (it) => { subs.push(it.more); const p = subs.length
|
|
381
|
+
return '<div class="lo-act-split" role="none">' + one(it, ' lo-act-main') +
|
|
382
|
+
'<button type="button" role="menuitem" tabindex="-1" class="lo-act-next" data-act-page="' + p + '" aria-haspopup="menu" aria-label="' + esc(it.more.title) + '" title="' + esc(it.more.title) + '">' + ACT_NEXT + '</button></div>' }
|
|
383
|
+
const main = items.map((it, i) => it.note ? (notes.push(id + '-n' + i), '<div class="lo-act-note" id="' + id + '-n' + i + '" role="none">' + (it.icon || '') + '<span>' + esc(it.note) + '</span></div>')
|
|
384
|
+
: it.more ? split(it) : one(it)).join('')
|
|
385
|
+
const page = (m, p) => '<div class="lo-act-page" data-page="' + p + '" role="group" aria-label="' + esc(m.title) + '" hidden>' +
|
|
386
|
+
'<button type="button" role="menuitem" tabindex="-1" class="lo-act lo-act-back" data-act-back aria-label="' + esc('Back · ' + m.title) + '">' + ACT_BACK + '<span>' + esc(m.title) + '</span></button>' +
|
|
387
|
+
(m.items || []).map((x) => one({ ...x, icon: x.on ? ACT_CHECK : '<span class="ic" aria-hidden="true"></span>' })).join('') + '</div>'
|
|
327
388
|
menu.innerHTML = (sheet && title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span><button type="button" class="lo-sheet-done" data-act-close>Done</button></div>' : '') +
|
|
328
|
-
|
|
389
|
+
(subs.length ? '<div class="lo-act-page" data-page="0" role="none">' + main + '</div>' + subs.map((m, j) => page(m, j + 1)).join('') : main)
|
|
390
|
+
if (subs.length) menu.classList.add('has-pages')
|
|
391
|
+
if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
|
|
329
392
|
let backdrop = null
|
|
330
393
|
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
|
|
331
394
|
document.body.appendChild(menu)
|
|
332
|
-
|
|
395
|
+
const page0 = menu.querySelector('.lo-act-page[data-page="0"]')
|
|
396
|
+
act = { anchor, menu, backdrop, page: page0, up: null, items: [...(page0 || menu).querySelectorAll('[role="menuitem"]')] }
|
|
333
397
|
anchor.setAttribute('aria-haspopup', 'menu')
|
|
334
398
|
anchor.setAttribute('aria-controls', id)
|
|
335
399
|
anchor.setAttribute('aria-expanded', 'true')
|
|
400
|
+
const backFocus = () => act && act.menu.querySelector('[data-act-page="' + act.page.dataset.page + '"]')
|
|
336
401
|
menu.addEventListener('click', (e) => {
|
|
337
402
|
if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
|
|
403
|
+
const next = e.target.closest('[data-act-page]')
|
|
404
|
+
if (next) { e.preventDefault(); actPage(Number(next.dataset.actPage)); return }
|
|
405
|
+
if (e.target.closest('[data-act-back]')) { e.preventDefault(); actPage(0, { focus: backFocus() }); return }
|
|
338
406
|
// the item's own click goes on to the board's handler (the event's path is
|
|
339
407
|
// already fixed); the menu just gets out of the way
|
|
340
408
|
if (e.target.closest('[role="menuitem"]')) closeActionMenu()
|
|
341
409
|
})
|
|
342
|
-
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
|
|
410
|
+
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it && act && act.page && act.page.contains(it)) it.focus({ preventScroll: true }) })
|
|
343
411
|
menu.addEventListener('keydown', (e) => {
|
|
344
412
|
const k = act ? act.items.indexOf(document.activeElement) : -1
|
|
413
|
+
const onSub = !!(act && act.page && Number(act.page.dataset.page) > 0)
|
|
345
414
|
switch (e.key) {
|
|
346
415
|
case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
|
|
347
416
|
case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
|
|
348
417
|
case 'Home': e.preventDefault(); actFocus(0); return
|
|
349
418
|
case 'End': e.preventDefault(); actFocus(-1); return
|
|
350
|
-
case '
|
|
419
|
+
case 'ArrowRight': {
|
|
420
|
+
const row = document.activeElement && document.activeElement.closest('.lo-act-split')
|
|
421
|
+
if (row) { e.preventDefault(); actPage(Number(row.querySelector('[data-act-page]').dataset.actPage)) }
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
case 'ArrowLeft': if (onSub) { e.preventDefault(); actPage(0, { focus: backFocus() }) } return
|
|
425
|
+
case 'Escape': e.preventDefault(); e.stopPropagation(); if (onSub) actPage(0, { focus: backFocus() }); else closeActionMenu({ focus: true }); return
|
|
351
426
|
case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
|
|
352
427
|
default:
|
|
353
428
|
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
|
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() },
|