@leftoff/cli 0.0.0-stage → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/CHANGELOG.md +564 -0
  3. package/LICENSE.md +9 -0
  4. package/README.md +28 -2
  5. package/assets/sample-transcript.jsonl +12 -0
  6. package/bin/checkpoints.js +7 -0
  7. package/bin/leftoff.js +585 -0
  8. package/hooks/README.md +22 -0
  9. package/hooks/settings-hooks.example.json +82 -0
  10. package/mod/.claude-plugin/plugin.json +15 -0
  11. package/mod/hooks/core.js +1397 -0
  12. package/mod/hooks/hooks.json +4 -0
  13. package/mod/hooks/register.js +1006 -0
  14. package/package.json +45 -4
  15. package/templates/agent-feed-worker/README.md +45 -0
  16. package/templates/agent-feed-worker/src/index.js +69 -0
  17. package/templates/agent-feed-worker/wrangler.toml +20 -0
  18. package/ui/app.css +1282 -0
  19. package/ui/app.js +2979 -0
  20. package/ui/apple-touch-icon.png +0 -0
  21. package/ui/changelog-md.js +64 -0
  22. package/ui/favicon-32.png +0 -0
  23. package/ui/favicon-badge-32.png +0 -0
  24. package/ui/favicon-badge.svg +1 -0
  25. package/ui/favicon.ico +0 -0
  26. package/ui/favicon.svg +1 -0
  27. package/ui/go.js +68 -0
  28. package/ui/icon-192.png +0 -0
  29. package/ui/icon-512.png +0 -0
  30. package/ui/icon-maskable-512.png +0 -0
  31. package/ui/icons.js +41 -0
  32. package/ui/index.html +55 -0
  33. package/ui/manifest.webmanifest +37 -0
  34. package/ui/needs.js +197 -0
  35. package/ui/offline.css +11 -0
  36. package/ui/offline.html +24 -0
  37. package/ui/pair.html +31 -0
  38. package/ui/picker.js +294 -0
  39. package/ui/relay-client.js +187 -0
  40. package/ui/relay-crypto.js +155 -0
  41. package/ui/relay-pair.js +48 -0
  42. package/ui/repos.js +167 -0
  43. package/ui/settings-nav.js +23 -0
  44. package/ui/summary-examples.js +66 -0
  45. package/ui/sw.js +34 -0
  46. package/ui/theme.js +41 -0
  47. package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
