@leftoff/cli 0.9.1 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/bin/leftoff.js +270 -270
- package/docs/SECURITY.md +4 -0
- package/docs/peer-protocol.md +3 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +7 -0
- package/mod/hooks/register.js +4 -2
- package/package.json +1 -1
- package/ui/app.css +58 -0
- package/ui/app.js +135 -29
- package/ui/direct.js +114 -0
- package/ui/icons.js +3 -0
- package/ui/picker.js +85 -10
- package/ui/relay-client.js +105 -10
package/docs/SECURITY.md
CHANGED
|
@@ -89,6 +89,10 @@ None of this touches the board's data: everything under [What the relay can see]
|
|
|
89
89
|
|
|
90
90
|
A relayed request is replayed against the Mac's own board from inside the daemon (proved with a secret that never leaves memory) and counts as a **remote device**, with the same rules as a phone over Tailscale: it can read the board; answering prompts and pairing new devices need Settings → Privacy & security → *Trust my paired devices* on that Mac (`remote.allowAnswers`; 403 `trust_off` while it's off); revoking a paired device works from any paired device, itself included, since it only cuts access; Go, moving the folder, Syncthing, turning the relay on or off and the trust switch itself are refused (this Mac's business, loopback only). The daemon still listens only on 127.0.0.1: the relay connection is outbound.
|
|
91
91
|
|
|
92
|
+
### Direct when on the same Mac (0.9.3)
|
|
93
|
+
|
|
94
|
+
When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
|
|
95
|
+
|
|
92
96
|
### Logging
|
|
93
97
|
|
|
94
98
|
The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
|
package/docs/peer-protocol.md
CHANGED
|
@@ -55,6 +55,9 @@ Each command is one envelope. It's JSON and travels the same way over every tran
|
|
|
55
55
|
| `ask.answer` | `askId`, `body` | `engine.answerAsk()`: the board's Allow / Deny / answer code path |
|
|
56
56
|
| `session.reply` | `sessionId`, `text` | `engine.postReply()`: the board's reply box |
|
|
57
57
|
| `session.go` | `sessionId` | `engine.jump()`: the board's Go |
|
|
58
|
+
| `session.checkpoint` | `sessionId`, `length?`, `userNote?`, `next?` | `engine.generateCheckpoint()`: Checkpoint now (0.9.2); returns the checkpoint, which the asking board lists at once |
|
|
59
|
+
| `session.patch` | `sessionId`, `title?`, `pinned?`, `archived?` | `engine.patchSession()`: rename, Pin, Archive (0.9.2) |
|
|
60
|
+
| `session.title.auto` | `sessionId` | `engine.regenerateTitle()`: Regenerate title (0.9.2) |
|
|
58
61
|
|
|
59
62
|
Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
|
|
60
63
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -1354,6 +1354,13 @@ export function parseCheckpointReply(text) {
|
|
|
1354
1354
|
return { ...out, ...parseSections(rest) }
|
|
1355
1355
|
}
|
|
1356
1356
|
|
|
1357
|
+
// 0.9.2: a checkpoint written with nothing to go on: no prompt and no reply were
|
|
1358
|
+
// found (the heuristic's placeholder lines). Never saved; old ones are set aside.
|
|
1359
|
+
export function isEmptyCheckpoint(cp) {
|
|
1360
|
+
return !!cp && /^No prompt captured yet\.?$/.test(String(cp.doing || '').trim()) && /^No reply captured yet\.?$/.test(String(cp.stoppedAt || '').trim())
|
|
1361
|
+
}
|
|
1362
|
+
export const NOTHING_TO_CHECKPOINT = 'Nothing to checkpoint yet — no messages found for this session'
|
|
1363
|
+
|
|
1357
1364
|
// No model available: build a checkpoint from the last prompt and the last reply.
|
|
1358
1365
|
// Informative / long add what can be read off the transcript without a model:
|
|
1359
1366
|
// the files edited (with the tool), the failed tool calls, the questions asked.
|
package/mod/hooks/register.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// each event is registered once per matcher.
|
|
15
15
|
import {
|
|
16
16
|
DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
|
|
17
|
-
parseCheckpointReply, heuristicCheckpoint, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
17
|
+
parseCheckpointReply, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
18
18
|
sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
@@ -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.
|
|
31
|
+
const MOD_VERSION = '0.9.4' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
|
@@ -652,6 +652,8 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
652
652
|
parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
|
|
653
653
|
generator = { by: 'heuristic' }
|
|
654
654
|
}
|
|
655
|
+
// 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
|
|
656
|
+
if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
|
|
655
657
|
if (opts.next) parsed = { ...parsed, next: opts.next }
|
|
656
658
|
const cp = {
|
|
657
659
|
sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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
|
@@ -1775,3 +1775,61 @@ html.dv-open .lo-menu { z-index: 62; }
|
|
|
1775
1775
|
.pr-more-sess > summary { list-style: none; cursor: pointer; display: inline; }
|
|
1776
1776
|
.pr-more-sess > summary::-webkit-details-marker { display: none; }
|
|
1777
1777
|
.pr-more-sess[open] > summary { display: none; }
|
|
1778
|
+
|
|
1779
|
+
/* 0.9.2: the ⋯ menu's sub lines, its plain note (a Mac that can't be reached),
|
|
1780
|
+
the row of other checkpoint lengths, and the split "Checkpoint now" button. */
|
|
1781
|
+
.lo-act.has-sub { align-items: flex-start; white-space: normal; }
|
|
1782
|
+
.lo-act.has-sub .ic { margin-top: 2px; }
|
|
1783
|
+
.lo-act-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
1784
|
+
.lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
|
|
1785
|
+
.lo-act-note { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); color: var(--text2); font-size: 13.5px; line-height: 1.4; white-space: normal; }
|
|
1786
|
+
.lo-act-note .ic { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--muted); }
|
|
1787
|
+
.lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
|
|
1788
|
+
.lo-menu.sheet .lo-act-sub { font-size: 14px; }
|
|
1789
|
+
/* 0.9.4: a row with a second page (Checkpoint now ›), the iOS way: the row
|
|
1790
|
+
does its action, the › at its right edge (its own target, a hairline before
|
|
1791
|
+
it) slides the menu to the page; ‹ title goes back. Fixed width on a
|
|
1792
|
+
desktop so the page change only eases the height. */
|
|
1793
|
+
.lo-actmenu.has-pages { overflow-x: hidden; }
|
|
1794
|
+
.lo-menu.lo-actmenu.has-pages:not(.sheet) { width: min(312px, calc(100vw - 16px)); max-width: none; }
|
|
1795
|
+
.lo-act-split { display: flex; align-items: stretch; position: relative; }
|
|
1796
|
+
.lo-act-split .lo-act-main { flex: 1 1 auto; min-width: 0; }
|
|
1797
|
+
.lo-act-next { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; margin-left: 2px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; position: relative; }
|
|
1798
|
+
.lo-act-next::before { content: ""; position: absolute; left: -1px; top: 9px; bottom: 9px; width: 1px; background: var(--line); }
|
|
1799
|
+
.lo-act-next .ic { width: 16px; height: 16px; }
|
|
1800
|
+
.lo-act-next:hover, .lo-act-next:focus { outline: none; background: var(--bg2); color: var(--text); }
|
|
1801
|
+
[data-theme="dark"] .lo-act-next:hover, [data-theme="dark"] .lo-act-next:focus, :root:not([data-theme="light"]) .lo-act-next:hover, :root:not([data-theme="light"]) .lo-act-next:focus { background: var(--line); }
|
|
1802
|
+
.lo-act-next:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
|
|
1803
|
+
.lo-act-split:hover .lo-act-next::before, .lo-act-split:focus-within .lo-act-next::before { opacity: 0; }
|
|
1804
|
+
.lo-act-back { color: var(--text); font-weight: 600; border-bottom: 1px solid var(--line); border-radius: 8px 8px 0 0; margin-bottom: 4px; }
|
|
1805
|
+
.lo-act-back .ic { color: var(--accent); }
|
|
1806
|
+
.lo-act-page .lo-act.has-sub { padding-top: 9px; padding-bottom: 9px; }
|
|
1807
|
+
.lo-act-page .lo-act.on { font-weight: 600; }
|
|
1808
|
+
.lo-act .lo-act-check { color: var(--accent); }
|
|
1809
|
+
.lo-act .lo-act-sub { white-space: normal; }
|
|
1810
|
+
.lo-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
1811
|
+
.lo-menu.lo-act-sizing { transition: height .2s cubic-bezier(.2, .8, .2, 1), top .2s cubic-bezier(.2, .8, .2, 1); overflow-y: hidden; }
|
|
1812
|
+
@keyframes lo-page-fwd { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: none; } }
|
|
1813
|
+
@keyframes lo-page-back { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
|
|
1814
|
+
.lo-act-page.lo-in-fwd { animation: lo-page-fwd .2s cubic-bezier(.2, .8, .2, 1); }
|
|
1815
|
+
.lo-act-page.lo-in-back { animation: lo-page-back .2s cubic-bezier(.2, .8, .2, 1); }
|
|
1816
|
+
.lo-menu.sheet .lo-act-next { width: 52px; min-height: 44px; }
|
|
1817
|
+
.lo-menu.sheet .lo-act-next .ic { width: 20px; height: 20px; }
|
|
1818
|
+
.lo-menu.sheet .lo-act-next::before { top: 12px; bottom: 12px; }
|
|
1819
|
+
.lo-menu.sheet .lo-act-page .lo-act.has-sub { padding-top: 12px; padding-bottom: 12px; }
|
|
1820
|
+
/* a phone keeps :hover on the last row tapped; only focus highlights there */
|
|
1821
|
+
@media (hover: none) { :root .lo-act:hover:not(:focus), :root .lo-act-next:hover:not(:focus) { background: none; } }
|
|
1822
|
+
@media (prefers-reduced-motion: reduce) { .lo-act-page.lo-in-fwd, .lo-act-page.lo-in-back { animation: none; } .lo-menu.lo-act-sizing { transition: none; } }
|
|
1823
|
+
.split { display: inline-flex; align-items: stretch; }
|
|
1824
|
+
.split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
|
1825
|
+
.split .btn.split-more { border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0 8px; margin-left: 1px; display: inline-flex; align-items: center; }
|
|
1826
|
+
.split .btn.split-more .ic { width: 15px; height: 15px; margin: 0; }
|
|
1827
|
+
@media (pointer: coarse) { .split .btn.split-more { min-width: 40px; justify-content: center; } }
|
|
1828
|
+
.more-wrap { display: inline-flex; }
|
|
1829
|
+
/* 0.9.2: the popover's Theme control never wraps: it gets its own row under
|
|
1830
|
+
the label, three equal segments, whatever the popover's width. */
|
|
1831
|
+
.statuspop .sp-theme { flex-wrap: wrap; row-gap: 6px; }
|
|
1832
|
+
.statuspop .sp-theme .sp-k { width: 100%; }
|
|
1833
|
+
.statuspop .sp-theme .theme { flex: 1 1 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap; }
|
|
1834
|
+
.statuspop .sp-theme .theme button { justify-content: center; white-space: nowrap; min-width: 0; height: 30px; padding: 0 6px; }
|
|
1835
|
+
@media (pointer: coarse) { .statuspop .sp-theme .theme button { height: 36px; font-size: 13px; } }
|
package/ui/app.js
CHANGED
|
@@ -370,11 +370,12 @@ function goButton(s) {
|
|
|
370
370
|
// Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
|
|
371
371
|
if (!s.isLocal) {
|
|
372
372
|
if (!j.peer) return ''
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (
|
|
373
|
+
// 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
|
|
374
|
+
const label = 'Go ↗', on = 'Go on ' + s.machine
|
|
375
|
+
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
|
|
376
|
+
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
|
|
376
377
|
const st = goState.get(s.id)
|
|
377
|
-
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
|
|
378
|
+
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
|
|
378
379
|
}
|
|
379
380
|
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
|
|
380
381
|
if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
|
|
@@ -419,35 +420,108 @@ function copyResumeIcon(s) {
|
|
|
419
420
|
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
420
421
|
}
|
|
421
422
|
// items: [{ action, label, icon, title, attrs }]
|
|
422
|
-
|
|
423
|
-
|
|
423
|
+
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
424
|
+
// 0.9.4: or { …item, more: { title, items } }: the row does its action, and a ›
|
|
425
|
+
// at its right edge opens a second page of items (e.g. the checkpoint lengths,
|
|
426
|
+
// each with { sub, on }).
|
|
427
|
+
function moreMenu(s, items, { project = true, title = '' } = {}) {
|
|
428
|
+
items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
|
|
424
429
|
if (!items.length) return ''
|
|
425
|
-
|
|
426
|
-
'
|
|
427
|
-
|
|
428
|
-
|
|
430
|
+
const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
|
|
431
|
+
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + (it.on ? ' data-on="' + esc(it.onLabel || 'on') + '"' : '') + '>' +
|
|
432
|
+
(it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
|
|
433
|
+
return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
|
|
434
|
+
'<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
|
|
435
|
+
: it.more ? '<div class="more-split" data-more-title="' + esc(it.more.title) + '">' + btn(it) + '<div class="more-sub">' + it.more.items.map(btn).join('') + '</div></div>' : btn(it)).join('') + '</template>'
|
|
429
436
|
}
|
|
430
437
|
function openCardMenu(btn) {
|
|
431
438
|
const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
|
|
432
439
|
if (!tpl) return
|
|
433
440
|
const card = btn.closest('.card')
|
|
434
|
-
const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
|
|
435
|
-
const
|
|
441
|
+
const title = btn.dataset.menuTitle || (card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : '')
|
|
442
|
+
const toItem = (b) => {
|
|
436
443
|
const svg = b.querySelector('svg')
|
|
437
444
|
const attrs = {}
|
|
438
|
-
for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
|
|
439
|
-
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
|
|
440
|
-
}
|
|
445
|
+
for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub' && a.name !== 'data-on') || a.name === 'title') attrs[a.name] = a.value
|
|
446
|
+
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}), ...(b.dataset.on ? { on: true, onLabel: b.dataset.on } : {}) }
|
|
447
|
+
}
|
|
448
|
+
const items = [...tpl.content.children].map((el) => {
|
|
449
|
+
if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
|
|
450
|
+
if (el.classList.contains('more-split')) return { ...toItem(el.querySelector(':scope > button')), more: { title: el.dataset.moreTitle || '', items: [...el.querySelectorAll('.more-sub > button')].map(toItem) } }
|
|
451
|
+
return el.tagName === 'BUTTON' ? toItem(el) : null
|
|
452
|
+
}).filter(Boolean)
|
|
441
453
|
lastMenuAnchor = btn
|
|
442
454
|
openActionMenu(btn, items, { title })
|
|
443
455
|
}
|
|
444
|
-
|
|
445
|
-
|
|
456
|
+
// 0.9.2: what this board can do with a session: 'local'; 'peer', through the
|
|
457
|
+
// Mac it lives on (the same path as answering and Go); 'away', when that Mac
|
|
458
|
+
// can't be reached from here. Agent records are read-only (null).
|
|
459
|
+
function reachOf(s) {
|
|
460
|
+
if (!s || s.agent || /^agent:/.test(String(s.id || ''))) return null
|
|
461
|
+
if (s.isLocal) return 'local'
|
|
462
|
+
const p = s.peer || {}
|
|
463
|
+
return p.ok || p.code === 'checking' ? 'peer' : 'away'
|
|
464
|
+
}
|
|
465
|
+
// The length "Checkpoint now" writes: yours from Settings → Summaries.
|
|
466
|
+
function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
|
|
467
|
+
const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
|
|
468
|
+
// 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
|
|
469
|
+
// Informative / Long, each with its line, your default ticked.
|
|
470
|
+
function checkpointItems() {
|
|
471
|
+
const def = defaultLength()
|
|
472
|
+
return [
|
|
473
|
+
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', title: 'Checkpoint now · ' + lengthLabel(def), attrs: { 'data-length': def },
|
|
474
|
+
more: { title: 'Checkpoint length', items: LENGTH_CHOICES.map((o) => ({ action: 'checkpoint', label: o.label, sub: o.blurb, on: o.key === def, onLabel: 'your default', attrs: { 'data-length': o.key } })) } },
|
|
475
|
+
]
|
|
476
|
+
}
|
|
477
|
+
// The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
|
|
478
|
+
// words, and what you can do (switch the board to that Mac, or check again).
|
|
479
|
+
function awayNote(s) {
|
|
480
|
+
const m = s.machine || 'another Mac', c = s.peer && s.peer.code
|
|
481
|
+
return c === 'not_allowed' ? 'Lives on ' + m + ', which doesn’t take actions from other Macs (Settings → Privacy & security on ' + m + ')'
|
|
482
|
+
: c === 'key_changed' ? 'Lives on ' + m + ', whose key changed; confirm it in Settings → Privacy & security'
|
|
483
|
+
: c === 'no_key' ? 'Lives on ' + m + ', which needs a newer Leftoff for this'
|
|
484
|
+
: c === 'off' ? 'Lives on ' + m + '; actions on other Macs are off here'
|
|
485
|
+
: asleepOnRelay(m) ? 'Lives on ' + m + ', which is asleep right now'
|
|
486
|
+
: 'Lives on ' + m + ', which isn’t reachable right now'
|
|
487
|
+
}
|
|
488
|
+
function asleepOnRelay(m) { const st = window.leftoffRelay && state.relayStatus; const x = st && (st.macs || []).find((y) => y.machine === m); return !!(x && !x.online) }
|
|
489
|
+
function awayMenuItems(s) {
|
|
490
|
+
const m = s.machine
|
|
491
|
+
const canSwitch = !!window.leftoffRelay && m && m !== window.leftoffRelay.mac && !asleepOnRelay(m)
|
|
492
|
+
return [
|
|
493
|
+
{ note: awayNote(s), icon: 'wifi-off' },
|
|
494
|
+
canSwitch ? { action: 'relay-mac', label: 'Switch to ' + m + '’s board', icon: 'monitor', attrs: { 'data-mac': m } } : null,
|
|
495
|
+
{ action: 'peer-recheck', label: 'Check again', icon: 'refresh-cw', attrs: { 'data-machine': m || '' } },
|
|
496
|
+
].filter(Boolean)
|
|
497
|
+
}
|
|
498
|
+
const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' || r === 'peer' ? [
|
|
499
|
+
...checkpointItems(),
|
|
446
500
|
{ action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
|
|
447
501
|
{ action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', title: 'A new title from the latest messages (like /cp-name --auto)' },
|
|
448
502
|
{ action: 'archive', label: 'Archive', icon: 'archive' },
|
|
449
|
-
] : []
|
|
450
|
-
|
|
503
|
+
] : r === 'away' ? awayMenuItems(s) : [] }
|
|
504
|
+
// 0.9.2: "Checkpoint now" on a session's page: the main part writes your
|
|
505
|
+
// length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
|
|
506
|
+
function checkpointSplit(s) {
|
|
507
|
+
const def = defaultLength()
|
|
508
|
+
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
509
|
+
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
|
|
510
|
+
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
511
|
+
}
|
|
512
|
+
function openLengthMenu(btn, id) {
|
|
513
|
+
const def = defaultLength()
|
|
514
|
+
lastMenuAnchor = btn
|
|
515
|
+
openActionMenu(btn, LENGTH_CHOICES.map((o) => ({ label: o.label + (o.key === def ? ' · your default' : ''), sub: o.blurb, icon: o.key === def ? icon('check') : icon('bookmark-plus'), cls: o.key === def ? 'on' : '',
|
|
516
|
+
attrs: { 'data-action': 'checkpoint', 'data-id': id, 'data-length': o.key } })), { title: 'Checkpoint now' })
|
|
517
|
+
}
|
|
518
|
+
const roNote = (t, tip) => '<span class="ro" title="' + esc(tip || t) + '">' + esc(t) + '</span>'
|
|
519
|
+
// 0.9.2: a short line on another Mac's card (the full story is its title and the ⋯ menu)
|
|
520
|
+
function remoteNote(s) {
|
|
521
|
+
if (reachOf(s) === 'peer') return roNote('via ' + s.machine, 'Lives on ' + s.machine + ' · checkpoints, answering and Go work through it')
|
|
522
|
+
if (/class="go-off"/.test(goButton(s))) return '' // the grey Go already says why
|
|
523
|
+
return roNote(s.machine + ' unreachable', awayNote(s) + ' · read-only here')
|
|
524
|
+
}
|
|
451
525
|
|
|
452
526
|
function card(s) {
|
|
453
527
|
if (s.agent) return agentCard(s)
|
|
@@ -465,7 +539,7 @@ function card(s) {
|
|
|
465
539
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
466
540
|
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
467
541
|
copyResumeIcon(s) +
|
|
468
|
-
(s.isLocal ?
|
|
542
|
+
(s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
|
|
469
543
|
'</div></article>'
|
|
470
544
|
}
|
|
471
545
|
|
|
@@ -1102,11 +1176,11 @@ async function viewSession(id) {
|
|
|
1102
1176
|
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1103
1177
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1104
1178
|
// 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
|
|
1105
|
-
const canRename = s
|
|
1179
|
+
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1106
1180
|
html += '<div class="pagehead"><div>' + (canRename
|
|
1107
1181
|
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1108
1182
|
: '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
1109
|
-
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s
|
|
1183
|
+
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1110
1184
|
html += '<div class="facts">' +
|
|
1111
1185
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1112
1186
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
@@ -1135,8 +1209,10 @@ async function viewSession(id) {
|
|
|
1135
1209
|
if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
|
|
1136
1210
|
else html += '<div class="timeline">' + cpTimeline(s.checkpoints.filter((cp) => !cp.doubt), newestCp) + '</div>'
|
|
1137
1211
|
// 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
|
|
1138
|
-
const doubted = s.checkpoints.filter((cp) => cp.doubt)
|
|
1212
|
+
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
|
|
1139
1213
|
if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
1214
|
+
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
1215
|
+
if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
|
|
1140
1216
|
html += answeredList(s)
|
|
1141
1217
|
$app.innerHTML = html
|
|
1142
1218
|
}
|
|
@@ -2960,19 +3036,27 @@ function paintStatus() {
|
|
|
2960
3036
|
const pref = window.cpTheme ? window.cpTheme.pref() : 'system'
|
|
2961
3037
|
const row = (k, v, cls) => '<div class="sp-row"><span class="sp-k">' + esc(k) + '</span><span class="sp-v' + (cls ? ' ' + cls : '') + '">' + v + '</span></div>'
|
|
2962
3038
|
const syncWarn = !!settingsDots().sync
|
|
2963
|
-
pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint">' + (h ? (
|
|
3039
|
+
pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint sp-route">' + (h ? routeLabel() : 'offline') + '</span></div>' +
|
|
2964
3040
|
(h ? row('Sessions', esc(h.sessionsOpen + ' open') + ' <span class="faint">of ' + esc(h.sessionsTotal) + '</span>') +
|
|
2965
3041
|
(h.needsYou ? row('Needs you', '<a href="#/waiting">' + esc(h.needsYou) + ' waiting</a>') : '') +
|
|
2966
3042
|
row('Summaries', esc(h.summarizer + (h.summarizerDetail && h.summarizerDetail.model ? ' · ' + h.summarizerDetail.model : '')) + ' <a href="#/settings/models">Change</a>') +
|
|
2967
3043
|
relayRows(row) +
|
|
2968
3044
|
((h.machines || []).length > 1 && !window.leftoffRelay ? row('Macs', esc(h.machines.join(', '))) : '') +
|
|
2969
3045
|
row('Sync', '<a href="#/settings/sync">' + esc(syncLine()) + '</a>', syncWarn ? 'warnish' : '') +
|
|
2970
|
-
row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : '
|
|
3046
|
+
row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : '')) // 0.9.2: What’s new is the button below
|
|
2971
3047
|
: '<p class="faint">The daemon isn’t answering.</p>') +
|
|
2972
3048
|
'<div class="sp-row sp-theme"><span class="sp-k">Theme</span><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div></div>' +
|
|
2973
3049
|
'<div class="sp-links"><a href="#/settings">' + icon('settings') + 'Settings</a><a href="#/whats-new">' + icon('sparkles') + 'What’s new</a></div>'
|
|
2974
3050
|
placeStatus()
|
|
2975
3051
|
}
|
|
3052
|
+
// 0.9.3: the quiet marker for how this board reaches its Mac. On your hosted
|
|
3053
|
+
// board: "direct · this Mac" (its own daemon over 127.0.0.1) or "via relay".
|
|
3054
|
+
function routeLabel() {
|
|
3055
|
+
const lr = window.leftoffRelay
|
|
3056
|
+
if (!lr) return 'this Mac'
|
|
3057
|
+
return lr.route === 'direct' ? 'direct · this Mac' : 'via relay'
|
|
3058
|
+
}
|
|
3059
|
+
window.addEventListener('leftoff-route', () => { if (statusOpen()) paintStatus() })
|
|
2976
3060
|
// Through the relay (v0.6.0): which Mac this board comes from, and a switcher.
|
|
2977
3061
|
function relayRows(row) {
|
|
2978
3062
|
const lr = window.leftoffRelay
|
|
@@ -3055,7 +3139,11 @@ function paletteActions() {
|
|
|
3055
3139
|
const sid = state.route && state.route.name === 's' ? state.route.arg : null
|
|
3056
3140
|
const s = sid ? (state.sessions || []).find((x) => x.id === sid) : null
|
|
3057
3141
|
if (s) {
|
|
3058
|
-
if (s
|
|
3142
|
+
if (reachOf(s) === 'local' || reachOf(s) === 'peer') { // 0.9.2: any length, and through the Mac it lives on
|
|
3143
|
+
const def = defaultLength()
|
|
3144
|
+
out.push({ action: 'checkpoint', id: s.id, length: def, title: 'Checkpoint now', sub: (s.title || 'this session') + ' · ' + lengthLabel(def), icon: 'bookmark-plus' })
|
|
3145
|
+
for (const o of LENGTH_CHOICES) if (o.key !== def) out.push({ action: 'checkpoint', id: s.id, length: o.key, title: 'Checkpoint now (' + o.label.toLowerCase() + ')', sub: o.blurb, icon: 'bookmark-plus' })
|
|
3146
|
+
}
|
|
3059
3147
|
if (!s.agent) out.push({ action: 'resume', id: s.id, title: 'Copy resume command', sub: s.title || 'this session', icon: 'copy' })
|
|
3060
3148
|
if (canProject(s)) out.push({ action: 'project-menu', key: s.repoKey, repo: s.repoLabel || '', title: s.repoProject ? 'Change project…' : 'Add to project…', sub: s.repoLabel || '', icon: 'folder' })
|
|
3061
3149
|
}
|
|
@@ -3097,6 +3185,7 @@ function runPaletteItem(it) {
|
|
|
3097
3185
|
if (it.id) b.dataset.id = it.id
|
|
3098
3186
|
if (it.key) b.dataset.key = it.key
|
|
3099
3187
|
if (it.repo) b.dataset.repo = it.repo
|
|
3188
|
+
if (it.length) b.dataset.length = it.length
|
|
3100
3189
|
b.className = 'palette-proxy'
|
|
3101
3190
|
const anchor = document.getElementById('searchBtn')
|
|
3102
3191
|
if (anchor) { lastMenuAnchor = anchor }
|
|
@@ -3542,10 +3631,27 @@ document.addEventListener('click', async (ev) => {
|
|
|
3542
3631
|
const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
|
|
3543
3632
|
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3544
3633
|
} else if (action === 'checkpoint') {
|
|
3634
|
+
// 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
|
|
3635
|
+
const length = btn.dataset.length || null
|
|
3636
|
+
const away = s && !s.isLocal ? s.machine : null
|
|
3637
|
+
const was = btn.innerHTML
|
|
3545
3638
|
btn.disabled = true; btn.textContent = 'Writing…'
|
|
3546
|
-
if (!btn.isConnected || btn.classList.contains('palette-proxy')) toast('Writing a checkpoint…',
|
|
3547
|
-
|
|
3548
|
-
|
|
3639
|
+
if (!btn.isConnected || btn.classList.contains('palette-proxy') || !btn.closest('.split')) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000) // from a menu (closed) or ⌘K
|
|
3640
|
+
let cp
|
|
3641
|
+
try { cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }) }
|
|
3642
|
+
catch (e) { toast(e.message, 6000); if (btn.isConnected) { btn.disabled = false; btn.innerHTML = was } return }
|
|
3643
|
+
toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + cp.next)
|
|
3644
|
+
render()
|
|
3645
|
+
} else if (action === 'cp-lengths') {
|
|
3646
|
+
ev.preventDefault(); return openLengthMenu(btn, id)
|
|
3647
|
+
} else if (action === 'peer-recheck') {
|
|
3648
|
+
const m = btn.dataset.machine
|
|
3649
|
+
toast('Checking ' + (m || 'the other Mac') + '…', 8000)
|
|
3650
|
+
try {
|
|
3651
|
+
const o = await api('POST', '/v1/peers/check', {})
|
|
3652
|
+
const p = (o.peers || []).find((x) => x.name === m || x.machine === m)
|
|
3653
|
+
toast(p && p.usable && p.usable.ok ? m + ' is reachable again' : 'Still can’t reach ' + (m || 'it') + (p && p.usable && p.usable.reason ? ': ' + p.usable.reason : ''), 6000)
|
|
3654
|
+
} catch (e) { toast(e.message, 5000) }
|
|
3549
3655
|
render()
|
|
3550
3656
|
} else if (action === 'pin') {
|
|
3551
3657
|
await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { pinned: !(s && s.pinned) }); render()
|
package/ui/direct.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
|
|
2
|
+
// open on a Mac that runs Leftoff asks that Mac's own daemon over
|
|
3
|
+
// http://127.0.0.1:7429 instead of through the relay; the address bar stays.
|
|
4
|
+
//
|
|
5
|
+
// The request is the very envelope the relay would carry (ui/relay-crypto.js),
|
|
6
|
+
// posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
|
|
7
|
+
// direct needs no new trust: the daemon opens it with this browser's paired
|
|
8
|
+
// key, and the board only believes an answer that opens with the same key.
|
|
9
|
+
// Before it switches, the board checks: the daemon says it is on this account
|
|
10
|
+
// (user) and a Mac you didn't steer away from (picked), and a sealed round
|
|
11
|
+
// trip through it works (verify). A stranger's Leftoff on the port fails all
|
|
12
|
+
// of it, and the browser refuses it anyway (CORS is for this Mac's own name).
|
|
13
|
+
//
|
|
14
|
+
// When anything about the direct path fails (the daemon stopped, Safari
|
|
15
|
+
// refusing http://127.0.0.1 from an https page, a Local Network Access
|
|
16
|
+
// "Block"), the call goes again through the relay once, quietly, and the board
|
|
17
|
+
// stays on the relay until a later probe (every minute, or when the tab comes
|
|
18
|
+
// back) finds the direct path again. Plain logic, no DOM: unit tested.
|
|
19
|
+
import { LOCAL_PORT, isPhoneUa } from './viewer.js'
|
|
20
|
+
|
|
21
|
+
export const DIRECT_PATH = '/v1/relay/direct'
|
|
22
|
+
export const PROBE_EVERY_MS = 60000
|
|
23
|
+
export const FOCUS_GAP_MS = 10000
|
|
24
|
+
export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
|
|
25
|
+
|
|
26
|
+
export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
|
|
27
|
+
|
|
28
|
+
// What the local daemon said -> its machine when it may serve this board, else null.
|
|
29
|
+
export function directMatch(info, { user, picked } = {}) {
|
|
30
|
+
if (!info || info.app !== 'leftoff' || !info.direct || typeof info.machine !== 'string' || !info.machine) return null
|
|
31
|
+
if (!user || String(info.user || '').toLowerCase() !== String(user).toLowerCase()) return null
|
|
32
|
+
if (picked && picked !== info.machine) return null
|
|
33
|
+
return info.machine
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A sealed request's body may only be read once; a fallback needs it again.
|
|
37
|
+
export function aborted(signal) { return !!(signal && signal.aborted) }
|
|
38
|
+
|
|
39
|
+
export class DirectRoute {
|
|
40
|
+
constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
|
|
41
|
+
this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
|
|
42
|
+
// a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
|
|
43
|
+
this.never = isPhoneUa(ua, { maxTouchPoints })
|
|
44
|
+
this.every = every; this.focusGap = focusGap
|
|
45
|
+
this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
|
|
46
|
+
}
|
|
47
|
+
get url() { return directUrl(this.port) }
|
|
48
|
+
|
|
49
|
+
// -> the machine (direct confirmed, not yet switched on) or null. Never throws.
|
|
50
|
+
probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
|
|
51
|
+
if (this.never) return Promise.resolve(null)
|
|
52
|
+
if (this.inflight) return this.inflight
|
|
53
|
+
this.inflight = (async () => {
|
|
54
|
+
this.checkedAt = this.now()
|
|
55
|
+
const t0 = this.now()
|
|
56
|
+
let info
|
|
57
|
+
try {
|
|
58
|
+
const r = await this.fetchImpl(this.url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
|
|
59
|
+
if (r.status !== 200) return null
|
|
60
|
+
info = await r.json()
|
|
61
|
+
} catch { return null }
|
|
62
|
+
const m = directMatch(info, { user: this.user, picked })
|
|
63
|
+
if (!m) return null
|
|
64
|
+
if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) return null }
|
|
65
|
+
this.latencyMs = Math.max(0, Math.round(this.now() - t0))
|
|
66
|
+
return m
|
|
67
|
+
})().finally(() => { this.inflight = null })
|
|
68
|
+
return this.inflight
|
|
69
|
+
}
|
|
70
|
+
use(machine) { this.on = true; this.machine = machine; this.lastError = null }
|
|
71
|
+
drop(err) { this.on = false; this.lastError = err ? (err.code || err.message || String(err)) : null; this.checkedAt = this.now() }
|
|
72
|
+
// time for another look? (focus: sooner, but not on every click into the window)
|
|
73
|
+
due({ focus = false } = {}) {
|
|
74
|
+
if (this.never || this.on || this.inflight) return false
|
|
75
|
+
return this.now() - this.checkedAt >= (focus ? this.focusGap : this.every)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// POST the sealed envelope; -> the sealed response envelope. Throws DirectError.
|
|
79
|
+
async send(env, { signal } = {}) {
|
|
80
|
+
let r
|
|
81
|
+
try {
|
|
82
|
+
r = await this.fetchImpl(this.url, { method: 'POST', mode: 'cors', credentials: 'omit', cache: 'no-store', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ env }), signal })
|
|
83
|
+
} catch (e) { if (aborted(signal)) throw e; throw new DirectError('unreachable', e && e.message) }
|
|
84
|
+
if (r.status !== 200) throw new DirectError('http_' + r.status)
|
|
85
|
+
let j
|
|
86
|
+
try { j = await r.json() } catch { throw new DirectError('bad_response') }
|
|
87
|
+
if (!j || !j.env) throw new DirectError('bad_response')
|
|
88
|
+
return j.env
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// One call: direct when it's on, else (or when direct fails) the relay, once.
|
|
93
|
+
// A call the page itself aborted is not retried.
|
|
94
|
+
export async function directOrRelay({ route, direct, relay, signal, onDrop = () => {} }) {
|
|
95
|
+
if (route && route.on) {
|
|
96
|
+
try { return await direct() } catch (e) {
|
|
97
|
+
if (aborted(signal)) throw e
|
|
98
|
+
route.drop(e)
|
|
99
|
+
try { onDrop(e) } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return relay()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A sealed response -> a fetch Response (204/304 carry no body).
|
|
106
|
+
export function toResponse(out) {
|
|
107
|
+
const nullBody = out.s === 204 || out.s === 205 || out.s === 304
|
|
108
|
+
return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function timeoutSignal(ms) {
|
|
112
|
+
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
|
|
113
|
+
const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
|
|
114
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -31,6 +31,9 @@ export const ICONS = {
|
|
|
31
31
|
"shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
|
|
32
32
|
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
|
|
33
33
|
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
|
34
|
+
"chevron-down": "<path d=\"m6 9 6 6 6-6\"/>",
|
|
35
|
+
"wifi-off": "<path d=\"M12 20h.01\"/> <path d=\"M8.5 16.429a5 5 0 0 1 7 0\"/> <path d=\"M5 12.859a10 10 0 0 1 5.17-2.69\"/> <path d=\"M19 12.859a10 10 0 0 0-2.007-1.523\"/> <path d=\"M2 8.82a15 15 0 0 1 4.177-2.643\"/> <path d=\"M22 8.82a15 15 0 0 0-11.288-3.764\"/> <path d=\"m2 2 20 20\"/>",
|
|
36
|
+
"monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\"/> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\"/> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\"/>",
|
|
34
37
|
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
35
38
|
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
36
39
|
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
|