@leftoff/cli 0.9.7 → 0.9.8-beta.10
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 +39 -0
- package/bin/leftoff.js +414 -379
- package/docs/SECURITY.md +10 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +425 -1
- package/ui/app.js +952 -40
- package/ui/index.html +18 -6
- package/ui/live-status.js +97 -0
- package/ui/live-view.js +374 -0
- package/ui/live-wake.js +96 -0
- package/ui/nav.js +88 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +33 -2
- package/ui/pull.js +31 -0
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/restore-gate.js +26 -2
- package/ui/sw.js +66 -6
- package/ui/tabs.js +216 -0
- package/ui/tips.js +117 -0
package/ui/live-wake.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// 0.9.8: the board stays live after a phone wakes up.
|
|
2
|
+
//
|
|
3
|
+
// iOS suspends a Home Screen app (and its live-update long-poll or event
|
|
4
|
+
// stream) when you switch away or lock the screen. A request that was in
|
|
5
|
+
// flight then may never settle, so the stream can go quiet forever while the
|
|
6
|
+
// page still shows a confident "Working · 5m" timer. The pieces here are
|
|
7
|
+
// pure (unit tested in test/unit/livewake-098.test.mjs); ui/app.js wires them:
|
|
8
|
+
// - the daemon sends a ping on the stream every HEARTBEAT_MS (a long-poll
|
|
9
|
+
// answers at least that often), and a stream with nothing for STREAM_DEAD_MS
|
|
10
|
+
// is closed and opened again (with backoff), then the view refetches
|
|
11
|
+
// - coming back (visible, pageshow, focus, online) refetches at once
|
|
12
|
+
// - while a Live message on screen is still going, its thread is polled every
|
|
13
|
+
// POLL_MS, so a dead stream can't leave a Working bubble behind
|
|
14
|
+
// - a pending bubble whose data is older than STALE_MS says "Reconnecting…"
|
|
15
|
+
|
|
16
|
+
export const HEARTBEAT_MS = 15000 // the daemon's stream ping (src/server.js)
|
|
17
|
+
export const STREAM_DEAD_MS = 2 * HEARTBEAT_MS + 5000
|
|
18
|
+
export const POLL_MS = 5000
|
|
19
|
+
export const STALE_MS = 15000
|
|
20
|
+
export const AWAY_MS = 2000 // focus or visible after less than this in the background isn't a wake-up (unless the stream died)
|
|
21
|
+
|
|
22
|
+
const PENDING = new Set(['sent', 'received', 'working'])
|
|
23
|
+
|
|
24
|
+
// A board message whose turn is still going (its bubble spins or waits).
|
|
25
|
+
export function isPending(m) {
|
|
26
|
+
const st = m && m.status
|
|
27
|
+
return !!(st && PENDING.has(st.phase))
|
|
28
|
+
}
|
|
29
|
+
export function anyPending(msgs) { return (msgs || []).some(isPending) }
|
|
30
|
+
|
|
31
|
+
// Should the Live thread be polled right now?
|
|
32
|
+
export function shouldPoll({ visible = true, onLive = false, pending = false } = {}) {
|
|
33
|
+
return !!(visible && onLive && pending)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Has the stream been silent longer than a ping allows?
|
|
37
|
+
export function streamDead(lastAt, now = Date.now(), limit = STREAM_DEAD_MS) {
|
|
38
|
+
if (!lastAt) return false
|
|
39
|
+
return now - lastAt > limit
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Is what the page shows too old to trust a running timer?
|
|
43
|
+
export function isStale(okAt, now = Date.now(), limit = STALE_MS) {
|
|
44
|
+
if (!okAt) return false
|
|
45
|
+
return now - okAt > limit
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Reconnect delays: 1 s, 2 s, 4 s … at most 30 s.
|
|
49
|
+
export function backoffMs(attempt) {
|
|
50
|
+
const n = Math.max(0, Math.floor(Number(attempt) || 0))
|
|
51
|
+
return Math.min(30000, 1000 * 2 ** Math.min(n, 5))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What a refetch compares: has anything in the thread changed?
|
|
55
|
+
export function liveSignature(msgs) {
|
|
56
|
+
return (msgs || []).map((m) => {
|
|
57
|
+
const st = m.status || {}
|
|
58
|
+
return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0].join(':')
|
|
59
|
+
}).join('|')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Which reasons to refetch: coming back to the page or the network.
|
|
63
|
+
export function wakeReason(type, { visibilityState = 'visible', persisted = false } = {}) {
|
|
64
|
+
if (type === 'visibilitychange') return visibilityState === 'visible' ? 'visible' : null
|
|
65
|
+
if (type === 'pageshow') return persisted ? 'pageshow-bfcache' : null // a plain pageshow is the first load, already fetching
|
|
66
|
+
if (type === 'focus' || type === 'online') return type
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Coalesce wake-ups (visible + focus + pageshow all fire together on iOS).
|
|
71
|
+
export function createWake({ now = () => Date.now(), minGapMs = 1500 } = {}) {
|
|
72
|
+
let last = 0
|
|
73
|
+
return {
|
|
74
|
+
due() { const t = now(); if (t - last < minGapMs) return false; last = t; return true },
|
|
75
|
+
reset() { last = 0 },
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The stream watchdog: call seen() on every event or ping, check() on a timer.
|
|
80
|
+
// check() answers 'ok', or 'dead' once (then waits for the reconnect's delay).
|
|
81
|
+
export function createWatchdog({ now = () => Date.now(), limit = STREAM_DEAD_MS } = {}) {
|
|
82
|
+
let lastAt = now(), attempt = 0, nextAt = 0
|
|
83
|
+
return {
|
|
84
|
+
seen() { lastAt = now(); attempt = 0; nextAt = 0 },
|
|
85
|
+
opened() { lastAt = now() },
|
|
86
|
+
check() {
|
|
87
|
+
const t = now()
|
|
88
|
+
if (!streamDead(lastAt, t, limit)) return 'ok'
|
|
89
|
+
if (t < nextAt) return 'wait'
|
|
90
|
+
nextAt = t + backoffMs(attempt++)
|
|
91
|
+
return 'dead'
|
|
92
|
+
},
|
|
93
|
+
get lastAt() { return lastAt },
|
|
94
|
+
get attempt() { return attempt },
|
|
95
|
+
}
|
|
96
|
+
}
|
package/ui/nav.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The sidebar rail, the phone's bottom bar and Quick look (0.9.8). Pure functions
|
|
2
|
+
// over the daemon's sessions (shared with the unit tests); app.js wires them.
|
|
3
|
+
//
|
|
4
|
+
// Quick look: hover (or long-press on a phone) the Needs you item for the count
|
|
5
|
+
// and the five longest waits, with Allow once / Always / Deny on permission prompts
|
|
6
|
+
// (Always only when the Needs you page offers "don't ask again"). Those
|
|
7
|
+
// buttons are the Needs you page's own (data-action="ask-answer"): same request,
|
|
8
|
+
// same checks, and nothing is ever answered without a click.
|
|
9
|
+
|
|
10
|
+
import { esc, needsItems, needsLine, waitLabel, alwaysText, alwaysButton } from './needs.js'
|
|
11
|
+
|
|
12
|
+
// The nav item a route lights up. A session page belongs to the board, a
|
|
13
|
+
// catch-up to the digest.
|
|
14
|
+
export function navKey(name) {
|
|
15
|
+
if (name === 's' || name === 'board' || !name) return 'board'
|
|
16
|
+
if (name === 'catchup') return 'digest'
|
|
17
|
+
return name
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// On a phone the bar holds four sections and More; these live under More.
|
|
21
|
+
export const MORE_KEYS = ['timeline', 'digest', 'whats-new', 'settings']
|
|
22
|
+
export const moreActive = (key) => MORE_KEYS.includes(key)
|
|
23
|
+
|
|
24
|
+
// The red count on Needs you: nothing at zero, 99+ past 99.
|
|
25
|
+
export function badgeText(n) {
|
|
26
|
+
const v = Math.max(0, Math.floor(Number(n) || 0))
|
|
27
|
+
if (!v) return ''
|
|
28
|
+
return v > 99 ? '99+' : String(v)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function qlTitle(total) {
|
|
32
|
+
if (!total) return 'Nothing needs you'
|
|
33
|
+
return total + (total === 1 ? ' thing needs' : ' things need') + ' you'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "6m" from the need's wait, or from when it was asked when there's no wait clock.
|
|
37
|
+
export function qlAge(n, now = Date.now()) {
|
|
38
|
+
if (!n) return ''
|
|
39
|
+
if (n.waitSec != null) return waitLabel(n.waitSec).replace('waiting ', '')
|
|
40
|
+
const t = Date.parse(n.since || n.at || '')
|
|
41
|
+
if (!Number.isFinite(t)) return ''
|
|
42
|
+
return waitLabel(Math.max(1, (now - t) / 1000)).replace('waiting ', '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The popover's items: the same order as the Needs you page (blocked first,
|
|
46
|
+
// longest wait first), the first `limit` of them.
|
|
47
|
+
export function quickLookItems(sessions = [], limit = 5) {
|
|
48
|
+
const all = needsItems(sessions)
|
|
49
|
+
return { total: all.length, items: all.slice(0, limit), more: Math.max(0, all.length - limit) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A permission prompt with an id is the one kind answered inline.
|
|
53
|
+
export const qlAnswerable = (n) => !!(n && n.kind === 'permission' && n.ask && n.ask.id)
|
|
54
|
+
|
|
55
|
+
// opts: { mayAnswer, pending: { [askId]: label }, now }
|
|
56
|
+
export function qlRow(n, opts = {}) {
|
|
57
|
+
const s = n.session || {}
|
|
58
|
+
const askId = n.ask && n.ask.id ? n.ask.id : ''
|
|
59
|
+
const key = askId || 'reply:' + s.id
|
|
60
|
+
const mac = n.peer || s.machine || ''
|
|
61
|
+
const meta = [s.repoLabel, mac, qlAge(n, opts.now)].filter(Boolean).join(' · ')
|
|
62
|
+
const line = needsLine(n)
|
|
63
|
+
const always = qlAnswerable(n) ? alwaysText(n.ask) : ''
|
|
64
|
+
let h = '<li class="ql-item k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + (always ? ' has-always' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '">' +
|
|
65
|
+
'<a class="ql-main" href="#/s/' + encodeURIComponent(s.id || '') + '">' +
|
|
66
|
+
'<span class="ql-t">' + esc(s.title || 'Untitled session') + '</span>' +
|
|
67
|
+
(line ? '<span class="ql-what" title="' + esc(line) + '">' + esc(line) + '</span>' : '') +
|
|
68
|
+
(meta ? '<span class="ql-meta mono">' + esc(meta) + '</span>' : '') + '</a>'
|
|
69
|
+
if (qlAnswerable(n)) {
|
|
70
|
+
const busy = opts.pending && opts.pending[key]
|
|
71
|
+
const dis = opts.mayAnswer === false || busy ? ' disabled' : ''
|
|
72
|
+
const off = opts.mayAnswer === false ? ' title="Answering from this device is off"' : ''
|
|
73
|
+
h += '<div class="ql-actions">' +
|
|
74
|
+
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + off + ' aria-label="Allow once: ' + esc(line) + '">' + (busy ? esc(busy) : 'Allow once') + '</button>' +
|
|
75
|
+
(always ? alwaysButton(n.ask, dis, { label: 'Always', scope: 'ql', session: s, title: off }) : '') +
|
|
76
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + off + ' aria-label="Deny: ' + esc(line) + '">Deny</button></div>'
|
|
77
|
+
}
|
|
78
|
+
return h + '</li>'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function quickLookHtml(model = {}, opts = {}) {
|
|
82
|
+
const total = model.total || 0
|
|
83
|
+
const items = model.items || []
|
|
84
|
+
let h = '<div class="ql-head"><h2 id="qlTitle">' + esc(qlTitle(total)) + '</h2></div>'
|
|
85
|
+
if (!items.length) h += '<p class="ql-empty">When a session asks for permission or a question, it shows up here.</p>'
|
|
86
|
+
else h += '<ul class="ql-list">' + items.map((n) => qlRow(n, opts)).join('') + '</ul>' + (model.more ? '<p class="ql-more">+' + model.more + ' more</p>' : '')
|
|
87
|
+
return h + '<a class="ql-foot" href="#/waiting">Open Needs you <span aria-hidden="true">→</span></a>'
|
|
88
|
+
}
|
package/ui/navhide.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Hide the nav on scroll (phones, 0.9.8). Under 700px wide the top row and the
|
|
2
|
+
// bottom tab bar slide away while you scroll down a page and come back when
|
|
3
|
+
// you scroll up, reach the top or the very bottom, Safari style. Never while
|
|
4
|
+
// you're typing, a menu or sheet is open, or the page barely scrolls. Pure
|
|
5
|
+
// functions here (shared with the unit tests); app.js wires the scroll.
|
|
6
|
+
|
|
7
|
+
export const NAVHIDE_DOWN = 10 // px scrolled down in one go before the bars go
|
|
8
|
+
export const NAVHIDE_UP = 10 // px scrolled up in one go before they come back
|
|
9
|
+
export const NAVHIDE_TOP = 56 // this near the top they always show
|
|
10
|
+
export const NAVHIDE_BOTTOM = 8 // this near the very bottom they always show
|
|
11
|
+
|
|
12
|
+
// What counts as typing: a focused field the keyboard is up for.
|
|
13
|
+
export const NAVHIDE_TYPING = 'textarea, [contenteditable=""], [contenteditable="true"], input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="range"]):not([type="color"]):not([type="file"]):not([type="hidden"])'
|
|
14
|
+
|
|
15
|
+
// One scroll reading → the next state. state: { hidden, anchor } where anchor
|
|
16
|
+
// is where the current run of scrolling in one direction started. reading:
|
|
17
|
+
// { y: scrollY, max: how far the page can scroll, blocked: typing / a menu
|
|
18
|
+
// open / the bars held (a route change) }.
|
|
19
|
+
export function navHideStep(state, { y = 0, max = 0, blocked = false } = {}) {
|
|
20
|
+
const at = Number(y) || 0
|
|
21
|
+
const prev = state && typeof state === 'object' ? state : { hidden: false, anchor: at } // no state yet: resting here
|
|
22
|
+
const room = Number(max) || 0
|
|
23
|
+
const show = { hidden: false, anchor: at }
|
|
24
|
+
if (blocked) return show
|
|
25
|
+
if (room < NAVHIDE_TOP + NAVHIDE_DOWN + NAVHIDE_BOTTOM) return show // too short to bother
|
|
26
|
+
if (at <= NAVHIDE_TOP) return show
|
|
27
|
+
if (at >= room - NAVHIDE_BOTTOM) return show
|
|
28
|
+
const anchor = Number.isFinite(prev.anchor) ? prev.anchor : at
|
|
29
|
+
if (prev.hidden) {
|
|
30
|
+
if (at > anchor) return { hidden: true, anchor: at } // still going down: follow it
|
|
31
|
+
return anchor - at >= NAVHIDE_UP ? show : { hidden: true, anchor }
|
|
32
|
+
}
|
|
33
|
+
if (at < anchor) return { hidden: false, anchor: at } // going up while shown: follow it
|
|
34
|
+
return at - anchor >= NAVHIDE_DOWN ? { hidden: true, anchor: at } : { hidden: false, anchor }
|
|
35
|
+
}
|
package/ui/needs.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// the panel on a session's page. Pure functions over the daemon's needsYou items
|
|
3
3
|
// (shared with the unit tests); app.js wires the clicks.
|
|
4
4
|
//
|
|
5
|
-
// Kinds: permission (Allow once / Allow for this session / Deny with a note),
|
|
5
|
+
// Kinds: permission (Allow once / Always allow here ⓘ / Allow for this session / Deny with a note),
|
|
6
6
|
// question (AskUserQuestion's options as buttons, plus Other…), terminal (only
|
|
7
7
|
// the terminal can answer: Go), reply (the turn ended on a question: a reply box
|
|
8
8
|
// when the mod can type it in). Nothing is ever answered without a click.
|
|
9
9
|
|
|
10
10
|
import { mdInline } from './changelog-md.js'
|
|
11
|
+
import { tipHtml, tipId } from './tips.js'
|
|
11
12
|
|
|
12
13
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
13
14
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
@@ -56,6 +57,35 @@ export function cardBadge(n) {
|
|
|
56
57
|
export const KBD_SUBMIT = 'Meta+Enter Control+Enter'
|
|
57
58
|
const kbdHint = (opts) => '<span class="kbd-hint" aria-hidden="true">' + esc(opts.kbd || '⌘↵') + '</span>'
|
|
58
59
|
|
|
60
|
+
// 0.9.8 "Always allow here" (Claude Code's "don't ask again"): offered only
|
|
61
|
+
// when Leftoff can name the exact rule(s). One line on the button; the rule(s),
|
|
62
|
+
// the file and the project are in its ⓘ tooltip and in its aria-label.
|
|
63
|
+
export function alwaysText(ask) {
|
|
64
|
+
const a = ask && ask.always
|
|
65
|
+
if (!a || !Array.isArray(a.rules) || !a.rules.length) return ''
|
|
66
|
+
return 'adds ' + a.rules.join(', ') + ' to ' + (a.file || '.claude/settings.local.json')
|
|
67
|
+
}
|
|
68
|
+
// The tooltip's words: "Adds `Bash(x)` to .claude/settings.local.json in api, so
|
|
69
|
+
// Claude won't ask for this again here." (markup; the rules in mono)
|
|
70
|
+
export function alwaysTipHtml(ask, session = null) {
|
|
71
|
+
const a = ask && ask.always
|
|
72
|
+
if (!alwaysText(ask)) return ''
|
|
73
|
+
const where = a.project || (session && session.repoLabel) || ''
|
|
74
|
+
return 'Adds ' + a.rules.map((r) => '<code>' + esc(r) + '</code>').join(', ') + ' to <code>' + esc(a.file || '.claude/settings.local.json') + '</code>' +
|
|
75
|
+
(where ? ' in <b>' + esc(where) + '</b>' : ' in this project') + ', so Claude won’t ask for ' + (a.rules.length > 1 ? 'these' : 'this') + ' again here.'
|
|
76
|
+
}
|
|
77
|
+
// opts: { label ('Always allow here'; Quick look says 'Always'), scope (keeps the
|
|
78
|
+
// tooltip's id unique when Quick look and the page show the same ask), session, title }
|
|
79
|
+
export function alwaysButton(ask, dis = '', opts = {}) {
|
|
80
|
+
const t = alwaysText(ask)
|
|
81
|
+
if (!t) return ''
|
|
82
|
+
const label = opts.label || 'Always allow here'
|
|
83
|
+
const id = tipId(opts.scope || 'always', ask.id)
|
|
84
|
+
return '<span class="always-wrap">' +
|
|
85
|
+
'<button type="button" class="btn always" data-action="ask-answer" data-choice="allow_always"' + dis + (opts.title || '') + ' aria-label="' + esc(label + ': ' + t) + '" aria-describedby="' + esc(id) + '">' + esc(label) + '</button>' +
|
|
86
|
+
tipHtml({ id, label: 'What “' + label + '” does', html: alwaysTipHtml(ask, opts.session) }) + '</span>'
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
// opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
|
|
60
90
|
export function needPanel(n, opts = {}) {
|
|
61
91
|
const s = n.session || {}
|
|
@@ -81,7 +111,8 @@ export function needPanel(n, opts = {}) {
|
|
|
81
111
|
if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
|
|
82
112
|
h += '<div class="need-actions">' +
|
|
83
113
|
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
84
|
-
|
|
114
|
+
alwaysButton(n.ask, dis, { session: s }) +
|
|
115
|
+
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
|
|
85
116
|
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
86
117
|
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
|
87
118
|
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
|
package/ui/pull.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pull to refresh on phones (0.9.8). Pull down from the top of any page and let
|
|
2
|
+
// go past the line: the page reads its data again (no full reload). Pure
|
|
3
|
+
// functions here (shared with the unit tests); app.js wires the touches.
|
|
4
|
+
|
|
5
|
+
export const PULL_TRIGGER = 70 // px of finger travel that arms a refresh
|
|
6
|
+
export const PULL_MAX = 110 // how far the spinner travels at most
|
|
7
|
+
|
|
8
|
+
// Where a pull never starts: text fields, the Live box, menus and sheets,
|
|
9
|
+
// sideways scrollers, and anything open on top of the page.
|
|
10
|
+
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
|
|
11
|
+
|
|
12
|
+
// The phone layout on a touch screen, at the very top of the page, from a
|
|
13
|
+
// place that isn't a field or an overlay, and with no overlay open.
|
|
14
|
+
export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
|
|
15
|
+
if (!coarse || !narrow || overlayOpen) return false
|
|
16
|
+
if (scrollY > 0) return false
|
|
17
|
+
if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// One finger movement: how far the spinner sits (with some resistance), how
|
|
22
|
+
// far round it is, and whether letting go now refreshes. Sideways first means
|
|
23
|
+
// it isn't a pull.
|
|
24
|
+
export function pullStep(dy, dx = 0, { trigger = PULL_TRIGGER, max = PULL_MAX } = {}) {
|
|
25
|
+
if (!(dy > 0) || Math.abs(dx) > dy) return { pulling: false, dist: 0, progress: 0, armed: false }
|
|
26
|
+
const dist = Math.min(max, dy < trigger ? dy * 0.8 : trigger * 0.8 + (dy - trigger) * 0.35)
|
|
27
|
+
return { pulling: true, dist: Math.round(dist), progress: Math.min(1, dy / trigger), armed: dy >= trigger }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What letting go does.
|
|
31
|
+
export const pullRelease = (step) => (step && step.pulling && step.armed ? 'refresh' : 'cancel')
|
package/ui/push.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Phone notifications (0.9.8): the one per-device button. No setting: the
|
|
2
|
+
// button and the browser's own permission prompt are the opt-in, and the same
|
|
3
|
+
// button turns them off for this device. The Mac that owns a session sends
|
|
4
|
+
// the push (src/push.js); this file only subscribes this browser.
|
|
5
|
+
//
|
|
6
|
+
// iPhone: Web Push works for a board added to the Home Screen (iOS 16.4+), so
|
|
7
|
+
// Safari in a tab gets one line saying how, and the button appears in the
|
|
8
|
+
// Home Screen app. Pure parts (pushSupport, pushUi) are shared with the tests.
|
|
9
|
+
|
|
10
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
|
|
11
|
+
const BELL = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>'
|
|
12
|
+
|
|
13
|
+
export const isIos = (ua = '', touch = 0) => /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && touch > 1)
|
|
14
|
+
|
|
15
|
+
// What this browser can do -> { state } : insecure | add_to_home | unsupported | denied | off
|
|
16
|
+
export function pushSupport({ ua = '', touch = 0, standalone = false, hasPush = false, hasSW = false, secure = true, permission = 'default' } = {}) {
|
|
17
|
+
const ios = isIos(ua, touch)
|
|
18
|
+
if (!secure) return { state: 'insecure', ios }
|
|
19
|
+
if (ios && !standalone) return { state: 'add_to_home', ios }
|
|
20
|
+
if (!hasPush || !hasSW) return { state: 'unsupported', ios }
|
|
21
|
+
if (permission === 'denied') return { state: 'denied', ios }
|
|
22
|
+
return { state: 'off', ios }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The block for a slot. st: pushSupport() plus { on, busy, note, noteBad }
|
|
26
|
+
export function pushUi(st = {}) {
|
|
27
|
+
const note = st.note ? '<p class="push-note' + (st.noteBad ? ' bad' : '') + '" role="status">' + esc(st.note) + '</p>' : ''
|
|
28
|
+
const busy = st.busy ? ' disabled aria-busy="true"' : ''
|
|
29
|
+
const wrap = (inner) => '<div class="push-box">' + BELL + '<div class="push-main">' + inner + note + '</div></div>'
|
|
30
|
+
switch (st.on ? 'on' : st.state) {
|
|
31
|
+
case 'add_to_home': return wrap('<p class="push-line">Add Leftoff to your Home Screen first (Share → Add to Home Screen), then turn on notifications here.</p>')
|
|
32
|
+
case 'denied': return wrap('<p class="push-line">Notifications are blocked for Leftoff on this device. Allow them in ' + (st.ios ? 'Settings → Notifications → Leftoff' : 'the browser’s site settings') + ', then come back.</p>')
|
|
33
|
+
case 'on': return wrap('<p class="push-line"><strong>Notifications on for this device</strong></p><div class="push-acts"><button type="button" class="btn" data-push="test"' + busy + '>Send a test</button><button type="button" class="btn ghost" data-push="off"' + busy + '>Turn off</button></div>')
|
|
34
|
+
case 'off': return wrap('<p class="push-line">Get a notification when Claude needs you or a Live reply lands.</p><div class="push-acts"><button type="button" class="btn primary" data-push="on"' + busy + '>' + (st.busy ? 'Turning on…' : 'Turn on notifications') + '</button></div>')
|
|
35
|
+
default: return '' // no Push here (or not https): nothing to offer
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
40
|
+
const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)), (c) => c.charCodeAt(0))
|
|
41
|
+
|
|
42
|
+
const ui = { st: null, on: false, sub: null, busy: false, note: '', noteBad: false, api: null, refreshed: false }
|
|
43
|
+
|
|
44
|
+
function env() {
|
|
45
|
+
return {
|
|
46
|
+
ua: navigator.userAgent, touch: navigator.maxTouchPoints || 0,
|
|
47
|
+
standalone: !!(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true,
|
|
48
|
+
hasPush: 'PushManager' in window && 'Notification' in window, hasSW: 'serviceWorker' in navigator,
|
|
49
|
+
secure: !!window.isSecureContext, permission: 'Notification' in window ? Notification.permission : 'default',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function registration() {
|
|
54
|
+
if (!('serviceWorker' in navigator)) return null
|
|
55
|
+
return (await navigator.serviceWorker.getRegistration('/')) || (await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => null))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keyOf = (sub) => { try { const k = sub.options && sub.options.applicationServerKey; return k ? b64u(k) : null } catch { return null } }
|
|
59
|
+
|
|
60
|
+
async function load() {
|
|
61
|
+
const e = env()
|
|
62
|
+
ui.st = pushSupport(e)
|
|
63
|
+
ui.sub = null; ui.on = false
|
|
64
|
+
if (ui.st.state !== 'off') return
|
|
65
|
+
try { const reg = await registration(); ui.sub = reg && reg.pushManager ? await reg.pushManager.getSubscription() : null } catch { ui.sub = null }
|
|
66
|
+
ui.on = !!ui.sub && e.permission === 'granted'
|
|
67
|
+
// Once a page load: tell this Mac again (it shares with your other Macs), so a
|
|
68
|
+
// Mac that lost track, or a new one, knows this device.
|
|
69
|
+
if (ui.on && !ui.refreshed && ui.api) { ui.refreshed = true; ui.api('POST', '/v1/push/subscribe', { subscription: ui.sub.toJSON(), serverKey: keyOf(ui.sub), ua: navigator.userAgent }).catch(() => {}) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function paintPush() {
|
|
73
|
+
const slots = document.querySelectorAll('[data-push-slot]')
|
|
74
|
+
if (!slots.length) return
|
|
75
|
+
const html = ui.st ? pushUi({ ...ui.st, on: ui.on, busy: ui.busy, note: ui.note, noteBad: ui.noteBad }) : ''
|
|
76
|
+
for (const el of slots) { el.innerHTML = html; el.hidden = !html; el.setAttribute('data-pp', '1') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function turnOn() {
|
|
80
|
+
// The permission prompt first, straight from the tap (iOS asks only then).
|
|
81
|
+
const perm = await Notification.requestPermission()
|
|
82
|
+
if (perm !== 'granted') { ui.st = pushSupport(env()); ui.note = perm === 'denied' ? '' : 'Notifications weren’t allowed. Tap again to try.'; return }
|
|
83
|
+
await registration()
|
|
84
|
+
const reg = await navigator.serviceWorker.ready
|
|
85
|
+
const info = await ui.api('GET', '/v1/push')
|
|
86
|
+
let sub = await reg.pushManager.getSubscription()
|
|
87
|
+
// An existing subscription stays (the Mac that made it shares its key); a new one uses this Mac's key.
|
|
88
|
+
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: unb64u(info.publicKey) })
|
|
89
|
+
await ui.api('POST', '/v1/push/subscribe', { subscription: sub.toJSON(), serverKey: keyOf(sub) || info.publicKey, ua: navigator.userAgent })
|
|
90
|
+
ui.sub = sub; ui.on = true; ui.refreshed = true
|
|
91
|
+
ui.note = 'You’ll get a notification when Claude needs you or a Live reply lands.'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function turnOff() {
|
|
95
|
+
const sub = ui.sub
|
|
96
|
+
if (sub) {
|
|
97
|
+
const endpoint = sub.endpoint
|
|
98
|
+
try { await sub.unsubscribe() } catch {}
|
|
99
|
+
await ui.api('POST', '/v1/push/unsubscribe', { endpoint }).catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
ui.sub = null; ui.on = false
|
|
102
|
+
ui.note = 'Notifications off for this device.'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function sendTest() {
|
|
106
|
+
const r = await ui.api('POST', '/v1/push/test', { endpoint: ui.sub && ui.sub.endpoint })
|
|
107
|
+
if (r && r.ok) { ui.note = 'Sent from ' + (r.machine || 'your Mac') + '. It should show up in a few seconds.'; return }
|
|
108
|
+
if (r && r.error === 'not_subscribed') { ui.on = false; ui.sub = null; throw new Error('This Mac didn’t know this device. Turn notifications on again.') }
|
|
109
|
+
throw new Error('The push service didn’t take it' + (r && r.error ? ' (' + r.error + ')' : '') + '.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wire it once: api is app.js's api(method, path, body).
|
|
113
|
+
export function mountPush({ api }) {
|
|
114
|
+
ui.api = api
|
|
115
|
+
document.addEventListener('click', async (ev) => {
|
|
116
|
+
const b = ev.target.closest && ev.target.closest('[data-push]')
|
|
117
|
+
if (!b || ui.busy) return
|
|
118
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
119
|
+
const what = b.getAttribute('data-push')
|
|
120
|
+
ui.busy = true; ui.note = ''; ui.noteBad = false; paintPush()
|
|
121
|
+
try {
|
|
122
|
+
if (what === 'on') await turnOn()
|
|
123
|
+
else if (what === 'off') await turnOff()
|
|
124
|
+
else if (what === 'test') await sendTest()
|
|
125
|
+
} catch (e) { ui.note = e && e.message ? e.message : String(e); ui.noteBad = true }
|
|
126
|
+
ui.busy = false
|
|
127
|
+
paintPush()
|
|
128
|
+
})
|
|
129
|
+
load().then(paintPush, paintPush)
|
|
130
|
+
// Settings is drawn on demand: paint a slot the moment one appears.
|
|
131
|
+
const app = document.getElementById('app')
|
|
132
|
+
if (app && window.MutationObserver) new MutationObserver(() => { if (document.querySelector('[data-push-slot]:not([data-pp])')) paintPush() }).observe(app, { childList: true, subtree: true })
|
|
133
|
+
// A tapped notification: the service worker asks the open board to show that session.
|
|
134
|
+
if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', (ev) => {
|
|
135
|
+
const d = ev.data || {}
|
|
136
|
+
if (d.type !== 'leftoff-open' || typeof d.url !== 'string') return
|
|
137
|
+
try { const u = new URL(d.url, location.origin); if (u.origin === location.origin && location.hash !== u.hash) location.hash = u.hash } catch {}
|
|
138
|
+
})
|
|
139
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !ui.busy) load().then(paintPush, paintPush) })
|
|
140
|
+
}
|
package/ui/relay-client.js
CHANGED
|
@@ -44,7 +44,7 @@ export async function forgetDeviceKey() {
|
|
|
44
44
|
|
|
45
45
|
// ------------------------------------------------------------ state
|
|
46
46
|
|
|
47
|
-
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
|
|
47
|
+
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null, owners: new Map() }
|
|
48
48
|
// ui/direct.js loads only on the board itself (never on pair.html, which the
|
|
49
49
|
// relay serves before a browser is paired and where this file is imported too).
|
|
50
50
|
let route = null, directOrRelay = null
|
|
@@ -140,6 +140,39 @@ function watchDirect() {
|
|
|
140
140
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// ------------------------------------------------------------ auto-switch host (0.9.8)
|
|
144
|
+
// On your leftoff.dev board, a session that lives on another Mac loads its own
|
|
145
|
+
// data (Live, its transcript, Find lists, files...) straight from that Mac
|
|
146
|
+
// through the relay, instead of from the Mac serving the board. The board says
|
|
147
|
+
// which Mac each session lives on (leftoffRelay.owner); these decide where one
|
|
148
|
+
// call goes. Pure and exported, so they are unit-tested.
|
|
149
|
+
|
|
150
|
+
// /v1/sessions/<id>/<anything> -> <id>; the record itself (/v1/sessions/<id>) stays on
|
|
151
|
+
// the board's Mac (it's the synced copy the board lists), and so does everything else.
|
|
152
|
+
const SESSION_SUB = /^\/v1\/sessions\/([^/?#]+)\/[^?#]+$/
|
|
153
|
+
export function sessionOfPath(pathname) {
|
|
154
|
+
const m = SESSION_SUB.exec(String(pathname || ''))
|
|
155
|
+
if (!m) return null
|
|
156
|
+
try { return decodeURIComponent(m[1]) } catch { return null }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Which Mac a call should go to, or null for the board's own Mac.
|
|
160
|
+
// owners: Map(sessionId -> machine) the board filled in; current: the Mac serving the board;
|
|
161
|
+
// online: the Macs the relay says are connected now.
|
|
162
|
+
export function ownerRoute(pathname, owners, { current = null, online = [] } = {}) {
|
|
163
|
+
const id = sessionOfPath(pathname)
|
|
164
|
+
if (!id || !owners || typeof owners.get !== 'function') return null
|
|
165
|
+
const m = owners.get(id)
|
|
166
|
+
if (!m || m === current) return null
|
|
167
|
+
return online.includes(m) ? m : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A failed hop to the owner falls back to the board's Mac (which reaches the
|
|
171
|
+
// owner over the peer link, or says plainly that it's offline).
|
|
172
|
+
export function fallBack({ status = 0, code = '' } = {}) {
|
|
173
|
+
return status === 0 || status === 401 || status === 403 && code === 'unknown_device' || status === 502 || status === 503 || status === 504 || code === 'mac_offline' || code === 'timeout'
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
// ------------------------------------------------------------ the tunnel
|
|
144
177
|
|
|
145
178
|
async function tunnel(url, init = {}, retried = false) {
|
|
@@ -151,6 +184,9 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
151
184
|
const hdrs = new Headers(init.headers || {})
|
|
152
185
|
if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
|
|
153
186
|
const body = { m: method, p: url.pathname + url.search, b, h }
|
|
187
|
+
// 0.9.8: a session that lives on another Mac answers for itself (ui/owner-route.js)
|
|
188
|
+
const owner = ownerRoute(url.pathname, relay.owners, { current: relay.mac, online: onlineMacs() })
|
|
189
|
+
if (owner) { const r = await viaOwner(owner, body, init).catch(() => null); if (r) return r }
|
|
154
190
|
if (!onDirect()) return viaRelay(body, init, retried)
|
|
155
191
|
return directOrRelay({
|
|
156
192
|
route,
|
|
@@ -164,6 +200,22 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
164
200
|
})
|
|
165
201
|
}
|
|
166
202
|
|
|
203
|
+
function onlineMacs() { return ((relay.status && relay.status.macs) || []).filter((m) => m.online).map((m) => m.machine) }
|
|
204
|
+
// Straight to the session's own Mac through the relay; null sends the call to the
|
|
205
|
+
// board's Mac instead (the owner went away, or doesn't know this browser yet).
|
|
206
|
+
async function viaOwner(machine, body, init) {
|
|
207
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine, body })
|
|
208
|
+
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' })
|
|
209
|
+
if (res.status !== 200) {
|
|
210
|
+
const j = await res.json().catch(() => ({}))
|
|
211
|
+
if (j.code === 'mac_offline') refreshStatus().catch(() => {})
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
const out = toResponse(await openResponse(relay.key, env, await res.json()))
|
|
215
|
+
if (out.status === 401 || out.status === 403) { const j = await out.clone().json().catch(() => ({})); if (fallBack({ status: out.status, code: j.code || '' }) || /device/.test(j.code || '')) return null }
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
167
219
|
async function viaRelay(body, init, retried) {
|
|
168
220
|
const gone = relay.mac
|
|
169
221
|
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
@@ -196,6 +248,7 @@ function patchedFetch(input, init) {
|
|
|
196
248
|
}
|
|
197
249
|
|
|
198
250
|
// EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
|
|
251
|
+
export const POLL_WAIT_MS = 20000, POLL_MARGIN_MS = 12000
|
|
199
252
|
class RelayEventSource extends EventTarget {
|
|
200
253
|
constructor(url) {
|
|
201
254
|
super()
|
|
@@ -207,11 +260,20 @@ class RelayEventSource extends EventTarget {
|
|
|
207
260
|
let fails = 0
|
|
208
261
|
while (this.readyState !== 2) {
|
|
209
262
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
263
|
+
// 0.9.8: a poll that outlives its wait plus a margin is dead (iOS
|
|
264
|
+
// suspended the page mid-request, or the relay dropped it silently):
|
|
265
|
+
// abort it and poll again instead of waiting forever.
|
|
266
|
+
const ctrl = this.ctrl = new AbortController()
|
|
267
|
+
const timer = setTimeout(() => ctrl.abort(), POLL_WAIT_MS + POLL_MARGIN_MS)
|
|
268
|
+
let j
|
|
269
|
+
try {
|
|
270
|
+
const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=' + POLL_WAIT_MS, { signal: ctrl.signal })
|
|
271
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
272
|
+
j = await res.json()
|
|
273
|
+
} finally { clearTimeout(timer) }
|
|
274
|
+
if (this.readyState === 2) return
|
|
214
275
|
if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
|
|
276
|
+
this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
|
|
215
277
|
const first = this.seq == null
|
|
216
278
|
this.seq = j.seq
|
|
217
279
|
if (!first) for (const e of j.events || []) {
|
|
@@ -272,6 +334,9 @@ export const leftoffRelay = {
|
|
|
272
334
|
get route() { return onDirect() ? 'direct' : 'relay' },
|
|
273
335
|
get directLatencyMs() { return route ? route.latencyMs : null },
|
|
274
336
|
status: () => refreshStatus(),
|
|
337
|
+
// 0.9.8: which Mac a session lives on (null: the board's own), so its calls go there
|
|
338
|
+
owner(id, machine) { if (!id) return; if (machine) relay.owners.set(String(id), String(machine)); else relay.owners.delete(String(id)) },
|
|
339
|
+
ownerOf(id) { const m = relay.owners.get(String(id)); return m && m !== relay.mac && onlineMacs().includes(m) ? m : null },
|
|
275
340
|
// Switch Macs: the board reloads from the other one.
|
|
276
341
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
277
342
|
// This browser revoked itself (0.6.6).
|
package/ui/restore-gate.js
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
// route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
|
|
12
12
|
// machine the Mac the restore card belongs to
|
|
13
13
|
// phone a phone or tablet (never runs Leftoff)
|
|
14
|
-
//
|
|
15
|
-
|
|
14
|
+
// canAsk 0.9.8: that Mac takes Restore requests from other devices (GET /v1/restore has asks)
|
|
15
|
+
// -> { ok, how: 'local' | 'direct' | 'ask' | null, hint: text with `commands` or null }
|
|
16
|
+
// 'ask': the request goes to that Mac, where a person confirms before any tab opens.
|
|
17
|
+
export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false, canAsk = false } = {}) {
|
|
16
18
|
if (local && !hosted) return { ok: true, how: 'local', hint: null }
|
|
17
19
|
if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
|
|
18
20
|
const m = machine || 'the Mac'
|
|
21
|
+
if (canAsk && machine) return { ok: true, how: 'ask', hint: 'Restore asks ' + m + ' first: someone at ' + m + ' confirms before the tabs open.' }
|
|
19
22
|
if (phone) return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it can’t run from a phone yet. On ' + m + ', run `leftoff restore`.' }
|
|
20
23
|
if (hosted) {
|
|
21
24
|
return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + '. Open this board on ' + m + ', or run `leftoff restore` there. Already on ' + m + '? Run `leftoff setup` and turn on https://leftoff.localhost so this page can reach it.' }
|
|
@@ -23,6 +26,27 @@ export function restoreAccess({ local = false, hosted = false, route = null, rou
|
|
|
23
26
|
return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it works from ' + m + '’s own browser. Or run `leftoff restore` there.' }
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// 0.9.8: what the card says about a Restore it asked another Mac for.
|
|
30
|
+
// ask: { state, machine, count, expiresAt, result, error } -> { text, tone: 'info' | 'ok' | 'warn' }
|
|
31
|
+
export function askLine(ask, { clock = (iso) => iso } = {}) {
|
|
32
|
+
if (!ask) return null
|
|
33
|
+
const m = ask.machine || 'the Mac', n = ask.count || 0
|
|
34
|
+
const tabs = n + ' tab' + (n === 1 ? '' : 's')
|
|
35
|
+
switch (ask.state) {
|
|
36
|
+
case 'pending': return { text: 'Waiting for someone at ' + m + ' to confirm' + (ask.expiresAt ? ' (until ' + clock(ask.expiresAt) + ')' : '') + '…', tone: 'info' }
|
|
37
|
+
case 'confirming': return { text: 'Confirmed on ' + m + ': opening ' + tabs + '…', tone: 'info' }
|
|
38
|
+
case 'confirmed': {
|
|
39
|
+
const r = ask.result || {}
|
|
40
|
+
if (r.how === 'print') return { text: 'Confirmed on ' + m + ', but Ghostty didn’t open there: run `leftoff restore` on ' + m + '.', tone: 'warn' }
|
|
41
|
+
return { text: 'Confirmed on ' + m + ': ' + (r.opened || 0) + ' of ' + tabs + ' opened' + (r.failed ? ', ' + r.failed + ' didn’t' : '') + '.', tone: r.failed ? 'warn' : 'ok' }
|
|
42
|
+
}
|
|
43
|
+
case 'declined': return { text: 'Declined on ' + m + '. Nothing was opened.', tone: 'warn' }
|
|
44
|
+
case 'expired': return { text: 'Nobody confirmed on ' + m + ' in time. Nothing was opened.', tone: 'warn' }
|
|
45
|
+
case 'failed': return { text: 'It didn’t work on ' + m + ': ' + (ask.error || 'an error') + '.', tone: 'warn' }
|
|
46
|
+
default: return { text: ask.error || 'Couldn’t ask ' + m + '.', tone: 'warn' }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
// The hint as HTML: escaped, `commands` in <code>.
|
|
27
51
|
export function hintHtml(hint, esc) {
|
|
28
52
|
return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
|