package/ui/picker.js ADDED
@@ -0,0 +1,294 @@
1
+ // Leftoff's own dropdowns (v0.5.9). The app never uses a native <select> or
2
+ // <datalist>: their menus are the OS's (a grey popup on a Mac, a wheel or a
3
+ // bare list with a stray "/" on an iPhone) and can't be styled. Two elements:
4
+ //
5
+ // <lo-select id="f-tz" aria-label="Time zone"><option value="">…</option>…</lo-select>
6
+ // A button that opens a listbox. Like a select: .value, .selectedIndex,
7
+ // .options, .disabled, and a bubbling "change" event. More than 10 options
8
+ // get a filter box at the top of the menu.
9
+ // <lo-combo id="f-space-repo" placeholder="repo folder name"><option value="clutch_hunt" data-sub="~/src">…</lo-combo>
10
+ // A text field with suggestions that filter as you type; any text is allowed.
11
+ // .value is the text. "input" and "change" bubble from it.
12
+ //
13
+ // Both: arrows, Home/End, Enter, Escape, Tab; a click outside closes; the menu
14
+ // never leaves the viewport, and under 600px wide it opens as a bottom sheet.
15
+ // ARIA: combobox + listbox + option, aria-activedescendant, aria-selected.
16
+ // Markup helpers: selectHtml() and comboHtml(). test/unit/ui-no-native-select.test.mjs
17
+ // fails if any other file in ui/ writes a native select or datalist.
18
+
19
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
20
+ const CHEVRON = '<svg class="lo-chev" 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="m6 9 6 6 6-6"/></svg>'
21
+ const CHECK = '<svg class="lo-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>'
22
+ const SHEET_MAX = 599.98
23
+ const FILTER_AT = 10
24
+ let uid = 0
25
+ let open = null // { host, menu, list, items, active, filter, backdrop }
26
+
27
+ const attrs = (o = {}) => Object.entries(o).filter(([, v]) => v != null && v !== false).map(([k, v]) => ' ' + k + (v === true ? '' : '="' + esc(v) + '"')).join('')
28
+
29
+ // options: [{ value, label, sub, selected }] or [value, label] pairs or plain strings
30
+ function normOpts(options) {
31
+ return (options || []).map((o) => (Array.isArray(o) ? { value: o[0], label: o[1] != null ? o[1] : o[0], sub: o[2] } : typeof o === 'object' && o ? o : { value: o, label: o }))
32
+ }
33
+ export function selectHtml({ id, value, options, label, cls, attrs: extra, disabled } = {}) {
34
+ const opts = normOpts(options)
35
+ const has = opts.some((o) => String(o.value) === String(value))
36
+ return '<lo-select' + attrs({ id, class: cls, 'aria-label': label, disabled: !!disabled, ...(extra || {}) }) + '>' +
37
+ opts.map((o, i) => '<option' + attrs({ value: o.value, 'data-sub': o.sub, selected: has ? String(o.value) === String(value) : (o.selected || (value == null && i === 0)) }) + '>' + esc(o.label) + '</option>').join('') + '</lo-select>'
38
+ }
39
+ export function comboHtml({ id, value, options, label, placeholder, cls, attrs: extra } = {}) {
40
+ return '<lo-combo' + attrs({ id, class: cls, 'aria-label': label, placeholder, value, ...(extra || {}) }) + '>' +
41
+ normOpts(options).map((o) => '<option' + attrs({ value: o.value, 'data-sub': o.sub }) + '>' + esc(o.label) + '</option>').join('') + '</lo-combo>'
42
+ }
43
+
44
+ const readOpts = (el) => [...el.querySelectorAll(':scope > option')].map((o) => ({ value: o.getAttribute('value') != null ? o.getAttribute('value') : o.textContent, label: o.textContent, sub: o.dataset.sub || '', selected: o.hasAttribute('selected') }))
45
+ const narrow = () => window.innerWidth <= SHEET_MAX
46
+ const fire = (el, type) => el.dispatchEvent(new Event(type, { bubbles: true }))
47
+
48
+ // (a plain class under Node, so the unit tests can import the markup helpers)
49
+ const Base = typeof HTMLElement === 'undefined' ? class {} : HTMLElement
50
+ class LoSelect extends Base {
51
+ connectedCallback() {
52
+ if (this._ready) return
53
+ this._ready = true
54
+ this._opts = readOpts(this)
55
+ const sel = this._opts.findIndex((o) => o.selected)
56
+ this._index = sel >= 0 ? sel : (this._opts.length ? 0 : -1)
57
+ this._id = 'lo' + (++uid)
58
+ const lbl = this.getAttribute('aria-label') || ''
59
+ this.removeAttribute('aria-label')
60
+ this.innerHTML = '<button type="button" class="lo-sel-btn" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="' + this._id + '-list"' + (lbl ? ' aria-label="' + esc(lbl) + '"' : '') + (this.hasAttribute('disabled') ? ' disabled' : '') + '>' +
61
+ '<span class="lo-sel-val"></span>' + CHEVRON + '</button>'
62
+ this._btn = this.firstChild
63
+ if (!lbl && this.id) { const l = document.querySelector('label[for="' + CSS.escape(this.id) + '"]'); if (l) { if (!l.id) l.id = this._id + '-label'; this._btn.setAttribute('aria-labelledby', l.id + ' ' + this._id + '-val') } }
64
+ this._btn.querySelector('.lo-sel-val').id = this._id + '-val'
65
+ this._paint()
66
+ this._btn.addEventListener('click', () => (open && open.host === this ? closeMenu() : openMenu(this)))
67
+ this._btn.addEventListener('keydown', (e) => onKey(this, e))
68
+ }
69
+ _paint() { const o = this._opts[this._index]; const v = this._btn && this._btn.querySelector('.lo-sel-val'); if (v) v.textContent = o ? o.label : ''; if (this._btn) this._btn.title = o ? o.label : '' }
70
+ get options() { return (this._opts || readOpts(this)).map((o) => ({ ...o })) }
71
+ get value() { const o = (this._opts || readOpts(this))[this._ready ? this._index : Math.max(0, readOpts(this).findIndex((x) => x.selected))]; return o ? String(o.value) : '' }
72
+ set value(v) { if (!this._ready) this.connectedCallback(); const i = this._opts.findIndex((o) => String(o.value) === String(v)); if (i >= 0) { this._index = i; this._paint() } }
73
+ get selectedIndex() { return this._ready ? this._index : Math.max(0, readOpts(this).findIndex((x) => x.selected)) }
74
+ set selectedIndex(i) { if (!this._ready) this.connectedCallback(); if (i >= 0 && i < this._opts.length) { this._index = i; this._paint() } }
75
+ get disabled() { return this.hasAttribute('disabled') }
76
+ set disabled(v) { this.toggleAttribute('disabled', !!v); if (this._btn) this._btn.disabled = !!v }
77
+ focus() { if (this._btn) this._btn.focus() }
78
+ // the control a menu anchors to and returns focus to
79
+ get _anchor() { return this._btn }
80
+ _choose(i) {
81
+ if (i < 0 || i >= this._opts.length) return
82
+ const changed = i !== this._index
83
+ this._index = i; this._paint(); closeMenu(); this._btn.focus()
84
+ if (changed) { fire(this, 'input'); fire(this, 'change') }
85
+ }
86
+ _items(q) { return this._opts.map((o, i) => ({ ...o, i })).filter((o) => !q || (o.label + ' ' + o.value + ' ' + o.sub).toLowerCase().includes(q)) }
87
+ }
88
+
89
+ class LoCombo extends Base {
90
+ connectedCallback() {
91
+ if (this._ready) return
92
+ this._ready = true
93
+ this._opts = readOpts(this)
94
+ this._id = 'lo' + (++uid)
95
+ const a = (k) => this.getAttribute(k)
96
+ const inputId = this.id ? this.id + '-input' : this._id + '-input'
97
+ this.innerHTML = '<input type="text" class="lo-combo-input" id="' + esc(inputId) + '" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="' + this._id + '-list" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"' +
98
+ (a('aria-label') ? ' aria-label="' + esc(a('aria-label')) + '"' : '') + (a('placeholder') ? ' placeholder="' + esc(a('placeholder')) + '"' : '') + '>' +
99
+ '<button type="button" class="lo-combo-btn" tabindex="-1" aria-label="Show suggestions">' + CHEVRON + '</button>'
100
+ this.removeAttribute('aria-label')
101
+ this._input = this.querySelector('input')
102
+ if (a('value')) this._input.value = a('value')
103
+ const btn = this.querySelector('.lo-combo-btn')
104
+ btn.addEventListener('mousedown', (e) => e.preventDefault())
105
+ btn.addEventListener('click', () => { if (open && open.host === this) closeMenu(); else { this._input.focus(); openMenu(this, { all: true }) } })
106
+ this._input.addEventListener('input', (e) => { e.stopPropagation(); fire(this, 'input'); if (this._items(this._q()).length) openMenu(this); else closeMenu() })
107
+ this._input.addEventListener('change', (e) => { e.stopPropagation(); fire(this, 'change') })
108
+ this._input.addEventListener('keydown', (e) => onKey(this, e))
109
+ this._input.addEventListener('focus', () => { if (narrow()) return; if (!this._input.value && this._opts.length) openMenu(this, { all: true }) })
110
+ }
111
+ _q() { return this._input ? this._input.value.trim().toLowerCase() : '' }
112
+ get options() { return (this._opts || readOpts(this)).map((o) => ({ ...o })) }
113
+ get value() { return this._input ? this._input.value : (this.getAttribute('value') || '') }
114
+ set value(v) { if (this._input) this._input.value = v == null ? '' : String(v); else this.setAttribute('value', v == null ? '' : String(v)) }
115
+ focus() { if (this._input) this._input.focus() }
116
+ get _anchor() { return this._input }
117
+ _choose(i) {
118
+ const o = this._opts[i]
119
+ if (!o) return
120
+ this._input.value = String(o.value); closeMenu(); this._input.focus()
121
+ fire(this, 'input'); fire(this, 'change')
122
+ }
123
+ _items(q, all) {
124
+ const list = this._opts.map((o, i) => ({ ...o, i }))
125
+ if (all || !q) return list
126
+ // names that start with the text first, then the rest that contain it
127
+ const starts = list.filter((o) => o.label.toLowerCase().startsWith(q))
128
+ return starts.concat(list.filter((o) => !starts.includes(o) && (o.label + ' ' + o.sub).toLowerCase().includes(q)))
129
+ }
130
+ }
131
+
132
+ function openMenu(host, { all = false } = {}) {
133
+ if (host.disabled) return
134
+ const combo = host instanceof LoCombo
135
+ if (open && open.host !== host) closeMenu()
136
+ // a select opens as a bottom sheet on a phone; a combo keeps its field in
137
+ // view (the keyboard is up), so its list is a full-width panel under it
138
+ const sheet = narrow() && !combo
139
+ if (!open) {
140
+ const menu = document.createElement('div')
141
+ menu.className = 'lo-menu' + (sheet ? ' sheet' : '')
142
+ const label = host.dataset.title || (host._anchor && (host._anchor.getAttribute('aria-label') || (host._anchor.getAttribute('aria-labelledby') && document.getElementById(host._anchor.getAttribute('aria-labelledby').split(' ')[0]) || {}).textContent)) || ''
143
+ const wantFilter = !combo && host._opts.length > FILTER_AT
144
+ menu.innerHTML = (sheet ? '<div class="lo-sheet-head"><span>' + esc(label) + '</span><button type="button" class="lo-sheet-done">Done</button></div>' : '') +
145
+ (wantFilter ? '<div class="lo-filter-wrap"><input type="search" class="lo-filter" placeholder="Filter…" aria-label="Filter ' + esc(label) + '" autocomplete="off" autocapitalize="off" spellcheck="false" aria-controls="' + host._id + '-list"></div>' : '') +
146
+ '<div class="lo-list" role="listbox" id="' + host._id + '-list"' + (label ? ' aria-label="' + esc(label) + '"' : '') + '></div>'
147
+ let backdrop = null
148
+ if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop); backdrop.addEventListener('click', () => closeMenu()) }
149
+ document.body.appendChild(menu)
150
+ open = { host, menu, list: menu.querySelector('.lo-list'), filter: menu.querySelector('.lo-filter'), backdrop, active: -1, items: [] }
151
+ host._anchor.setAttribute('aria-expanded', 'true')
152
+ host.classList.add('open')
153
+ menu.addEventListener('mousedown', (e) => { if (!e.target.closest('.lo-filter')) e.preventDefault() })
154
+ menu.addEventListener('click', (e) => {
155
+ if (e.target.closest('.lo-sheet-done')) { closeMenu(); host._anchor.focus(); return }
156
+ const opt = e.target.closest('[role="option"]')
157
+ if (opt) host._choose(Number(opt.dataset.i))
158
+ })
159
+ menu.addEventListener('mousemove', (e) => { const opt = e.target.closest('[role="option"]'); if (opt) setActive(open.items.findIndex((x) => x.i === Number(opt.dataset.i)), { scroll: false }) })
160
+ if (open.filter) {
161
+ open.filter.addEventListener('input', () => fill(host, open.filter.value.trim().toLowerCase()))
162
+ open.filter.addEventListener('keydown', (e) => onKey(host, e))
163
+ }
164
+ }
165
+ fill(host, combo ? (all ? '' : host._q()) : '', { all })
166
+ if (open.filter && !sheet) open.filter.focus({ preventScroll: true })
167
+ }
168
+
169
+ function fill(host, q, { all = false } = {}) {
170
+ if (!open) return
171
+ const combo = host instanceof LoCombo
172
+ open.items = host._items(q, all)
173
+ const cur = combo ? -1 : host._index
174
+ const needle = q && !all ? q : ''
175
+ const mark = (s) => { if (!needle) return esc(s); const i = s.toLowerCase().indexOf(needle); return i < 0 ? esc(s) : esc(s.slice(0, i)) + '<mark>' + esc(s.slice(i, i + needle.length)) + '</mark>' + esc(s.slice(i + needle.length)) }
176
+ open.list.innerHTML = open.items.length ? open.items.map((o, k) => {
177
+ const sel = combo ? String(o.value) === host.value : o.i === cur
178
+ return '<div role="option" class="lo-opt' + (sel ? ' sel' : '') + '" id="' + host._id + '-o' + o.i + '" data-i="' + o.i + '" aria-selected="' + sel + '">' + CHECK +
179
+ '<span class="lo-opt-text"><span class="lo-opt-label">' + mark(o.label) + '</span>' + (o.sub ? '<span class="lo-opt-sub">' + esc(o.sub) + '</span>' : '') + '</span></div>'
180
+ }).join('') : '<div class="lo-empty">No match</div>'
181
+ const selPos = open.items.findIndex((o) => (combo ? String(o.value) === host.value : o.i === cur))
182
+ setActive(combo ? (q && !all && open.items.length ? 0 : selPos) : (selPos >= 0 ? selPos : 0), { scroll: true })
183
+ place()
184
+ }
185
+
186
+ function setActive(k, { scroll = true } = {}) {
187
+ if (!open) return
188
+ const { list, items, host } = open
189
+ open.active = k
190
+ list.querySelectorAll('.lo-opt.active').forEach((el) => el.classList.remove('active'))
191
+ const it = items[k]
192
+ const target = open.filter || host._anchor
193
+ if (!it) { host._anchor.removeAttribute('aria-activedescendant'); if (open.filter) open.filter.removeAttribute('aria-activedescendant'); return }
194
+ const el = document.getElementById(host._id + '-o' + it.i)
195
+ if (el) { el.classList.add('active'); if (scroll) el.scrollIntoView({ block: 'nearest' }) }
196
+ target.setAttribute('aria-activedescendant', host._id + '-o' + it.i)
197
+ }
198
+
199
+ // Desktop: under the control (above it when there is more room there), at
200
+ // least as wide as the control, never past the viewport's edges.
201
+ function place() {
202
+ if (!open) return
203
+ const { menu, host } = open
204
+ const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
205
+ if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
206
+ const r = host.getBoundingClientRect()
207
+ const pad = 8
208
+ const full = narrow()
209
+ menu.classList.toggle('wide', full)
210
+ menu.style.minWidth = (full ? vw - 2 * pad : Math.min(Math.max(r.width, 180), vw - 2 * pad)) + 'px'
211
+ menu.style.maxWidth = (full ? vw - 2 * pad : Math.min(440, vw - 2 * pad)) + 'px'
212
+ const below = vh - r.bottom - pad, above = r.top - pad
213
+ const up = below < 220 && above > below
214
+ const room = Math.max(120, Math.min(340, (up ? above : below) - 6))
215
+ menu.style.maxHeight = room + 'px'
216
+ const w = menu.offsetWidth, h = menu.offsetHeight
217
+ menu.style.left = Math.max(pad, Math.min(r.left, vw - pad - w)) + 'px'
218
+ menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : r.bottom + 6) + 'px'
219
+ menu.classList.toggle('up', up)
220
+ }
221
+
222
+ export function closeMenu() {
223
+ if (!open) return
224
+ const { menu, backdrop, host } = open
225
+ open = null
226
+ menu.remove(); if (backdrop) backdrop.remove()
227
+ host.classList.remove('open')
228
+ if (host._anchor) { host._anchor.setAttribute('aria-expanded', 'false'); host._anchor.removeAttribute('aria-activedescendant') }
229
+ }
230
+
231
+ function typeahead(host, ch) {
232
+ host._ta = (Date.now() - (host._taAt || 0) < 700 ? host._ta || '' : '') + ch.toLowerCase()
233
+ host._taAt = Date.now()
234
+ const opts = open ? open.items : host._opts.map((o, i) => ({ ...o, i }))
235
+ const k = opts.findIndex((o) => o.label.toLowerCase().startsWith(host._ta))
236
+ if (k < 0) return
237
+ if (open) setActive(k); else host._choose(opts[k].i)
238
+ }
239
+
240
+ function onKey(host, e) {
241
+ const combo = host instanceof LoCombo
242
+ const isOpen = open && open.host === host
243
+ const n = isOpen ? open.items.length : 0
244
+ switch (e.key) {
245
+ case 'ArrowDown': case 'ArrowUp': {
246
+ e.preventDefault()
247
+ if (!isOpen) { openMenu(host, { all: combo && !host._q() }); if (e.altKey) return; return }
248
+ if (e.altKey && e.key === 'ArrowUp') { if (open.active >= 0) host._choose(open.items[open.active].i); return }
249
+ const d = e.key === 'ArrowDown' ? 1 : -1
250
+ setActive(n ? (open.active < 0 ? (d > 0 ? 0 : n - 1) : (open.active + d + n) % n) : -1)
251
+ return
252
+ }
253
+ case 'Home': case 'End':
254
+ if (!isOpen || (combo && !e.ctrlKey)) return
255
+ e.preventDefault(); setActive(e.key === 'Home' ? 0 : n - 1); return
256
+ case 'PageDown': case 'PageUp':
257
+ if (!isOpen) return
258
+ e.preventDefault(); setActive(Math.max(0, Math.min(n - 1, open.active + (e.key === 'PageDown' ? 8 : -8)))); return
259
+ case 'Enter':
260
+ if (isOpen && open.active >= 0 && open.items[open.active]) { e.preventDefault(); host._choose(open.items[open.active].i); return }
261
+ if (!combo) { e.preventDefault(); if (!isOpen) openMenu(host) }
262
+ return
263
+ case ' ':
264
+ if (combo || (open && e.target === open.filter)) return
265
+ e.preventDefault()
266
+ if (isOpen && open.active >= 0) host._choose(open.items[open.active].i); else if (!isOpen) openMenu(host)
267
+ return
268
+ case 'Escape':
269
+ if (isOpen) { e.preventDefault(); e.stopPropagation(); closeMenu(); host._anchor.focus() }
270
+ return
271
+ case 'Tab':
272
+ if (isOpen) closeMenu()
273
+ return
274
+ default:
275
+ if (!combo && e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !(open && e.target === open.filter)) { e.preventDefault(); typeahead(host, e.key) }
276
+ }
277
+ }
278
+
279
+ if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
280
+ customElements.define('lo-select', LoSelect)
281
+ customElements.define('lo-combo', LoCombo)
282
+ // a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
283
+ document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
284
+ window.addEventListener('resize', () => { if (open) closeMenu() })
285
+ document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place() }, true)
286
+ // <label for="f-tz"> focuses (and on a click, opens) the control, as it would a select
287
+ document.addEventListener('click', (e) => {
288
+ const l = e.target.closest && e.target.closest('label[for]')
289
+ if (!l) return
290
+ const host = document.getElementById(l.getAttribute('for'))
291
+ if (!host || !(host instanceof LoSelect || host instanceof LoCombo)) return
292
+ e.preventDefault(); host.focus()
293
+ })
294
+ }
@@ -0,0 +1,187 @@
1
+ // The board through the hosted relay (v0.6.0, "Use from anywhere").
2
+ //
3
+ // Loaded on every board page; it does nothing unless the relay marked the page
4
+ // (<meta name="leftoff-relay">, added at <user>.leftoff.dev only). Then every
5
+ // same-origin /v1/ call the board makes is sealed with this browser's device
6
+ // key (made at pairing, kept in IndexedDB, not extractable) and sent to one of
7
+ // your Macs through /_relay/api. The relay passes ciphertext. The live-update
8
+ // stream becomes a long-poll over the same tunnel. Which Mac: the one you
9
+ // picked in the status popover, else the most recently active one.
10
+ import { sealRequest, openResponse } from './relay-crypto.js'
11
+
12
+ const meta = typeof document !== 'undefined' && document.querySelector('meta[name="leftoff-relay"]')
13
+ export const RELAY_USER = meta ? meta.getAttribute('content') : null
14
+ const MAC_KEY = 'leftoff.relay.mac'
15
+ const realFetch = typeof window !== 'undefined' ? window.fetch.bind(window) : null
16
+
17
+ // ------------------------------------------------------------ key store
18
+
19
+ function idb() {
20
+ return new Promise((resolve, reject) => {
21
+ const r = indexedDB.open('leftoff-relay', 1)
22
+ r.onupgradeneeded = () => r.result.createObjectStore('keys')
23
+ r.onsuccess = () => resolve(r.result)
24
+ r.onerror = () => reject(r.error)
25
+ })
26
+ }
27
+ export async function saveDeviceKey(rec) {
28
+ const db = await idb()
29
+ await new Promise((resolve, reject) => { const t = db.transaction('keys', 'readwrite'); t.objectStore('keys').put(rec, 'device'); t.oncomplete = resolve; t.onerror = () => reject(t.error) })
30
+ }
31
+ export async function loadDeviceKey() {
32
+ try {
33
+ const db = await idb()
34
+ return await new Promise((resolve) => { const t = db.transaction('keys', 'readonly'); const g = t.objectStore('keys').get('device'); g.onsuccess = () => resolve(g.result || null); g.onerror = () => resolve(null) })
35
+ } catch { return null }
36
+ }
37
+ export async function forgetDeviceKey() {
38
+ try { const db = await idb(); await new Promise((resolve) => { const t = db.transaction('keys', 'readwrite'); t.objectStore('keys').delete('device'); t.oncomplete = resolve; t.onerror = resolve }) } catch {}
39
+ }
40
+
41
+ // ------------------------------------------------------------ state
42
+
43
+ const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
44
+
45
+ function pickedMac() { try { return localStorage.getItem(MAC_KEY) } catch { return null } }
46
+ function remember(mac) {
47
+ try { if (mac) localStorage.setItem(MAC_KEY, mac); else localStorage.removeItem(MAC_KEY) } catch {}
48
+ }
49
+ function cookieMac(mac) { document.cookie = 'lo_mac=' + encodeURIComponent(mac || '') + '; Path=/; SameSite=Lax; Max-Age=' + (mac ? 31536000 : 0) + (location.protocol === 'https:' ? '; Secure' : '') }
50
+
51
+ async function refreshStatus() {
52
+ const t0 = performance.now()
53
+ const res = await realFetch('/_relay/status', { credentials: 'same-origin', cache: 'no-store' })
54
+ if (res.status === 401) { notPaired(); throw new Error('not paired') }
55
+ relay.status = await res.json()
56
+ relay.latencyMs = Math.round(performance.now() - t0)
57
+ const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
58
+ const want = pickedMac()
59
+ relay.mac = want && online.includes(want) ? want : relay.status.default
60
+ cookieMac(relay.mac)
61
+ return relay.status
62
+ }
63
+
64
+ async function init() {
65
+ const rec = await loadDeviceKey()
66
+ if (!rec || !rec.key || (rec.user && rec.user !== RELAY_USER)) { needsKey(); throw new Error('no device key') }
67
+ relay.device = rec.device; relay.key = rec.key
68
+ await refreshStatus()
69
+ if (!relay.mac) { asleep(); throw new Error('asleep') }
70
+ }
71
+
72
+ // ------------------------------------------------------------ the tunnel
73
+
74
+ async function tunnel(url, init = {}, retried = false) {
75
+ await relay.ready
76
+ const method = String(init.method || 'GET').toUpperCase()
77
+ let b = init.body == null ? null : init.body
78
+ if (b != null && typeof b !== 'string') b = await new Response(b).text()
79
+ const h = {}
80
+ const hdrs = new Headers(init.headers || {})
81
+ if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
82
+ const env = await sealRequest(relay.key, { device: relay.device, machine: relay.mac, body: { m: method, p: url.pathname + url.search, b, h } })
83
+ 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
+ }
88
+ const j = await res.clone().json().catch(() => ({}))
89
+ if (res.status === 401) { notPaired(j.code === 'unknown_device'); return res }
90
+ if (res.status === 503 && j.code === 'mac_offline' && !retried) {
91
+ // the Mac you were on went to sleep: another one takes over, or the asleep page
92
+ if (j.default && j.default !== relay.mac) { relay.mac = j.default; cookieMac(relay.mac); window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return tunnel(url, init, true) }
93
+ asleep()
94
+ }
95
+ return res
96
+ }
97
+
98
+ function patchedFetch(input, init) {
99
+ try {
100
+ const url = new URL(typeof input === 'string' ? input : input.url, location.href)
101
+ if (url.origin === location.origin && url.pathname.startsWith('/v1/')) return tunnel(url, typeof input === 'string' ? init : { method: input.method, headers: input.headers, body: init && init.body, ...(init || {}) })
102
+ } catch {}
103
+ return realFetch(input, init)
104
+ }
105
+
106
+ // EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
107
+ class RelayEventSource extends EventTarget {
108
+ constructor(url) {
109
+ super()
110
+ this.url = url; this.readyState = 0; this.onopen = null; this.onerror = null; this.onmessage = null
111
+ this.seq = null; this.ctrl = null
112
+ this.loop()
113
+ }
114
+ async loop() {
115
+ let fails = 0
116
+ while (this.readyState !== 2) {
117
+ try {
118
+ this.ctrl = new AbortController()
119
+ const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=20000', { signal: this.ctrl.signal })
120
+ if (!res.ok) throw new Error('HTTP ' + res.status)
121
+ const j = await res.json()
122
+ if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
123
+ const first = this.seq == null
124
+ this.seq = j.seq
125
+ if (!first) for (const e of j.events || []) {
126
+ const ev = new MessageEvent(e.event, { data: JSON.stringify(e.data) })
127
+ this.dispatchEvent(ev)
128
+ }
129
+ fails = 0
130
+ } catch (e) {
131
+ if (this.readyState === 2) return
132
+ fails++
133
+ if (this.onerror) this.onerror(new Event('error'))
134
+ await new Promise((r) => setTimeout(r, Math.min(15000, 1000 * 2 ** Math.min(fails, 4))))
135
+ }
136
+ }
137
+ }
138
+ close() { this.readyState = 2; if (this.ctrl) this.ctrl.abort() }
139
+ }
140
+
141
+ // ------------------------------------------------------------ screens
142
+
143
+ function screen(title, html) {
144
+ const el = document.createElement('div')
145
+ el.className = 'relay-screen'
146
+ el.setAttribute('role', 'dialog')
147
+ el.innerHTML = '<div class="relay-card"><div class="relay-logo">Leftoff</div><h1>' + title + '</h1>' + html + '</div>'
148
+ const put = () => { document.querySelectorAll('.relay-screen').forEach((x) => x.remove()); document.body.appendChild(el) }
149
+ if (document.body) put(); else document.addEventListener('DOMContentLoaded', put)
150
+ }
151
+ function needsKey() {
152
+ screen('Pair this browser again', '<p>This browser has a pass for ' + esc(RELAY_USER) + '.leftoff.dev but not the key that unlocks your board (it lives only in this browser, and it’s gone: private browsing, cleared website data, or another browser).</p>' +
153
+ '<p>On a Mac: Settings → Phone &amp; remote → <b>Pair a phone</b>, then scan the QR code.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
154
+ }
155
+ function notPaired(revoked) {
156
+ forgetDeviceKey()
157
+ screen('This browser isn’t paired anymore', '<p>' + (revoked ? 'It was revoked on one of your Macs.' : 'It was revoked, or its pass expired.') + ' Pair it again from a Mac: Settings → Phone &amp; remote → <b>Pair a phone</b>.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
158
+ }
159
+ let asleepShown = false
160
+ function asleep() {
161
+ if (asleepShown) return
162
+ asleepShown = true
163
+ // the relay draws the asleep page (with when each Mac was last seen)
164
+ location.replace('/?asleep=' + Date.now())
165
+ }
166
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]))
167
+
168
+ // ------------------------------------------------------------ public
169
+
170
+ export const leftoffRelay = {
171
+ user: RELAY_USER,
172
+ get mac() { return relay.mac },
173
+ get device() { return relay.device },
174
+ get latencyMs() { return relay.latencyMs },
175
+ status: () => refreshStatus(),
176
+ // Switch Macs: the board reloads from the other one.
177
+ pick(mac) { remember(mac); cookieMac(mac); location.reload() },
178
+ }
179
+
180
+ if (RELAY_USER) {
181
+ window.fetch = patchedFetch
182
+ window.EventSource = RelayEventSource
183
+ window.leftoffRelay = leftoffRelay
184
+ relay.ready = init()
185
+ relay.ready.catch(() => {})
186
+ document.documentElement.classList.add('via-relay')
187
+ }
@@ -0,0 +1,155 @@
1
+ // End-to-end encryption between a paired browser and your Macs, for
2
+ // "Use from anywhere" (v0.6.0). The same file runs in the browser (served to
3
+ // the page) and in the daemon (Node's WebCrypto), so both sides agree by
4
+ // construction. Nothing here is secret: the security is in the keys.
5
+ //
6
+ // Pairing (docs/relay.md has the picture):
7
+ // code 16 Crockford base32 characters, XXXX-XXXX-XXXX-XXXX. The first 4
8
+ // are the pairing id the relay routes by; the whole code is the
9
+ // secret, and it reaches the phone only inside the QR code / link
10
+ // fragment (#…), which browsers never send to a server, or typed.
11
+ // browser makes a P-256 key pair, sends { pair, pub, name, mac } where
12
+ // mac = HMAC(code, "leftoff-pair-v1|browser|<pair>|<pub>")
13
+ // Mac checks mac, makes its own P-256 key, answers { device, pub, mac }
14
+ // with mac = HMAC(code, "leftoff-pair-v1|mac|<pair>|<browserPub>|<macPub>|<device>")
15
+ // both K = HKDF-SHA256(ECDH, salt = code, info = "leftoff-relay-device-v1|<device>")
16
+ // The relay carries all of it and learns neither the code nor K: the ECDH
17
+ // private halves never leave the browser and the Mac, and the two MACs stop
18
+ // it from swapping in its own keys while the code is live (10 minutes).
19
+ //
20
+ // Requests: AES-256-GCM under K, a fresh 12-byte IV each, with the device,
21
+ // the target Mac, the request id and the time bound in as additional data,
22
+ // so a request can't be replayed, moved to the other Mac or answered with a
23
+ // different response.
24
+ const C = globalThis.crypto
25
+ const subtle = C.subtle
26
+ const te = new TextEncoder()
27
+ const td = new TextDecoder()
28
+
29
+ export const CODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
30
+ export const PAIR_TTL_MS = 10 * 60 * 1000
31
+ export const REQ_WINDOW_MS = 2 * 60 * 1000
32
+
33
+ export function b64u(bytes) {
34
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
35
+ let s = ''
36
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
37
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
38
+ }
39
+ export function unb64u(s) {
40
+ s = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
41
+ while (s.length % 4) s += '='
42
+ const bin = atob(s)
43
+ const out = new Uint8Array(bin.length)
44
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
45
+ return out
46
+ }
47
+ export function randomBytes(n) { return C.getRandomValues(new Uint8Array(n)) }
48
+ export function randomId(n = 16) { return b64u(randomBytes(n)) }
49
+
50
+ // ------------------------------------------------------------ pairing codes
51
+
52
+ export function newPairCode() {
53
+ const r = randomBytes(16)
54
+ let s = ''
55
+ for (let i = 0; i < 16; i++) s += CODE_ALPHABET[r[i] & 31]
56
+ return s.match(/.{4}/g).join('-')
57
+ }
58
+ // Typed by hand: case, dashes and spaces don't matter; O→0, I/L→1 as Crockford says.
59
+ export function normalizeCode(code) {
60
+ const s = String(code || '').toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
61
+ if (s.length !== 16 || [...s].some((ch) => !CODE_ALPHABET.includes(ch))) return null
62
+ return s.match(/.{4}/g).join('-')
63
+ }
64
+ export function pairIdOf(code) { const n = normalizeCode(code); return n ? n.slice(0, 4) : null }
65
+ // A link's fragment, #XXXX-XXXX-XXXX-XXXX (or #c=…): the code, or null.
66
+ export function codeFromHash(hash) {
67
+ const h = String(hash || '').replace(/^#/, '')
68
+ const m = h.match(/(?:^|[&?])c=([^&]+)/)
69
+ return normalizeCode(decodeURIComponent(m ? m[1] : h))
70
+ }
71
+ export function pairExpired(record, now = Date.now()) { return !record || !(record.expiresAt > now) }
72
+
73
+ async function hmacKey(code) {
74
+ return subtle.importKey('raw', te.encode(normalizeCode(code) || ''), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
75
+ }
76
+ export async function pairMac(code, parts) {
77
+ const k = await hmacKey(code)
78
+ return b64u(await subtle.sign('HMAC', k, te.encode('leftoff-pair-v1|' + parts.join('|'))))
79
+ }
80
+ export async function pairMacOk(code, parts, mac) {
81
+ const k = await hmacKey(code)
82
+ try { return await subtle.verify('HMAC', k, unb64u(mac), te.encode('leftoff-pair-v1|' + parts.join('|'))) } catch { return false }
83
+ }
84
+
85
+ // P-256 because every Safari since 2017 has it.
86
+ export async function newEcdh({ extractable = false } = {}) {
87
+ const kp = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, extractable, ['deriveBits'])
88
+ const pub = b64u(await subtle.exportKey('raw', kp.publicKey))
89
+ return { privateKey: kp.privateKey, pub }
90
+ }
91
+ // K as an AES-GCM key. In the browser it's non-extractable: page code can use
92
+ // it but can't read it out. The Mac asks for the raw bytes to wrap them.
93
+ export async function deriveDeviceKey({ privateKey, peerPub, code, device, extractable = false }) {
94
+ const peer = await subtle.importKey('raw', unb64u(peerPub), { name: 'ECDH', namedCurve: 'P-256' }, false, [])
95
+ const shared = await subtle.deriveBits({ name: 'ECDH', public: peer }, privateKey, 256)
96
+ const ikm = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey', 'deriveBits'])
97
+ const params = { name: 'HKDF', hash: 'SHA-256', salt: te.encode(normalizeCode(code)), info: te.encode('leftoff-relay-device-v1|' + device) }
98
+ if (extractable) {
99
+ const raw = new Uint8Array(await subtle.deriveBits(params, ikm, 256))
100
+ return { raw, key: await importDeviceKey(raw) }
101
+ }
102
+ return { key: await subtle.deriveKey(params, ikm, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) }
103
+ }
104
+ export function importDeviceKey(raw) {
105
+ return subtle.importKey('raw', raw instanceof Uint8Array ? raw : unb64u(raw), { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
106
+ }
107
+
108
+ // ------------------------------------------------------------ envelopes
109
+
110
+ const reqAad = (e) => te.encode(['leftoff-tunnel-v1', 'req', e.d, e.to, e.id, e.ts].join('|'))
111
+ const resAad = (e, req) => te.encode(['leftoff-tunnel-v1', 'res', req.d, req.to, req.id].join('|'))
112
+
113
+ // body: { m: method, p: path with query, b: body text or null, h: { content-type } }
114
+ export async function sealRequest(key, { device, machine, body, now = Date.now(), id = randomId(16) }) {
115
+ const env = { v: 1, d: device, to: machine, id, ts: now }
116
+ const iv = randomBytes(12)
117
+ const ct = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: reqAad(env) }, key, te.encode(JSON.stringify(body)))
118
+ return { ...env, iv: b64u(iv), ct: b64u(ct) }
119
+ }
120
+
121
+ export class TunnelError extends Error { constructor(code, message) { super(message || code); this.code = code } }
122
+
123
+ // The Mac's side. keyFor(device) -> CryptoKey or null (unknown or revoked).
124
+ // seen: a ReplayCache (or anything with has/add).
125
+ export async function openRequest(env, { keyFor, machine, seen, now = Date.now() }) {
126
+ if (!env || env.v !== 1 || typeof env.d !== 'string' || typeof env.id !== 'string' || typeof env.iv !== 'string' || typeof env.ct !== 'string' || typeof env.ts !== 'number') throw new TunnelError('bad_envelope')
127
+ if (env.to !== machine) throw new TunnelError('wrong_target', 'sealed for ' + env.to)
128
+ if (Math.abs(now - env.ts) > REQ_WINDOW_MS) throw new TunnelError('expired', 'the request is too old (or a clock is off)')
129
+ const key = await keyFor(env.d)
130
+ if (!key) throw new TunnelError('unknown_device', 'this browser isn’t paired with this Mac, or it was revoked')
131
+ if (seen && seen.has(env.d + '|' + env.id)) throw new TunnelError('replayed')
132
+ let body
133
+ try { body = JSON.parse(td.decode(await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(env.iv), additionalData: reqAad(env) }, key, unb64u(env.ct)))) } catch { throw new TunnelError('decrypt_failed') }
134
+ if (seen) seen.add(env.d + '|' + env.id, env.ts + REQ_WINDOW_MS)
135
+ return body
136
+ }
137
+
138
+ // body: { s: status, h: { content-type }, b: text }
139
+ export async function sealResponse(key, req, body) {
140
+ const iv = randomBytes(12)
141
+ const ct = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: resAad(null, req) }, key, te.encode(JSON.stringify(body)))
142
+ return { v: 1, re: req.id, iv: b64u(iv), ct: b64u(ct) }
143
+ }
144
+ export async function openResponse(key, req, env) {
145
+ if (!env || env.re !== req.id) throw new TunnelError('bad_response')
146
+ try { return JSON.parse(td.decode(await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(env.iv), additionalData: resAad(null, req) }, key, unb64u(env.ct)))) } catch { throw new TunnelError('decrypt_failed') }
147
+ }
148
+
149
+ // Remembers request ids until they can no longer pass the time check.
150
+ export class ReplayCache {
151
+ constructor() { this.m = new Map() }
152
+ has(k) { this.sweep(); return this.m.has(k) }
153
+ add(k, until) { this.m.set(k, until) }
154
+ sweep(now = Date.now()) { if (this.m.size < 500) return; for (const [k, t] of this.m) if (t < now) this.m.delete(k) }
155
+ }