@leftoff/cli 0.9.6 → 0.9.7
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 +16 -0
- package/README.md +1 -1
- package/bin/leftoff.js +374 -343
- package/docs/SECURITY.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +28 -3
- package/package.json +1 -1
- package/ui/app.css +89 -5
- package/ui/app.js +40 -14
- package/ui/direct.js +37 -12
- package/ui/restore-gate.js +40 -0
- package/ui/wizard.js +158 -0
package/docs/SECURITY.md
CHANGED
|
@@ -94,6 +94,8 @@ A relayed request is replayed against the Mac's own board from inside the daemon
|
|
|
94
94
|
|
|
95
95
|
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.
|
|
96
96
|
|
|
97
|
+
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
98
|
+
|
|
97
99
|
### Logging
|
|
98
100
|
|
|
99
101
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
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/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.
|
|
31
|
+
const MOD_VERSION = '0.9.7' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
|
@@ -103,6 +103,7 @@ async function api($, method, path, body, timeoutMs) {
|
|
|
103
103
|
// Every event goes through here: post, or spool a unique file (fs.write isn't atomic).
|
|
104
104
|
async function sendOrSpool($, type, data, timeoutMs) {
|
|
105
105
|
if (st.internal || !st.sessionId) return null
|
|
106
|
+
try { await ensureSetup($) } catch {} // a setup that failed leaves the fallback below to name the spool file's folder
|
|
106
107
|
const ev = { v: 1, type, sessionId: st.sessionId, at: nowIso(), capture: 'mod', machine: st.machine, data: data || {} }
|
|
107
108
|
st.record = applyEvent(st.record || { id: st.sessionId, turns: 0, turnsSinceCheckpoint: 0, backgroundTasks: 0 }, ev)
|
|
108
109
|
const r = await api($, 'POST', '/v1/events', { events: [ev] }, timeoutMs)
|
|
@@ -115,7 +116,17 @@ async function sendOrSpool($, type, data, timeoutMs) {
|
|
|
115
116
|
return j
|
|
116
117
|
}
|
|
117
118
|
try {
|
|
118
|
-
|
|
119
|
+
// An absolute root even when setup never came: without one the concat
|
|
120
|
+
// makes st.root null a relative "null/.spool" beside the session's cwd.
|
|
121
|
+
let root = st.root
|
|
122
|
+
if (!root) {
|
|
123
|
+
const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
|
|
124
|
+
if (home) root = home.replace(/[\\/]+$/, '') + '/checkpoints' // never a bare "/checkpoints"
|
|
125
|
+
}
|
|
126
|
+
// Only an absolute folder; otherwise skip the file but still keep the registry below.
|
|
127
|
+
if (root && /^([A-Za-z]:[\\/]|\/)/.test(String(root))) {
|
|
128
|
+
await $.fs.write(String(root) + '/.spool/' + (st.machine || 'unknown') + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
|
|
129
|
+
}
|
|
119
130
|
} catch {}
|
|
120
131
|
try {
|
|
121
132
|
await $.store.set('session:' + st.sessionId, {
|
|
@@ -160,6 +171,20 @@ async function loadSetup($) {
|
|
|
160
171
|
st.machine = resolveMachine({ env: await $.env.get('CHECKPOINTS_MACHINE'), hostname, cfg: st.cfg })
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
// loadSetup runs once per process and every sender awaits the same attempt:
|
|
175
|
+
// a hook that fires before session.start (classic.SessionStart on a cold
|
|
176
|
+
// boot, the old null/.spool race) waits for these values instead of reading
|
|
177
|
+
// st at its initial state.
|
|
178
|
+
let setupPromise = null
|
|
179
|
+
function ensureSetup($) {
|
|
180
|
+
if (!setupPromise) {
|
|
181
|
+
const p = loadSetup($)
|
|
182
|
+
setupPromise = p
|
|
183
|
+
p.catch(() => { if (setupPromise === p) setupPromise = null }) // a failed setup tries again on the next event
|
|
184
|
+
}
|
|
185
|
+
return setupPromise
|
|
186
|
+
}
|
|
187
|
+
|
|
163
188
|
async function readRepo($, cwd) {
|
|
164
189
|
let repo = null
|
|
165
190
|
try {
|
|
@@ -931,7 +956,7 @@ export function register(on, options) {
|
|
|
931
956
|
on('session.start', async ($, e, next) => {
|
|
932
957
|
try {
|
|
933
958
|
if (await isInternal($)) { st.internal = true; return next(e) }
|
|
934
|
-
await
|
|
959
|
+
try { await ensureSetup($) } catch {} // the same attempt an early hook may have started; a failure still says hello
|
|
935
960
|
st.sessionId = await $.session.id()
|
|
936
961
|
const cwd = await $.session.cwd()
|
|
937
962
|
const repo = await readRepo($, cwd)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
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
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
--toast-shadow: 0 10px 30px rgba(0,0,0,.4);
|
|
36
36
|
--chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%238b93a7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
37
37
|
--radius: 14px;
|
|
38
|
+
--btn-bg: #1d2433;
|
|
39
|
+
--btn-hover: #252d40;
|
|
40
|
+
--btn-active: #161b26;
|
|
41
|
+
--btn-line: #3a4560;
|
|
42
|
+
--btn-line-hover: #56637f;
|
|
43
|
+
--btn-shadow: none;
|
|
38
44
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
39
45
|
}
|
|
40
46
|
/* Light: a paper-white page with cool-gray structure, not an inverted dark.
|
|
@@ -75,6 +81,12 @@
|
|
|
75
81
|
--card-shadow: 0 1px 2px rgba(22,26,36,.04), 0 2px 8px rgba(22,26,36,.04);
|
|
76
82
|
--toast-shadow: 0 10px 30px rgba(22,26,36,.16);
|
|
77
83
|
--chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235b6478' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
84
|
+
--btn-bg: #ffffff;
|
|
85
|
+
--btn-hover: #f4f6fa;
|
|
86
|
+
--btn-active: #e9ecf2;
|
|
87
|
+
--btn-line: #c7cedb;
|
|
88
|
+
--btn-line-hover: #9aa3b5;
|
|
89
|
+
--btn-shadow: 0 1px 1px rgba(22,26,36,.06);
|
|
78
90
|
}
|
|
79
91
|
* { box-sizing: border-box; }
|
|
80
92
|
html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
@@ -147,11 +159,18 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
147
159
|
.nocp { color: var(--faint); font-size: 13px; font-style: italic; padding: 4px 0; }
|
|
148
160
|
|
|
149
161
|
.actions-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; padding-top: 2px; }
|
|
150
|
-
.
|
|
151
|
-
|
|
162
|
+
/* 0.9.7: a secondary button reads as a button on any surface (page, card, the
|
|
163
|
+
tinted banners): solid text, its own fill and a visible edge. Only a real
|
|
164
|
+
disabled state (disabled or aria-disabled) is dimmed, flat and dashed. */
|
|
165
|
+
.btn { background: var(--btn-bg); border: 1px solid var(--btn-line); border-radius: 8px; padding: 5px 10px; font-size: 12.5px; font-weight: 500; color: var(--text2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; box-shadow: var(--btn-shadow); transition: background-color .12s, border-color .12s, color .12s; }
|
|
166
|
+
.btn:hover { background: var(--btn-hover); border-color: var(--btn-line-hover); color: var(--text); }
|
|
167
|
+
.btn:active { background: var(--btn-active); box-shadow: none; }
|
|
168
|
+
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
152
169
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
153
170
|
.btn.primary:hover { filter: brightness(1.08); color: var(--on-accent); }
|
|
154
|
-
.btn:
|
|
171
|
+
.btn.primary:active { filter: brightness(.94); }
|
|
172
|
+
.btn:disabled, .btn[aria-disabled="true"], .btn:disabled:hover, .btn[aria-disabled="true"]:hover, .btn:disabled:active, .btn[aria-disabled="true"]:active { opacity: .5; cursor: not-allowed; box-shadow: none; filter: none; border-style: dashed; background: var(--btn-bg); border-color: var(--btn-line-hover); color: var(--muted); }
|
|
173
|
+
@media (prefers-reduced-motion: reduce) { .btn { transition: none; } }
|
|
155
174
|
|
|
156
175
|
.empty { border: 1px dashed var(--line); border-radius: var(--radius); padding: 38px; text-align: center; color: var(--muted); }
|
|
157
176
|
.empty code { background: var(--bg2); padding: 2px 6px; border-radius: 6px; }
|
|
@@ -488,7 +507,6 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
488
507
|
.btn.allow:hover { filter: brightness(1.08); }
|
|
489
508
|
.btn.deny { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
|
|
490
509
|
.btn.deny:hover { background: var(--tint-red); }
|
|
491
|
-
.btn[disabled] { opacity: .5; cursor: not-allowed; }
|
|
492
510
|
.deny-form { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
493
511
|
.deny-form[hidden] { display: none; }
|
|
494
512
|
.deny-form input, .need .qother, .reply-text { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
|
|
@@ -635,7 +653,8 @@ html { background: var(--bg); }
|
|
|
635
653
|
.banner.restore .rs-m { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
636
654
|
.banner.restore .rs-m.err { color: var(--red); }
|
|
637
655
|
.banner.restore .rs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
638
|
-
.banner.restore .rs-note { color: var(--
|
|
656
|
+
.banner.restore .rs-note { color: var(--text2); font-size: 12.5px; flex: 1 1 280px; min-width: 0; }
|
|
657
|
+
@media (pointer: coarse), (max-width: 600px) { .banner.restore .rs-actions .btn { min-height: 44px; } }
|
|
639
658
|
.banner .rs-cmds { margin: 8px 0 0; padding: 8px 10px; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
|
|
640
659
|
.banner.restore .rs-result { margin: 10px 0 0; }
|
|
641
660
|
|
|
@@ -1931,3 +1950,68 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
1931
1950
|
.board-search .set-search input, .board-search input { height: 44px; }
|
|
1932
1951
|
.tx-board-link { min-height: 44px; }
|
|
1933
1952
|
}
|
|
1953
|
+
|
|
1954
|
+
/* ------------------------------------------------ first-run setup wizard (0.9.7, ui/wizard.js) */
|
|
1955
|
+
.wz { max-width: 860px; margin: 8px auto 40px; }
|
|
1956
|
+
.wz .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
1957
|
+
.wz-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
1958
|
+
.wz-h1 { font-size: 22px; line-height: 1.25; margin: 0; font-weight: 700; color: var(--text); }
|
|
1959
|
+
.wz-count { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
|
1960
|
+
.wz-bar { list-style: none; margin: 0 0 14px; padding: 0; display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
|
|
1961
|
+
.wz-bar::-webkit-scrollbar { display: none; }
|
|
1962
|
+
.wz-bar li { flex: 1 0 auto; }
|
|
1963
|
+
.wz-bar button { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--muted); font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap; min-height: 40px; }
|
|
1964
|
+
.wz-bar button:hover { border-color: var(--line-strong); color: var(--text); }
|
|
1965
|
+
.wz-num { flex: none; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; background: var(--bg2); color: var(--muted); border: 1px solid var(--line); }
|
|
1966
|
+
.wz-bar li.done .wz-num { background: var(--tint-green); color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line)); }
|
|
1967
|
+
.wz-bar li.on button { border-color: var(--accent); color: var(--text); background: var(--tint-accent); }
|
|
1968
|
+
.wz-bar li.on .wz-num { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
1969
|
+
.wz-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 18px; outline: none; }
|
|
1970
|
+
.wz-card:focus-visible { box-shadow: 0 0 0 2px var(--focus); }
|
|
1971
|
+
.wz .wz-step { margin: 0 0 12px; }
|
|
1972
|
+
.wz-status { display: flex; gap: 12px; align-items: flex-start; }
|
|
1973
|
+
.wz-mark { flex: none; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; background: var(--tint-gray); color: var(--muted); }
|
|
1974
|
+
.wz-status.s-done .wz-mark, .wz-sumlist .wz-done .wz-mark { background: var(--tint-green); color: var(--green); }
|
|
1975
|
+
.wz-status.s-todo .wz-mark, .wz-sumlist .wz-todo .wz-mark { background: var(--tint-yellow); color: var(--yellow); }
|
|
1976
|
+
.wz-title { font-size: 16px; font-weight: 600; color: var(--text); line-height: 1.35; }
|
|
1977
|
+
.wz-detail { color: var(--text2); margin-top: 3px; }
|
|
1978
|
+
.wz-p { color: var(--text2); margin: 12px 0 0; }
|
|
1979
|
+
.wz-p a, .wz-detail a { color: var(--accent); }
|
|
1980
|
+
.wz-hint { color: var(--muted); font-size: 12.5px; }
|
|
1981
|
+
.wz-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
|
1982
|
+
.wz-list li { display: flex; align-items: center; gap: 8px; color: var(--text2); flex-wrap: wrap; }
|
|
1983
|
+
.wz-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gray); flex: none; }
|
|
1984
|
+
.wz-dot.up { background: var(--green); } .wz-dot.down { background: var(--red); }
|
|
1985
|
+
.wz-chip { font-size: 11.5px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--tint-gray); color: var(--muted); }
|
|
1986
|
+
.wz-chip.on { background: var(--tint-green); color: var(--green); }
|
|
1987
|
+
.wz-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
|
|
1988
|
+
.wz-result { margin-top: 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); display: flex; flex-direction: column; gap: 4px; color: var(--text2); }
|
|
1989
|
+
.wz-result.ok { border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
|
|
1990
|
+
.wz-result.bad { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); color: var(--text); }
|
|
1991
|
+
.wz-fix { margin-top: 14px; padding: 10px 12px; border-radius: 10px; background: var(--bg2); border: 1px solid var(--line); }
|
|
1992
|
+
.wz-fix-what { font-size: 13px; color: var(--text2); margin-bottom: 6px; }
|
|
1993
|
+
.wz-cmdrow { display: flex; align-items: center; gap: 8px; }
|
|
1994
|
+
.wz-cmd { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
|
|
1995
|
+
.wz-sumlist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
1996
|
+
.wz-sumlist li { display: flex; gap: 10px; align-items: flex-start; color: var(--text2); border: 0; padding: 0; }
|
|
1997
|
+
.wz-sumlist b { color: var(--text); margin-right: 4px; }
|
|
1998
|
+
.wz-h3 { font-size: 15px; margin: 18px 0 0; color: var(--text); }
|
|
1999
|
+
.wz-allset { font-weight: 600; color: var(--green); }
|
|
2000
|
+
.wz-nav { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 14px; }
|
|
2001
|
+
.wz-nav-r { display: flex; gap: 8px; }
|
|
2002
|
+
.wz .btn { font-size: 14px; padding: 8px 14px; min-height: 38px; }
|
|
2003
|
+
.wz-remote { margin-top: 10px; }
|
|
2004
|
+
@media (pointer: coarse), (max-width: 600px) {
|
|
2005
|
+
.wz .btn, .wz-bar button { min-height: 44px; }
|
|
2006
|
+
.wz-copy { min-width: 64px; justify-content: center; }
|
|
2007
|
+
}
|
|
2008
|
+
@media (max-width: 600px) {
|
|
2009
|
+
.wz { margin-top: 0; }
|
|
2010
|
+
.wz-card { padding: 14px; }
|
|
2011
|
+
.wz-bar li { flex: none; }
|
|
2012
|
+
.wz-bar .wz-lbl { display: none; }
|
|
2013
|
+
.wz-bar li.on .wz-lbl { display: inline; }
|
|
2014
|
+
.wz-cmdrow { flex-direction: column; align-items: flex-start; }
|
|
2015
|
+
.wz-cmd { width: 100%; }
|
|
2016
|
+
.wz-nav .btn { padding: 8px 16px; }
|
|
2017
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
+
import { mountWizard, shouldAutoOpen } from './wizard.js'
|
|
7
8
|
import { changedKeys } from './settings-dirty.js'
|
|
8
9
|
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
9
10
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
@@ -12,7 +13,8 @@ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentRepl
|
|
|
12
13
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
13
14
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
14
15
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
15
|
-
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
16
|
+
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
17
|
+
import { restoreAccess, hintHtml, restoreWhen } from './restore-gate.js'
|
|
16
18
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
17
19
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
18
20
|
const $app = document.getElementById('app')
|
|
@@ -205,7 +207,7 @@ function banners() {
|
|
|
205
207
|
const items = inc.slice(0, 2).flatMap((e) => e.sections.flatMap((s) => s.items.map((it) => ({ v: e.version, t: it })))).slice(0, 4)
|
|
206
208
|
out += '<div class="banner info update" role="status"><div class="grow"><b>Leftoff ' + (u.remoteVersion ? 'v' + esc(u.remoteVersion) : esc(u.remoteSha || 'update')) + ' is available</b> · you have v' + esc(h.version) +
|
|
207
209
|
(items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
|
|
208
|
-
'<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
|
|
210
|
+
'<code class="cmd">' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(u.upgradeCommand || 'leftoff upgrade') + '">Copy</button></div>'
|
|
209
211
|
}
|
|
210
212
|
out += syncBanners(h)
|
|
211
213
|
const mod = h.mod
|
|
@@ -310,7 +312,7 @@ function staleNote(machine) {
|
|
|
310
312
|
function updateLine() {
|
|
311
313
|
const h = state.health || {}
|
|
312
314
|
const u = h.update || {}
|
|
313
|
-
if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>leftoff upgrade</code>.'
|
|
315
|
+
if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code>.'
|
|
314
316
|
if (u.error) return 'Last check couldn’t reach the repo (' + esc(u.error) + ').'
|
|
315
317
|
if (u.checkedAt) return 'Up to date (v' + esc(h.version) + '), checked ' + esc(ago(u.checkedAt)) + '.'
|
|
316
318
|
return 'Not checked yet.'
|
|
@@ -1033,26 +1035,32 @@ function emptyFold(list) {
|
|
|
1033
1035
|
'<div class="grid">' + list.map(card).join('') + '</div></details>'
|
|
1034
1036
|
}
|
|
1035
1037
|
|
|
1036
|
-
// Restore (v0.4.7): the sessions that were open before this machine restarted
|
|
1038
|
+
// Restore (v0.4.7): the sessions that were open before this machine restarted
|
|
1039
|
+
// (0.9.7: or when the terminal quit),
|
|
1037
1040
|
// with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
|
|
1038
1041
|
// Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
|
|
1039
1042
|
const restoreUi = { open: false, off: new Set(), result: null }
|
|
1040
1043
|
function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
|
|
1041
1044
|
function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
|
|
1045
|
+
// 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
|
|
1046
|
+
function restoreGate() {
|
|
1047
|
+
const lr = window.leftoffRelay, r = state.restore || {}
|
|
1048
|
+
return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
|
|
1049
|
+
phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }) })
|
|
1050
|
+
}
|
|
1042
1051
|
function restoreBanner() {
|
|
1043
1052
|
const r = state.restore
|
|
1044
1053
|
if (!r || !r.sessions || !r.sessions.length) return restoreUi.result ? restoreResult() : ''
|
|
1045
1054
|
try { if (localStorage.getItem(restoreDismissKey(r)) === '1' && !restoreUi.open) return '' } catch {}
|
|
1046
1055
|
const n = r.sessions.length
|
|
1047
|
-
const what = r.restartKind === 'boot' ? (r.ghostty ? 'your Mac' : 'this machine') + ' restarted' : 'Leftoff restarted'
|
|
1048
1056
|
const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
|
|
1049
|
-
const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open
|
|
1057
|
+
const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open ' + esc(restoreWhen(r)) + '</b>' + esc(when)
|
|
1050
1058
|
if (!restoreUi.open) {
|
|
1051
1059
|
return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + head + '. Put them back, each in its own Ghostty tab.</span>' +
|
|
1052
1060
|
'<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
|
|
1053
1061
|
'<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" title="Not now">✕</button></div>'
|
|
1054
1062
|
}
|
|
1055
|
-
const
|
|
1063
|
+
const gate = restoreGate()
|
|
1056
1064
|
const picked = restorePicked().length
|
|
1057
1065
|
const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
|
|
1058
1066
|
'<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
|
|
@@ -1060,13 +1068,15 @@ function restoreBanner() {
|
|
|
1060
1068
|
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
|
|
1061
1069
|
'<div class="rs-list">' + rows + '</div>' +
|
|
1062
1070
|
'<div class="rs-actions">' +
|
|
1063
|
-
|
|
1064
|
-
: '
|
|
1071
|
+
'<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked ? '' : ' aria-disabled="true"') +
|
|
1072
|
+
(gate.ok ? '' : ' aria-describedby="restoreHint" title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
|
|
1065
1073
|
'<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
|
|
1066
1074
|
'<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
|
|
1067
|
-
(
|
|
1075
|
+
(gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
|
|
1068
1076
|
'</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
|
|
1069
1077
|
}
|
|
1078
|
+
// 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
|
|
1079
|
+
if (typeof window !== 'undefined') window.addEventListener('leftoff-route', () => { const el = restoreUi.open && document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner() })
|
|
1070
1080
|
function restoreResult() {
|
|
1071
1081
|
const x = restoreUi.result
|
|
1072
1082
|
if (!x) return ''
|
|
@@ -2407,6 +2417,7 @@ async function viewSettings() {
|
|
|
2407
2417
|
updates: '<div class="section">About</div>' +
|
|
2408
2418
|
'<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
|
|
2409
2419
|
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
2420
|
+
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
|
|
2410
2421
|
advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
|
|
2411
2422
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
2412
2423
|
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
@@ -3148,6 +3159,7 @@ async function render() {
|
|
|
3148
3159
|
else if (r.name === 'whats-new') await viewWhatsNew()
|
|
3149
3160
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
3150
3161
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3162
|
+
else if (r.name === 'setup') await viewSetup()
|
|
3151
3163
|
else await viewBoard()
|
|
3152
3164
|
} catch (e) {
|
|
3153
3165
|
if (e.code === 'login_required') return showLogin()
|
|
@@ -3155,6 +3167,18 @@ async function render() {
|
|
|
3155
3167
|
}
|
|
3156
3168
|
}
|
|
3157
3169
|
|
|
3170
|
+
// First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
|
|
3171
|
+
// it keeps its own step and fetches its own state.
|
|
3172
|
+
async function viewSetup() {
|
|
3173
|
+
if ($app.querySelector('.wz')) return
|
|
3174
|
+
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
|
|
3175
|
+
}
|
|
3176
|
+
// Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
|
|
3177
|
+
async function firstRunSetup() {
|
|
3178
|
+
if (location.hash.replace(/^#\/?/, '')) return
|
|
3179
|
+
try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) location.replace('#/setup') } catch {}
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3158
3182
|
// A remote browser (Tailscale) that hasn't signed in yet: the iPhone
|
|
3159
3183
|
// home-screen app always lands here first (it has its own cookie jar), so the
|
|
3160
3184
|
// typed code is the main way in; the QR link is the other.
|
|
@@ -3709,7 +3733,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
3709
3733
|
if (action === 'restore-copy') { const p = restorePicked(); if (!p.length) { toast('Pick at least one'); return } await copy(p.map((x) => x.command).join('\n')); return }
|
|
3710
3734
|
if (action === 'restore-go') {
|
|
3711
3735
|
const ids = restorePicked().map((x) => x.id)
|
|
3712
|
-
if (!ids.length) return
|
|
3736
|
+
if (!ids.length) { toast('Pick at least one'); return }
|
|
3737
|
+
const gate = restoreGate()
|
|
3738
|
+
if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
|
|
3713
3739
|
btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
|
|
3714
3740
|
const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
|
|
3715
3741
|
restoreUi.result = r.how === 'print' || r.failed ? r : null
|
|
@@ -4338,7 +4364,7 @@ let pending = null
|
|
|
4338
4364
|
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
|
|
4339
4365
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
4340
4366
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
4341
|
-
function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && state.route.name
|
|
4367
|
+
function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
|
|
4342
4368
|
let stream = null
|
|
4343
4369
|
function connect() {
|
|
4344
4370
|
if (stream) { try { stream.close() } catch {} }
|
|
@@ -4388,7 +4414,7 @@ document.addEventListener('change', async (ev) => {
|
|
|
4388
4414
|
if (ev.target.checked) restoreUi.off.delete(ev.target.value); else restoreUi.off.add(ev.target.value)
|
|
4389
4415
|
const n = restorePicked().length
|
|
4390
4416
|
const go = document.getElementById('restoreGo')
|
|
4391
|
-
if (go) { go.disabled
|
|
4417
|
+
if (go) { if (n && restoreGate().ok) go.removeAttribute('aria-disabled'); else go.setAttribute('aria-disabled', 'true'); go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
|
|
4392
4418
|
const all = document.querySelector('[data-action="restore-all"]')
|
|
4393
4419
|
if (all) all.textContent = n === (state.restore ? state.restore.sessions.length : 0) ? 'Select none' : 'Select all'
|
|
4394
4420
|
return
|
|
@@ -4455,7 +4481,7 @@ document.addEventListener('pointerdown', (ev) => {
|
|
|
4455
4481
|
if (m && !m.contains(ev.target)) m.open = false
|
|
4456
4482
|
})
|
|
4457
4483
|
api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
|
|
4458
|
-
refreshHealth().then(render)
|
|
4484
|
+
refreshHealth().then(render).then(firstRunSetup)
|
|
4459
4485
|
connect()
|
|
4460
4486
|
|
|
4461
4487
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
package/ui/direct.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
|
|
2
2
|
// open on a Mac that runs Leftoff asks that Mac's own daemon over
|
|
3
|
-
// http://127.0.0.1:7429
|
|
3
|
+
// http://127.0.0.1:7429 (0.9.7: or https://leftoff.localhost) instead of
|
|
4
|
+
// through the relay; the address bar stays.
|
|
4
5
|
//
|
|
5
6
|
// The request is the very envelope the relay would carry (ui/relay-crypto.js),
|
|
6
7
|
// posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
|
|
@@ -21,7 +22,13 @@ import { LOCAL_PORT, isPhoneUa } from './viewer.js'
|
|
|
21
22
|
export const DIRECT_PATH = '/v1/relay/direct'
|
|
22
23
|
export const PROBE_EVERY_MS = 60000
|
|
23
24
|
export const FOCUS_GAP_MS = 10000
|
|
25
|
+
// 0.9.7: Leftoff's own https://leftoff.localhost (`leftoff setup`, Caddy in
|
|
26
|
+
// front of 127.0.0.1:7429) is asked too: an https page may always call an
|
|
27
|
+
// https address, where Safari refuses plain http://127.0.0.1. Both go to the
|
|
28
|
+
// same daemon; the first that proves itself is used.
|
|
29
|
+
export const HTTPS_LOCAL = 'https://leftoff.localhost'
|
|
24
30
|
export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
|
|
31
|
+
export function directUrls(port = LOCAL_PORT) { return [HTTPS_LOCAL + DIRECT_PATH, directUrl(port)] }
|
|
25
32
|
|
|
26
33
|
export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
|
|
27
34
|
|
|
@@ -37,14 +44,18 @@ export function directMatch(info, { user, picked } = {}) {
|
|
|
37
44
|
export function aborted(signal) { return !!(signal && signal.aborted) }
|
|
38
45
|
|
|
39
46
|
export class DirectRoute {
|
|
40
|
-
constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
|
|
47
|
+
constructor({ user, port = LOCAL_PORT, urls = null, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
|
|
41
48
|
this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
|
|
49
|
+
this.urls = urls && urls.length ? urls.slice() : directUrls(port)
|
|
50
|
+
this.base = null // the address that answered last (one of urls)
|
|
42
51
|
// a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
|
|
43
52
|
this.never = isPhoneUa(ua, { maxTouchPoints })
|
|
44
53
|
this.every = every; this.focusGap = focusGap
|
|
45
54
|
this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
|
|
46
55
|
}
|
|
47
|
-
get url() { return
|
|
56
|
+
get url() { return this.base || this.urls[this.urls.length - 1] }
|
|
57
|
+
// 'https' (https://leftoff.localhost) or 'http' (127.0.0.1), once one answered
|
|
58
|
+
get via() { return this.base ? (this.base.startsWith('https:') ? 'https' : 'http') : null }
|
|
48
59
|
|
|
49
60
|
// -> the machine (direct confirmed, not yet switched on) or null. Never throws.
|
|
50
61
|
probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
|
|
@@ -53,15 +64,20 @@ export class DirectRoute {
|
|
|
53
64
|
this.inflight = (async () => {
|
|
54
65
|
this.checkedAt = this.now()
|
|
55
66
|
const t0 = this.now()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const r = await this.fetchImpl(
|
|
59
|
-
if (r.status !== 200)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
// every address at once; the first that is this account's Leftoff wins
|
|
68
|
+
const ask = async (url) => {
|
|
69
|
+
const r = await this.fetchImpl(url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
|
|
70
|
+
if (r.status !== 200) throw new DirectError('http_' + r.status)
|
|
71
|
+
const m = directMatch(await r.json(), { user: this.user, picked })
|
|
72
|
+
if (!m) throw new DirectError('not_ours')
|
|
73
|
+
return { url, m }
|
|
74
|
+
}
|
|
75
|
+
let hit
|
|
76
|
+
try { hit = await firstOf(this.urls.map(ask)) } catch { return null }
|
|
77
|
+
const prev = this.base
|
|
78
|
+
this.base = hit.url
|
|
79
|
+
const m = hit.m
|
|
80
|
+
if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) { this.base = prev; return null } }
|
|
65
81
|
this.latencyMs = Math.max(0, Math.round(this.now() - t0))
|
|
66
82
|
return m
|
|
67
83
|
})().finally(() => { this.inflight = null })
|
|
@@ -108,6 +124,15 @@ export function toResponse(out) {
|
|
|
108
124
|
return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
// Promise.any, for browsers and runtimes without it.
|
|
128
|
+
function firstOf(ps) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
let left = ps.length
|
|
131
|
+
if (!left) return reject(new DirectError('none'))
|
|
132
|
+
for (const p of ps) Promise.resolve(p).then(resolve, () => { if (--left === 0) reject(new DirectError('none')) })
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
function timeoutSignal(ms) {
|
|
112
137
|
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
|
|
113
138
|
const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 0.9.7: may this board press Restore, and if not, what would make it work.
|
|
2
|
+
// Restore opens Ghostty tabs on the Mac the restore card belongs to, so the
|
|
3
|
+
// board must be on that Mac: its own browser on localhost, or your hosted
|
|
4
|
+
// board (<user>.leftoff.dev) open on it and talking to it over the direct path
|
|
5
|
+
// (ui/direct.js: http://127.0.0.1:7429 or https://leftoff.localhost). The
|
|
6
|
+
// daemon checks the same thing again (src/direct.js DIRECT_LOCAL); this only
|
|
7
|
+
// decides how the button looks and what the hint says. Plain logic, no DOM.
|
|
8
|
+
//
|
|
9
|
+
// local the page is that Mac's own board (viewer.local)
|
|
10
|
+
// hosted the page is the hosted board (window.leftoffRelay)
|
|
11
|
+
// route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
|
|
12
|
+
// machine the Mac the restore card belongs to
|
|
13
|
+
// phone a phone or tablet (never runs Leftoff)
|
|
14
|
+
// -> { ok, how: 'local' | 'direct' | null, hint: text with `commands` or null }
|
|
15
|
+
export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false } = {}) {
|
|
16
|
+
if (local && !hosted) return { ok: true, how: 'local', hint: null }
|
|
17
|
+
if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
|
|
18
|
+
const m = machine || 'the Mac'
|
|
19
|
+
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
|
+
if (hosted) {
|
|
21
|
+
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.' }
|
|
22
|
+
}
|
|
23
|
+
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
|
+
}
|
|
25
|
+
|
|
26
|
+
// The hint as HTML: escaped, `commands` in <code>.
|
|
27
|
+
export function hintHtml(hint, esc) {
|
|
28
|
+
return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 0.9.7: when the restore card's sessions were open, from GET /v1/restore:
|
|
32
|
+
// restartKind 'quit' (the terminal quit while the Mac stayed up; restartTerminal
|
|
33
|
+
// names it when known), 'boot' or 'daemon'. short: the CLI's one-liners.
|
|
34
|
+
// -> "when Ghostty quit" | "before your Mac restarted" | "before Leftoff restarted"
|
|
35
|
+
export function restoreWhen(r = {}, { short = false } = {}) {
|
|
36
|
+
if (r.restartKind === 'quit') return 'when ' + (r.restartTerminal || 'the terminal') + ' quit'
|
|
37
|
+
if (short) return 'before the restart'
|
|
38
|
+
if (r.restartKind === 'boot') return 'before ' + (r.ghostty === false ? 'this machine' : 'your Mac') + ' restarted'
|
|
39
|
+
return 'before Leftoff restarted'
|
|
40
|
+
}
|