@leftoff/cli 0.9.17-beta.7 → 0.9.17-beta.8
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 +4 -0
- package/bin/leftoff.js +408 -408
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +30 -0
- package/ui/app.js +21 -2
- package/ui/hosted-by.js +50 -0
- package/ui/relay-client.js +96 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.8",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.17-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.17-beta.8' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.8",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -5102,3 +5102,33 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5102
5102
|
.ghost .ghost-k { flex: none; }
|
|
5103
5103
|
.ghost .ghost-all { flex: none; margin-left: 4px; padding: 0 5px; border-radius: 5px; pointer-events: auto; cursor: help; line-height: 16px; font-size: 12px; border: 1px solid var(--line-strong); align-self: center; }
|
|
5104
5104
|
.ghost .ghost-k { align-self: center; }
|
|
5105
|
+
|
|
5106
|
+
/* 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board; state by shape + word */
|
|
5107
|
+
.hostedby { min-width: 0; }
|
|
5108
|
+
.hosts { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
5109
|
+
.host-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 44px; box-sizing: border-box; }
|
|
5110
|
+
.host-row + .host-row { border-top: 1px solid var(--line); }
|
|
5111
|
+
.host-row.is-host { background: var(--bg2); }
|
|
5112
|
+
.host-shape { width: 16px; text-align: center; flex: none; font-size: 14px; line-height: 1; color: var(--text2); }
|
|
5113
|
+
.host-row.on .host-shape { color: var(--text); }
|
|
5114
|
+
.host-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; }
|
|
5115
|
+
.host-sub { flex-basis: 100%; font-size: 12.5px; }
|
|
5116
|
+
.host-chip { font-weight: 600; }
|
|
5117
|
+
.pref-chip { border-style: dashed; }
|
|
5118
|
+
.host-make { flex: none; min-height: 44px; }
|
|
5119
|
+
.host-pref { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
|
|
5120
|
+
.hostedby > .hint { margin-top: 8px; }
|
|
5121
|
+
.host-pref-label { font-size: 12.5px; color: var(--text2); margin-right: 4px; }
|
|
5122
|
+
.hp-opt { border: 1px solid var(--line); background: none; color: var(--text2); border-radius: 8px; padding: 0 12px; min-height: 44px; font: inherit; font-size: 13px; cursor: pointer; }
|
|
5123
|
+
.hp-opt.on { border-color: var(--accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
|
|
5124
|
+
.hp-opt.on::before { content: '✓ '; }
|
|
5125
|
+
.hp-opt:disabled { opacity: .55; cursor: default; }
|
|
5126
|
+
.hp-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5127
|
+
@media (pointer: fine) { .host-row { min-height: 36px; padding: 6px 12px; } .host-make, .hp-opt { min-height: 30px; } }
|
|
5128
|
+
.relay-hosts { list-style: none; margin: 14px 0 0; padding: 0; text-align: left; }
|
|
5129
|
+
.relay-host { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 0; min-height: 44px; box-sizing: border-box; }
|
|
5130
|
+
.relay-host + .relay-host { border-top: 1px solid var(--line); }
|
|
5131
|
+
.relay-host-shape { width: 16px; text-align: center; flex: none; }
|
|
5132
|
+
.relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
|
|
5133
|
+
.relay-use { min-height: 44px; margin-left: auto; }
|
|
5134
|
+
@media (pointer: fine) { .relay-host { min-height: 36px; } .relay-use { min-height: 30px; } }
|
package/ui/app.js
CHANGED
|
@@ -34,6 +34,7 @@ import { boardEmptyHtml, boardIsBrandNew, timelineEmptyHtml, digestFirstHtml, cp
|
|
|
34
34
|
import { copyIconClick } from './copy-icon.js' // 0.9.15 Copy branch name // 0.9.9 the session page's info cards
|
|
35
35
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
36
36
|
import { paintKeyed, forgetPaint, UNITS as PAINT_UNITS } from './keyed-paint.js'
|
|
37
|
+
import { hostedByHtml } from './hosted-by.js' // 0.9.18 Settings → Use from anywhere → Hosted by
|
|
37
38
|
import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
|
|
38
39
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
39
40
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -2381,6 +2382,10 @@ async function viewSession(id) {
|
|
|
2381
2382
|
// 0.9.15 (ui/back-line.js): how long you'd been away when this visit first opened
|
|
2382
2383
|
// each session, read before caughtUp folds its card; a new visit starts over.
|
|
2383
2384
|
const openAway = new Map()
|
|
2385
|
+
// 2026-10-29 (Joel: a 30-minute-old session said "Away 12h"): a session that started
|
|
2386
|
+
// while you were away reads at least "day" depth (ui/away.js), but its label says
|
|
2387
|
+
// how long it has really gone unseen: never more than the session's own age.
|
|
2388
|
+
function awayShownMs(s, ms) { const st = Date.parse((s && s.startedAt) || ''); return Number.isFinite(st) && ms != null ? Math.max(60000, Math.min(ms, Date.now() - st)) : ms }
|
|
2384
2389
|
function noteOpenAway(s) { if (s && !openAway.has(s.id)) openAway.set(s.id, presence.forSession(s).ms) }
|
|
2385
2390
|
function backDismissed(id) { try { return sessionStorage.getItem(BACK_PREFIX + id) } catch { return null } }
|
|
2386
2391
|
function backHtml(s) { noteOpenAway(s); return backLineHtml(s, backLine(s, { awayMs: openAway.get(s.id), dismissed: backDismissed(s.id) }), { esc, icon, ago }) }
|
|
@@ -2555,7 +2560,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2555
2560
|
const cu = catchupButtonHtml(s, { esc, icon, busy: !!scuOf(s.id).loading })
|
|
2556
2561
|
// 0.9.17: after time away the label is "Away 7h · Catch me up" on one line where the panel has room,
|
|
2557
2562
|
// else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
|
|
2558
|
-
const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayMs))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
|
|
2563
|
+
const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
|
|
2559
2564
|
const wtOn = wtUi.sid === s.id
|
|
2560
2565
|
const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
|
|
2561
2566
|
// 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
|
|
@@ -2597,7 +2602,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2597
2602
|
side += answeredList(s)
|
|
2598
2603
|
const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
|
|
2599
2604
|
const label = fsPeekWords(s)
|
|
2600
|
-
return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
|
|
2605
|
+
return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
|
|
2601
2606
|
}
|
|
2602
2607
|
// The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
|
|
2603
2608
|
// "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
|
|
@@ -4986,6 +4991,8 @@ function anywhereSection(r, adv = []) {
|
|
|
4986
4991
|
if (r.enabled) {
|
|
4987
4992
|
const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
|
|
4988
4993
|
h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
|
|
4994
|
+
// 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board, and swapping it
|
|
4995
|
+
h += hostedByHtml(r.hosting, { esc, canChange: local || !!v.relay || !!v.device, viewing: window.leftoffRelay ? window.leftoffRelay.mac : null })
|
|
4989
4996
|
if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
|
|
4990
4997
|
// Pairing from a paired phone follows Trust my paired devices (0.6.6).
|
|
4991
4998
|
const mayPair = v.mayPair != null ? !!v.mayPair : local
|
|
@@ -8265,6 +8272,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
8265
8272
|
refreshHealth().catch(() => {})
|
|
8266
8273
|
return render()
|
|
8267
8274
|
}
|
|
8275
|
+
// 0.9.18 Hosted by: Make host (now) and Preferred (Automatic or a machine)
|
|
8276
|
+
if (action === 'relay-host' || action === 'relay-host-pref') {
|
|
8277
|
+
const machine = btn.dataset.machine || null
|
|
8278
|
+
const prefer = action === 'relay-host-pref'
|
|
8279
|
+
btn.disabled = true
|
|
8280
|
+
let hz
|
|
8281
|
+
try { hz = await api('POST', '/v1/relay/host', { machine, prefer }) } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
|
|
8282
|
+
const now = hz && hz.host && hz.host.machine
|
|
8283
|
+
toast(prefer ? (machine ? machine + ' is your preferred host' : 'Hosting is automatic') + (now ? ' · ' + now + ' hosts now' : '') : machine + ' hosts the board now')
|
|
8284
|
+
if (window.leftoffRelay && window.leftoffRelay.useHost && now) window.leftoffRelay.useHost(now)
|
|
8285
|
+
return render()
|
|
8286
|
+
}
|
|
8268
8287
|
if (action === 'relay-user') {
|
|
8269
8288
|
const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
|
|
8270
8289
|
await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
|
package/ui/hosted-by.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 0.9.18 Hosted by (Settings → Use from anywhere): which of your machines serves
|
|
2
|
+
// <user>.leftoff.dev. The relay decides (relay/src/hosting.js): your preferred host
|
|
3
|
+
// whenever it's online, else the one already hosting, else an online one (Macs
|
|
4
|
+
// first). Here: each machine with its state by shape and word, the host marked,
|
|
5
|
+
// "Make host" per online machine, and Preferred (Automatic or a machine).
|
|
6
|
+
// Pure (no DOM), so the check and the unit tests render it directly.
|
|
7
|
+
const STATE = {
|
|
8
|
+
online: ['●', 'Online'],
|
|
9
|
+
not_answering: ['◐', 'Not answering'],
|
|
10
|
+
reconnecting: ['◌', 'Reconnecting'],
|
|
11
|
+
asleep: ['○', 'Offline'],
|
|
12
|
+
}
|
|
13
|
+
export function hostState(m) { return STATE[m && m.state] || (m && m.online ? STATE.online : STATE.asleep) }
|
|
14
|
+
const kind = (p) => (p === 'darwin' ? 'Mac' : p === 'linux' ? 'Linux' : p ? p : '')
|
|
15
|
+
|
|
16
|
+
export function hostedByHtml(h, { esc = (s) => String(s == null ? '' : s), canChange = true, viewing = null } = {}) {
|
|
17
|
+
if (!h || !Array.isArray(h.macs) || !h.macs.length) return ''
|
|
18
|
+
let out = '<label>Hosted by</label><div class="hostedby" id="hostedBy">'
|
|
19
|
+
if (!h.supported) {
|
|
20
|
+
out += '<div class="hint nomt">Your relay serves the board from the machine that connected last. Choosing a host needs a relay update.</div></div>'
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
const host = h.host || {}
|
|
24
|
+
const pref = host.preferred || null
|
|
25
|
+
out += '<ul class="hosts" role="list">'
|
|
26
|
+
for (const m of h.macs) {
|
|
27
|
+
const [shape, word] = hostState(m)
|
|
28
|
+
const isHost = m.machine === host.machine
|
|
29
|
+
const k = kind(m.platform)
|
|
30
|
+
out += '<li class="host-row' + (isHost ? ' is-host' : '') + (m.online ? ' on' : ' off') + '" data-machine="' + esc(m.machine) + '">' +
|
|
31
|
+
'<span class="host-shape" aria-hidden="true">' + shape + '</span>' +
|
|
32
|
+
'<span class="host-main"><b>' + esc(m.machine) + '</b>' + (isHost ? ' <span class="chip host-chip">Host</span>' : '') + (m.machine === pref ? ' <span class="chip pref-chip">Preferred</span>' : '') +
|
|
33
|
+
'<span class="faint host-sub">' + word + (k ? ' · ' + esc(k) : '') + (viewing && viewing === m.machine && !isHost ? ' · this browser' : '') + (m.serves === 0 ? ' · can’t open your paired browsers’ keys' : '') + '</span></span>'
|
|
34
|
+
if (canChange && !isHost) {
|
|
35
|
+
out += '<button type="button" class="btn sm host-make" data-action="relay-host" data-machine="' + esc(m.machine) + '"' + (m.online ? '' : ' disabled data-tip="' + esc(m.machine) + ' is offline: it can host once it’s online"') + '>Make host</button>'
|
|
36
|
+
}
|
|
37
|
+
out += '</li>'
|
|
38
|
+
}
|
|
39
|
+
out += '</ul>'
|
|
40
|
+
if (host.pending) out += '<div class="hint nomt host-pending">' + esc(host.pending.machine) + ' is back and takes over in a few seconds.</div>'
|
|
41
|
+
if (!host.machine) out += '<div class="hint nomt warnish">None of your machines is online. The first one to connect hosts.</div>'
|
|
42
|
+
// Preferred: a segmented choice, Automatic first
|
|
43
|
+
out += '<div class="host-pref" role="radiogroup" aria-label="Preferred host"><span class="host-pref-label">Preferred</span>'
|
|
44
|
+
const opt = (val, label, tip) => '<button type="button" role="radio" class="hp-opt' + ((pref || '') === val ? ' on' : '') + '" aria-checked="' + ((pref || '') === val ? 'true' : 'false') + '" data-action="relay-host-pref" data-machine="' + esc(val) + '" data-tip="' + esc(tip) + '"' + (canChange ? '' : ' disabled') + '>' + esc(label) + '</button>'
|
|
45
|
+
out += opt('', 'Automatic', 'Any online machine; a Mac before Linux, and the host stays put while it’s online')
|
|
46
|
+
for (const m of h.macs) out += opt(m.machine, m.machine, m.machine + ' hosts whenever it’s online; when it’s closed another machine takes over, and it takes back when it returns')
|
|
47
|
+
out += '</div>'
|
|
48
|
+
out += '<div class="hint">' + (pref ? esc(pref) + ' hosts whenever it’s online. ' : '') + 'When the host closes or loses its network, another online machine takes over within about half a minute. Phones and browsers stay paired.</div>'
|
|
49
|
+
return out + '</div>'
|
|
50
|
+
}
|
package/ui/relay-client.js
CHANGED
|
@@ -58,7 +58,30 @@ async function loadDirect() {
|
|
|
58
58
|
const INIT_WAIT_MS = 1200 // how long the first paint waits for this Mac's own answer
|
|
59
59
|
const RELOAD_KEY = 'leftoff.direct.reloaded'
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// 0.9.18: a pick from the reconnecting screen ("Use laptop instead") lasts this tab only
|
|
62
|
+
const TEMP_KEY = 'leftoff.relay.mac.temp', REJECT_KEY = 'leftoff.relay.rejected', REJECT_MS = 10 * 60 * 1000
|
|
63
|
+
function pickedMac() { try { return localStorage.getItem(MAC_KEY) || sessionStorage.getItem(TEMP_KEY) } catch { return null } }
|
|
64
|
+
function tempPick(mac) { try { sessionStorage.setItem(TEMP_KEY, mac) } catch {} }
|
|
65
|
+
// Machines that just refused this browser's key (unknown_device / not_ready): skipped for a while,
|
|
66
|
+
// so the board doesn't keep going back to a host that can't open it (today's "Reconnecting" loop).
|
|
67
|
+
function rejected(now = Date.now()) {
|
|
68
|
+
try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); return Object.keys(o).filter((m) => now - o[m] < REJECT_MS) } catch { return [] }
|
|
69
|
+
}
|
|
70
|
+
function reject(mac, now = Date.now()) {
|
|
71
|
+
if (!mac) return
|
|
72
|
+
try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); o[mac] = now; sessionStorage.setItem(REJECT_KEY, JSON.stringify(o)) } catch {}
|
|
73
|
+
}
|
|
74
|
+
function unreject(mac) { try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); if (o[mac]) { delete o[mac]; sessionStorage.setItem(REJECT_KEY, JSON.stringify(o)) } } catch {} }
|
|
75
|
+
// Which machine this browser uses: your own pick while it's online, else the relay's host
|
|
76
|
+
// (relay/src/hosting.js), else the last one that answered, else any online one that hasn't
|
|
77
|
+
// just refused this browser. Pure, unit-tested.
|
|
78
|
+
export function pickMac({ picked = null, online = [], host = null, rejected = [], good = null } = {}) {
|
|
79
|
+
const ok = (m) => m && online.includes(m) && !rejected.includes(m)
|
|
80
|
+
if (picked && online.includes(picked)) return picked
|
|
81
|
+
if (ok(host)) return host
|
|
82
|
+
if (ok(good)) return good
|
|
83
|
+
return online.find((m) => !rejected.includes(m)) || host || null
|
|
84
|
+
}
|
|
62
85
|
function remember(mac) {
|
|
63
86
|
try { if (mac) localStorage.setItem(MAC_KEY, mac); else localStorage.removeItem(MAC_KEY) } catch {}
|
|
64
87
|
}
|
|
@@ -73,8 +96,7 @@ async function refreshStatus() {
|
|
|
73
96
|
const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
|
|
74
97
|
// on this Mac's direct path the board stays on this Mac, whatever the relay sees
|
|
75
98
|
if (!onDirect()) {
|
|
76
|
-
|
|
77
|
-
relay.mac = want && online.includes(want) ? want : relay.status.default
|
|
99
|
+
relay.mac = pickMac({ picked: pickedMac(), online, host: relay.status.default, rejected: rejected(), good: lastGood() })
|
|
78
100
|
cookieMac(relay.mac)
|
|
79
101
|
}
|
|
80
102
|
return relay.status
|
|
@@ -252,8 +274,27 @@ async function viaRelay(body, init, retried) {
|
|
|
252
274
|
const gone = relay.mac
|
|
253
275
|
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
254
276
|
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' })
|
|
255
|
-
if (res.status === 200) {
|
|
277
|
+
if (res.status === 200) {
|
|
278
|
+
good(gone); unreject(gone)
|
|
279
|
+
const out = toResponse(await openResponse(relay.key, env, await res.json()))
|
|
280
|
+
// 0.9.18: the host changed (made elsewhere, or a preferred machine came back): follow it
|
|
281
|
+
const host = res.headers.get('x-leftoff-host')
|
|
282
|
+
if (host && host !== relay.mac && relay.mac === gone && !pickedMac() && !onDirect() && /^[\w.-]{1,64}$/.test(host)) followHost(host)
|
|
283
|
+
return out
|
|
284
|
+
}
|
|
256
285
|
const j = await res.clone().json().catch(() => ({}))
|
|
286
|
+
if ((j.code === 'unknown_device' || j.code === 'not_ready') && !retried) {
|
|
287
|
+
// 0.9.18: this machine can't open this browser's key (a sandbox outside your synced
|
|
288
|
+
// folder, a Mac still starting): another online machine that can takes over
|
|
289
|
+
reject(gone)
|
|
290
|
+
await refreshStatus().catch(() => {})
|
|
291
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
292
|
+
}
|
|
293
|
+
if (res.status === 504 && j.code === 'timeout' && !retried) {
|
|
294
|
+
// the host stopped answering (a closed lid before the relay notices): ask who hosts now
|
|
295
|
+
await refreshStatus().catch(() => {})
|
|
296
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
297
|
+
}
|
|
257
298
|
if (authVerdict(res.status, j.code) !== 'ok') {
|
|
258
299
|
// 0.9.17: the Mac that took over while yours restarted may not know this browser:
|
|
259
300
|
// stay with the one that does, and look again (never forget the key on this)
|
|
@@ -277,6 +318,16 @@ async function viaRelay(body, init, retried) {
|
|
|
277
318
|
return res
|
|
278
319
|
}
|
|
279
320
|
|
|
321
|
+
// The relay says another machine hosts now. Once per few seconds at most; the board
|
|
322
|
+
// hears leftoff-relay { switched } as it does after a failover.
|
|
323
|
+
let followedAt = 0
|
|
324
|
+
function followHost(host) {
|
|
325
|
+
if (Date.now() - followedAt < 5000) return
|
|
326
|
+
followedAt = Date.now()
|
|
327
|
+
relay.mac = host; cookieMac(host)
|
|
328
|
+
try { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: host, switched: true, followed: true } })) } catch {}
|
|
329
|
+
}
|
|
330
|
+
|
|
280
331
|
function patchedFetch(input, init) {
|
|
281
332
|
try {
|
|
282
333
|
const url = new URL(typeof input === 'string' ? input : input.url, location.href)
|
|
@@ -433,7 +484,40 @@ function trouble(code) {
|
|
|
433
484
|
}
|
|
434
485
|
function reconnecting() {
|
|
435
486
|
if (document.querySelector('.relay-screen')) return
|
|
436
|
-
screen('Reconnecting to your board…',
|
|
487
|
+
screen('Reconnecting to your board…', reconnectHtml({ status: relay.status, mac: relay.mac, rejected: rejected() }))
|
|
488
|
+
wirePicker()
|
|
489
|
+
}
|
|
490
|
+
// 0.9.18: the reconnecting screen says where each machine is (shape + word) and, when the
|
|
491
|
+
// host is out but another machine is online, that it's switching; with several online, a
|
|
492
|
+
// "Use <name> instead" button each (this tab only). Pure, unit-tested.
|
|
493
|
+
export function reconnectHtml({ status = null, mac = null, rejected = [] } = {}) {
|
|
494
|
+
const macs = (status && status.macs) || []
|
|
495
|
+
const online = macs.filter((m) => m.online).map((m) => m.machine)
|
|
496
|
+
const name = mac || 'Your Mac'
|
|
497
|
+
const hostOut = mac && (!online.includes(mac) || rejected.includes(mac))
|
|
498
|
+
const others = online.filter((m) => m !== mac && !rejected.includes(m))
|
|
499
|
+
const target = pickMac({ online: others, host: status && status.default, rejected })
|
|
500
|
+
let h = '<p class="relay-wait" role="status"><span class="relay-dot" aria-hidden="true"></span>'
|
|
501
|
+
if (hostOut && target) h += esc(name) + (online.includes(mac) ? ' can’t open this browser’s key.' : ' is offline.') + ' ' + esc(target) + ' is online: switching to it…</p>'
|
|
502
|
+
else h += esc(name) + ' may be restarting. This takes a few seconds.</p>'
|
|
503
|
+
if (macs.length > 1) {
|
|
504
|
+
h += '<ul class="relay-hosts" aria-label="Your machines">'
|
|
505
|
+
for (const m of macs) {
|
|
506
|
+
const st = m.online ? (rejected.includes(m.machine) ? ['◐', 'can’t open this browser’s key'] : ['●', 'online']) : m.state === 'reconnecting' ? ['◌', 'reconnecting'] : ['○', 'offline']
|
|
507
|
+
h += '<li class="relay-host ' + (m.online ? 'on' : 'off') + '"><span class="relay-host-shape" aria-hidden="true">' + st[0] + '</span><b>' + esc(m.machine) + '</b><span class="relay-host-word">' + st[1] + (m.machine === mac ? ' · was hosting' : '') + '</span>'
|
|
508
|
+
if (others.length > 1 && others.includes(m.machine)) h += '<button type="button" class="btn relay-use" data-mac="' + esc(m.machine) + '">Use ' + esc(m.machine) + ' instead</button>'
|
|
509
|
+
h += '</li>'
|
|
510
|
+
}
|
|
511
|
+
h += '</ul>'
|
|
512
|
+
}
|
|
513
|
+
return h
|
|
514
|
+
}
|
|
515
|
+
export function wirePicker() {
|
|
516
|
+
document.querySelectorAll('.relay-use').forEach((b) => b.addEventListener('click', () => {
|
|
517
|
+
const m = b.getAttribute('data-mac')
|
|
518
|
+
if (!m) return
|
|
519
|
+
stopTrouble(); tempPick(m); unreject(m); good(m); cookieMac(m); location.reload()
|
|
520
|
+
}))
|
|
437
521
|
}
|
|
438
522
|
// One sealed ping per candidate Mac (the one that last answered first): 'ok' (with
|
|
439
523
|
// the Mac), 'revoked', or 'retry'.
|
|
@@ -443,7 +527,10 @@ async function pingMacs() {
|
|
|
443
527
|
if (res0 && res0.status === 401) { const j = await res0.json().catch(() => ({})); return { verdict: authVerdict(401, j.code), code: j.code || 'not_paired' } }
|
|
444
528
|
if (res0 && res0.ok) { st = await res0.json().catch(() => null); if (st) relay.status = st }
|
|
445
529
|
const online = st ? st.macs.filter((m) => m.online).map((m) => m.machine) : []
|
|
446
|
-
|
|
530
|
+
// 0.9.18: the relay's host first, machines that just refused this browser last
|
|
531
|
+
const rej = rejected()
|
|
532
|
+
const order = [...new Set([st && st.default, lastGood(), relay.mac, ...online].filter((m) => m && online.includes(m)))].sort((a, b) => rej.includes(a) - rej.includes(b))
|
|
533
|
+
if (document.querySelector('.relay-screen') && !lostShown && !notPairedShown) { const w = document.querySelector('.relay-screen .relay-card'); if (w) { w.innerHTML = '<div class="relay-logo">Leftoff</div><h1>Reconnecting to your board…</h1>' + reconnectHtml({ status: relay.status, mac: relay.mac, rejected: rej }); wirePicker() } }
|
|
447
534
|
let last = { verdict: 'retry', code: online.length ? 'unknown_device' : 'mac_offline' }
|
|
448
535
|
for (const machine of order) {
|
|
449
536
|
try {
|
|
@@ -503,6 +590,9 @@ export const leftoffRelay = {
|
|
|
503
590
|
online(mac) { return !!mac && onlineMacs().includes(String(mac)) },
|
|
504
591
|
// Switch Macs: the board reloads from the other one.
|
|
505
592
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
593
|
+
// 0.9.18 Hosted by: the user's host changed (Settings): drop this browser's own pick and follow it.
|
|
594
|
+
useHost(mac) { remember(null); try { sessionStorage.removeItem(TEMP_KEY) } catch {} if (mac && mac !== relay.mac) { unreject(mac); cookieMac(mac); location.reload() } },
|
|
595
|
+
get pinned() { return pickedMac() },
|
|
506
596
|
// This browser revoked itself (0.6.6).
|
|
507
597
|
unpaired() { notPaired('self') },
|
|
508
598
|
}
|