dsh-composer-plus 1.9.1 → 1.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "1.9.1",
3
+ "version": "1.11.0",
4
4
  "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, sidebar search in message text, turn rollback with git file restore, delete chats to the Trash, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
5
5
  "icon": "./assets/icon.svg",
6
6
  "license": "MIT",
@@ -73,7 +73,7 @@
73
73
  "assets/features/*.webp"
74
74
  ],
75
75
  "scripts": {
76
- "test": "node scripts/build-locales.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.test.mjs && node test/project-name.test.mjs && node test/queue-drag.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/tether-gateway.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs",
76
+ "test": "node scripts/build-locales.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.test.mjs && node test/export-chat.test.mjs && node test/project-name.test.mjs && node test/queue-drag.test.mjs && node test/queue-move.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/tether-gateway.test.mjs && node test/tether-parity.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/project-picker.test.mjs && node test/predict.test.mjs && node test/predict-client.test.mjs && node test/complete.test.mjs && node test/complete-client.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs && node test/readme.test.mjs",
77
77
  "build:locales": "node scripts/build-locales.mjs",
78
78
  "prepublishOnly": "npm test"
79
79
  },
@@ -4,100 +4,180 @@
4
4
  <meta name="viewport" content="width=device-width,initial-scale=1">
5
5
  <title>Tether</title>
6
6
  <style>
7
- :root { color-scheme: light dark; font: 14px -apple-system, system-ui, sans-serif; --line: #8883; --card: #8881; }
8
- body { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
9
- h1 { font-size: 1.4rem; margin-bottom: .2rem; } h2 { font-size: 1rem; margin: 1.6rem 0 .5rem; }
10
- table { border-collapse: collapse; width: 100%; } td, th { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid var(--line); }
11
- code { word-break: break-all; font-size: 12px; } .muted { opacity: .65; }
12
- button { font: inherit; padding: .35rem .8rem; border-radius: 8px; border: 1px solid var(--line); background: var(--card); cursor: pointer; margin-right: .4rem; }
13
- button.primary { background: #0a84ff; color: #fff; border-color: #0a84ff; }
14
- .card { border: 1px solid var(--line); background: var(--card); border-radius: 14px; padding: 1rem 1.2rem; margin: 1rem 0; }
15
- .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: .4rem; vertical-align: middle; }
16
- .ok { background: #30d158; } .bad { background: #ff453a; } .warn { background: #ff9f0a; }
17
- #pair-card { display: flex; gap: 1.4rem; align-items: center; flex-wrap: wrap; }
18
- #qr { width: 220px; height: 220px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 10px; }
19
- ol { padding-left: 1.2rem; margin: .4rem 0; } li { margin: .3rem 0; }
20
- .alert { border-color: #ff9f0a; background: #ff9f0a22; }
21
- details summary { cursor: pointer; }
22
- /* ?embed=1: shown inside DSH's "Mobile connect" popover */
23
- .embed body { max-width: none; margin: 0; padding: 0 12px 12px; background: transparent; font-size: 13px; overflow: hidden; }
24
- .embed h1, .embed ol, .embed #gw, .embed #remote:empty { display: none; }
25
- .embed p { margin: 0 0 4px; } .embed h2 { font-size: 13px; margin: 10px 0 4px; }
26
- .embed .card { padding: 10px; margin: 8px 0; border-radius: 12px; }
27
- .embed #pair-card { flex-wrap: nowrap; gap: 12px; } .embed #qr { width: 132px; height: 132px; padding: 6px; }
28
- .embed td, .embed th { padding: .3rem .4rem; } .embed button { padding: .25rem .65rem; }
29
- .embed-only { display: none; } .embed .embed-only { display: block; }
7
+ :root { color-scheme: light dark; font: 13px/1.45 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
8
+ --fg: #1d1d1f; --mute: #6e6e73; --line: rgba(0,0,0,.1); --soft: rgba(0,0,0,.04); --blue: #0071e3; --green: #1f9d55; --orange: #c25e00; --red: #d70015; }
9
+ @media (prefers-color-scheme: dark) { :root { --fg: #f5f5f7; --mute: #98989d; --line: rgba(255,255,255,.12); --soft: rgba(255,255,255,.06); --blue: #0a84ff; --green: #32d74b; --orange: #ff9f0a; --red: #ff453a; } }
10
+ * { box-sizing: border-box; }
11
+ [hidden] { display: none !important; } /* .pair and .wait set display; hidden must still win */
12
+ body { max-width: 560px; margin: 32px auto; padding: 0 20px; color: var(--fg); }
13
+ h1 { font-size: 20px; font-weight: 600; margin: 0 0 2px; letter-spacing: -.01em; }
14
+ h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--mute); margin: 24px 0 6px; }
15
+ p { margin: 0; } .mute { color: var(--mute); } code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
16
+ a { color: var(--blue); }
17
+ button { font: inherit; font-weight: 500; padding: 5px 12px; border-radius: 7px; border: 1px solid var(--line); background: var(--soft); color: inherit; cursor: pointer; }
18
+ button:hover { background: var(--line); }
19
+ button.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
20
+ button.link { border: 0; background: none; padding: 0; color: var(--blue); font-weight: 400; }
21
+ button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
22
+ .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; background: var(--mute); }
23
+ .dot.ok { background: var(--green); } .dot.warn { background: var(--orange); } .dot.bad { background: var(--red); }
24
+ #status { margin-top: 2px; }
25
+
26
+ /* Pair: QR on the left, three short steps on the right. */
27
+ .pair { display: grid; grid-template-columns: auto 1fr; gap: 20px; align-items: start; margin-top: 20px; }
28
+ .qrbox { width: 184px; height: 184px; border-radius: 12px; background: #fff; padding: 10px; border: 1px solid var(--line); display: grid; place-items: center; }
29
+ #qr { width: 100%; height: 100%; image-rendering: pixelated; display: block; }
30
+ .qrbox .wait { color: #6e6e73; font-size: 12px; }
31
+ ol.steps { margin: 0; padding: 0; list-style: none; counter-reset: s; }
32
+ ol.steps li { counter-increment: s; position: relative; padding-left: 26px; margin: 0 0 10px; }
33
+ ol.steps li::before { content: counter(s); position: absolute; left: 0; top: 0; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--line); font-size: 11px; font-weight: 600; display: grid; place-items: center; }
34
+ .tools { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 4px; font-size: 12px; }
35
+ #exp { font-variant-numeric: tabular-nums; }
36
+
37
+ /* A waiting request takes the QR's place: the one thing to do. */
38
+ .req { margin-top: 20px; border: 1px solid var(--blue); border-radius: 12px; padding: 16px; background: color-mix(in srgb, var(--blue) 7%, transparent); }
39
+ .req .who { font-size: 15px; font-weight: 600; }
40
+ .req .acts { display: flex; gap: 8px; margin-top: 12px; }
41
+ .req .acts .primary { padding: 7px 18px; }
42
+ .note { margin-top: 14px; padding: 9px 12px; border-radius: 9px; background: var(--soft); }
43
+ .note.ok { color: var(--green); } .note.warn { color: var(--orange); }
44
+
45
+ table { width: 100%; border-collapse: collapse; }
46
+ td { padding: 7px 0; border-top: 1px solid var(--line); vertical-align: middle; } td:last-child { text-align: right; }
47
+ tr:first-child td { border-top: 0; }
48
+ details { margin-top: 24px; } summary { cursor: pointer; color: var(--mute); font-size: 12px; }
49
+ #status-table td { text-align: left; overflow-wrap: anywhere; } #status-table td:first-child { color: var(--mute); width: 30%; }
50
+ #status-table .link { margin-left: 8px; font-size: 12px; }
51
+ .addrow { margin-top: 8px; }
52
+
53
+ /* ?embed=1: inside DSH's "Mobile connect" popover */
54
+ .embed body { max-width: none; margin: 0; padding: 4px 12px 12px; background: transparent; overflow: hidden; }
55
+ .embed h1, .embed #remote:empty { display: none; }
56
+ .embed .pair { margin-top: 10px; gap: 14px; } .embed .qrbox { width: 148px; height: 148px; padding: 8px; }
57
+ .embed .req { margin-top: 10px; } .embed h2 { margin-top: 14px; } .embed details { margin-top: 12px; }
58
+ .embed ol.steps li { margin-bottom: 7px; }
30
59
  </style>
31
60
  <script>if (/[?&]embed\b/.test(location.search)) document.documentElement.classList.add('embed')</script>
32
61
  <h1>Tether</h1>
33
- <p class="muted" id="summary">Loading…</p>
34
- <p class="muted" id="remote"></p>
35
- <section id="pending-card" class="card alert" hidden>
36
- <strong>An iPhone wants to connect</strong>
37
- <table id="pending"></table>
62
+ <p id="status" class="mute">Loading…</p>
63
+ <p id="remote" class="mute"></p>
64
+
65
+ <section id="req" class="req" hidden aria-live="assertive">
66
+ <p class="who" id="req-who"></p>
67
+ <p class="mute" id="req-sub"></p>
68
+ <div class="acts"><button class="primary" id="approve">Approve</button><button id="deny">Deny</button></div>
38
69
  </section>
39
- <section class="card" id="pair-card">
40
- <img id="qr" alt="Pairing QR code" hidden>
70
+
71
+ <section id="pair" class="pair">
72
+ <div class="qrbox"><img id="qr" alt="Pairing code for Tether" hidden><span class="wait" id="qr-wait">Making a code…</span></div>
41
73
  <div>
42
- <strong>Pair an iPhone</strong>
43
- <p class="muted embed-only">Scan in Tether, then Approve here.</p>
44
- <ol>
45
- <li>Open Tether on the iPhone (same Wi-Fi as this Mac, or Tailscale on both).</li>
46
- <li>Tap <em>Scan QR code</em> and point it at this code.</li>
47
- <li>Click <em>Approve</em> here when the request appears.</li>
74
+ <ol class="steps">
75
+ <li id="step-cam">Open the iPhone <strong>Camera</strong> and point it at the code, then tap <strong>Open in Tether</strong>. Or tap <strong>Scan code</strong> in Tether.</li>
76
+ <li id="step-app" hidden>Open Tether on the iPhone and tap <strong>Scan code</strong>.</li>
77
+ <li>Tap <strong>Connect</strong> on the iPhone.</li>
78
+ <li>Click <strong>Approve</strong> here.</li>
48
79
  </ol>
49
- <p class="muted" id="exp"></p>
50
- <button id="pair">New code</button>
51
- <details><summary class="muted">Can't scan? Paste this code instead</summary><code id="payload"></code></details>
80
+ <p class="mute">The iPhone needs the Tether app and the same Wi‑Fi as this Mac (or Tailscale on both).</p>
81
+ <div class="tools"><span class="mute" id="exp"></span><button class="link" id="new">New code</button><button class="link" id="copy">Copy pairing link</button><button class="link" id="cancel-add" hidden>Cancel</button></div>
52
82
  </div>
53
83
  </section>
54
- <h2>Paired devices</h2>
84
+ <p id="note" class="note" hidden role="status"></p>
85
+
86
+ <h2>Paired iPhones</h2>
55
87
  <table id="devices"></table>
56
- <details id="gw"><summary class="muted">Gateway details</summary>
57
- <p><button id="start">Start</button><button id="stop">Stop</button></p>
58
- <table id="status"></table>
88
+ <p class="addrow" id="addrow" hidden><button id="add">Add an iPhone</button></p>
89
+
90
+ <details id="gw"><summary>Connection details</summary>
91
+ <table id="status-table"></table>
92
+ <p style="margin-top:8px"><button id="start">Start</button> <button id="stop">Stop</button></p>
59
93
  </details>
60
94
  <script>
61
95
  const $ = id => document.getElementById(id)
62
96
  const esc = s => String(s).replace(/[&<>"']/g, c => '&#' + c.charCodeAt(0) + ';')
63
- const post = (path, body) => fetch(path, { method: 'POST', headers: { 'x-dsh-admin': '1', 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) })
64
- .then(r => r.json()).then(s => s.error ? alert(s.error) : render(s))
65
- let lastPayload, expiresAt, asking = false
66
97
  const embed = document.documentElement.classList.contains('embed')
98
+ let state, lastPayload, asking = false, busy = false, flash = null
99
+ // With a paired iPhone the code hides behind "Add an iPhone"; a newly paired iPhone hides it again.
100
+ let adding = false, paired = 0
101
+ // Write only a change: a table rebuilt every second can lose a click on its buttons.
102
+ const put = (id, html) => { if ($(id).innerHTML !== html) $(id).innerHTML = html }
103
+ const short = fp => fp.length > 20 ? fp.slice(0, 8) + '…' + fp.slice(-8) : fp
104
+ const say = (text, kind = '') => { flash = { text, kind, until: Date.now() + 8000 }; paintNote() }
105
+ // One change at a time: a second click while a request runs does nothing.
106
+ async function post(path, body) {
107
+ if (busy) return
108
+ busy = true
109
+ try {
110
+ const r = await fetch(path, { method: 'POST', headers: { 'x-dsh-admin': '1', 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) })
111
+ const s = await r.json()
112
+ if (s.error) say(s.error, 'warn'); else render(s)
113
+ } catch { say('The Tether gateway did not answer. Is DSH still running?', 'warn') }
114
+ finally { busy = false }
115
+ }
116
+ const left = iso => Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 1000))
117
+ const mmss = s => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
118
+ const RESULT = {
119
+ approved: n => [n + ' is connected.', 'ok'],
120
+ denied: n => ['You denied ' + n + '.', ''],
121
+ cancelled: n => [n + ' stopped waiting before you clicked Approve. Scan the code again on the iPhone.', 'warn'],
122
+ expired: n => ['The request from ' + n + ' timed out. Scan the code again on the iPhone.', 'warn'],
123
+ }
124
+ function paintNote() {
125
+ const l = state?.last, f = flash && flash.until > Date.now() ? flash : null
126
+ const [text, kind] = f ? [f.text, f.kind] : l && RESULT[l.result] ? RESULT[l.result](l.name) : ['', '']
127
+ $('note').hidden = !text; $('note').textContent = text; $('note').className = 'note ' + kind
128
+ }
67
129
  function render(s) {
68
- const t = s.tailscale ?? {}
69
- $('remote').innerHTML = embed ? (t.online && t.ip ? '<span class="dot ok"></span>Tailscale <code>' + esc(t.ip) + '</code>' : t.installed ? '<span class="dot warn"></span>Tailscale off' : '')
70
- : 'Remote access: ' + (!t.installed ? 'install <a href="https://tailscale.com/download" target="_blank" rel="noopener">Tailscale</a> on this Mac and the iPhone (same account) to connect away from home.'
71
- : !t.online || !t.ip ? '<span class="dot warn"></span>Tailscale is off. Turn it on to reach this Mac away from home.'
72
- : '<span class="dot ok"></span>Phones connect remotely via <code>' + esc(t.ip + ':' + s.port) + '</code>' + (t.name ? ' (' + esc(t.name) + ')' : ''))
73
- $('status').innerHTML = [['Gateway', s.running ? 'running' : 'stopped'], ['IP', s.address ?? 'no Wi-Fi address'], ['Port', s.port],
74
- ['TLS', s.tls], ['Fingerprint (SHA-256)', s.fingerprint ?? '-'], ['DSH host', s.upstream]]
75
- .map(([k, v]) => '<tr><th>' + k + '</th><td><code>' + esc(v) + '</code></td></tr>').join('')
76
- const dsh = s.upstream === 'connected'
77
- $('summary').innerHTML = !s.running ? '<span class="dot bad"></span>Gateway stopped' + (s.address ? '' : ' (no Wi-Fi address)')
78
- : '<span class="dot ' + (dsh ? 'ok' : 'warn') + '"></span>' + (dsh ? 'Ready' : 'DSH not reachable: ' + esc(s.upstream)) +
79
- (embed ? ' · Wi-Fi <code>' : ' · phones connect to <code>') + esc(s.address + ':' + s.port) + '</code>'
80
- // Keep a fresh code on screen while this page is open; codes are one-time and still need Approve.
81
- if (!s.code && s.running && !asking && document.visibilityState === 'visible') { asking = true; post('/pair').finally(() => { asking = false }) }
82
- expiresAt = s.code && Date.parse(s.code.expiresAt)
83
- if (s.code) {
84
- $('payload').textContent = s.code.payload
85
- if (s.code.qr && lastPayload !== s.code.payload) { lastPayload = s.code.payload; $('qr').src = '/qr.png?t=' + Date.now() }
130
+ state = s
131
+ const t = s.tailscale ?? {}, dsh = s.upstream === 'connected'
132
+ $('status').innerHTML = !s.running ? '<span class="dot bad"></span>Off' + (s.address ? '' : ': this Mac has no Wi‑Fi address')
133
+ : '<span class="dot ' + (dsh ? 'ok' : 'warn') + '"></span>' + (dsh ? 'Ready for iPhones' : 'DSH is not answering') + ' · ' + esc(s.mac)
134
+ $('remote').innerHTML = !s.running ? '' : t.online && t.ip ? 'Away from home: Tailscale is on (' + esc(t.ip) + ').'
135
+ : embed ? '' : t.installed ? 'Away from home: turn Tailscale on.' : 'Away from home: install <a href="https://tailscale.com/download" target="_blank" rel="noopener">Tailscale</a> on this Mac and the iPhone.'
136
+
137
+ // One waiting request at a time is the normal case; show the oldest.
138
+ const p = s.pending[0]
139
+ if (s.devices.length > paired) adding = false
140
+ paired = s.devices.length
141
+ const showPair = !p && (paired === 0 || adding)
142
+ $('req').hidden = !p; $('pair').hidden = !showPair
143
+ $('addrow').hidden = !!p || showPair; $('cancel-add').hidden = paired === 0
144
+ if (p) {
145
+ $('req-who').textContent = p.name + ' wants to connect'
146
+ // An older gateway (before a dsh restart) sends no expiresAt: it waits 120 s.
147
+ $('req-sub').textContent = 'From ' + p.ip.replace(/^::ffff:/, '') + ' · ' + mmss(left(p.expiresAt ?? new Date(p.at + 120e3).toISOString())) + ' left' + (s.pending.length > 1 ? ' · ' + (s.pending.length - 1) + ' more waiting' : '')
148
+ $('approve').dataset.id = $('deny').dataset.id = p.id
86
149
  }
87
- $('qr').hidden = !s.code?.qr
88
- tick()
89
- $('pending-card').hidden = !s.pending.length
90
- $('pending').innerHTML = s.pending.map(p => '<tr><td>' + esc(p.name) + '</td><td class="muted">' + esc(p.ip) + '</td><td>' +
91
- '<button class="primary" data-a="approve" data-id="' + esc(p.id) + '">Approve</button><button data-a="deny" data-id="' + esc(p.id) + '">Deny</button></td></tr>').join('')
92
- $('devices').innerHTML = s.devices.map(d => '<tr><td><span class="dot ' + (d.connections ? 'ok' : 'bad') + '"></span>' + esc(d.name) + '</td><td class="muted">' +
93
- (d.connections ? 'online' : 'offline') + (embed ? '' : ' · expires ' + esc(new Date(d.expiresAt).toLocaleDateString())) +
94
- '</td><td><button data-a="revoke" data-id="' + esc(d.id) + '">Revoke</button></td></tr>').join('') || '<tr><td class="muted">No iPhone paired yet</td></tr>'
95
- }
96
- function tick() {
97
- const left = expiresAt ? Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) : 0
98
- const mmss = Math.floor(left / 60) + ':' + String(left % 60).padStart(2, '0')
99
- $('exp').textContent = left ? (embed ? 'Code valid ' : 'One-time code, valid for ') + mmss : 'Preparing a code…'
150
+
151
+ // Keep a fresh code on screen while the code shows; codes are one-time and still need Approve.
152
+ if (!s.code && s.running && showPair && !asking && document.visibilityState === 'visible') { asking = true; post('/pair').finally(() => { asking = false }) }
153
+ if (s.code?.qr && lastPayload !== s.code.payload) { lastPayload = s.code.payload; $('qr').src = '/qr.png?t=' + Date.now() }
154
+ $('qr').hidden = !s.code?.qr; $('qr-wait').hidden = !!s.code?.qr
155
+ $('qr-wait').textContent = s.running ? 'Making a code…' : 'Gateway is off'
156
+ $('exp').textContent = s.code ? 'Code works for ' + mmss(left(s.code.expiresAt)) : ''
157
+ // A gateway from before a dsh restart still makes a JSON code: the Camera app cannot open that one.
158
+ const cam = !s.code || s.code.payload.startsWith('tether://')
159
+ $('step-cam').hidden = !cam; $('step-app').hidden = cam
160
+ paintNote()
161
+
162
+ put('devices', s.devices.map(d => '<tr><td><span class="dot ' + (d.connections ? 'ok' : '') + '"></span>' + esc(d.name) +
163
+ ' <span class="mute">' + (d.connections ? 'online' : 'offline') + '</span></td><td><button class="link" data-revoke="' + esc(d.id) + '">Remove</button></td></tr>').join('')
164
+ || '<tr><td class="mute">No iPhone yet.</td></tr>')
165
+ // The certificate hash is long: a short form fits the popover, Copy gives the whole value.
166
+ const cert = s.fingerprint ? '<code title="SHA-256: ' + esc(s.fingerprint) + '">' + esc(short(s.fingerprint)) + '</code><button class="link" data-copy="' + esc(s.fingerprint) + '">Copy</button>' : '<code>-</code>'
167
+ put('status-table', [['Gateway', s.running ? 'running' : 'stopped'], ['Address', (s.address ?? 'no Wi‑Fi address') + ':' + s.port], ['DSH', s.upstream]]
168
+ .map(([k, v]) => '<tr><td>' + k + '</td><td><code>' + esc(v) + '</code></td></tr>').join('') + '<tr><td>Certificate</td><td>' + cert + '</td></tr>')
100
169
  }
170
+ setInterval(() => { if (state) render(state) }, 1000)
171
+ document.addEventListener('click', e => {
172
+ const b = e.target.closest('button'); if (!b) return
173
+ if (b.id === 'approve' || b.id === 'deny') post('/' + b.id, { id: b.dataset.id })
174
+ else if (b.dataset.revoke) { if (confirm('Remove this iPhone? It disconnects now and must pair again.')) post('/revoke', { id: b.dataset.revoke }) }
175
+ else if (b.id === 'copy') { if (state?.code) navigator.clipboard.writeText(state.code.payload).then(() => say('Pairing link copied. Paste it in Tether on the iPhone.', 'ok'), () => say('Could not copy.', 'warn')) }
176
+ else if (b.dataset.copy) navigator.clipboard.writeText(b.dataset.copy).then(() => say('Certificate hash copied.', 'ok'), () => say('Could not copy.', 'warn'))
177
+ else if (b.id === 'add' || b.id === 'cancel-add') { adding = b.id === 'add'; if (state) render(state) }
178
+ else if (b.id === 'new') post('/pair')
179
+ else if (b.id === 'start' || b.id === 'stop') post('/' + b.id)
180
+ })
101
181
  // Embedded in DSH: report our height so the popover fits the content and never scrolls.
102
182
  if (embed && parent !== window) {
103
183
  let last = 0
@@ -105,13 +185,7 @@ if (embed && parent !== window) {
105
185
  const report = () => { const h = Math.ceil(document.body.getBoundingClientRect().height); if (h !== last) { last = h; parent.postMessage({ type: 'dshm-height', h }, '*') } }
106
186
  new ResizeObserver(report).observe(document.body); addEventListener('load', report)
107
187
  }
108
- setInterval(tick, 1000)
109
- document.addEventListener('click', e => {
110
- const b = e.target.closest('button'); if (!b) return
111
- if (b.dataset.a) { if (b.dataset.a !== 'revoke' || confirm('Revoke this device? Its connections close immediately.')) post('/' + b.dataset.a, { id: b.dataset.id }) }
112
- else post('/' + b.id)
113
- })
114
- const poll = () => fetch('/state').then(r => r.json()).then(render).catch(() => {}).finally(() => setTimeout(poll, 1500))
188
+ const poll = () => fetch('/state').then(r => r.json()).then(render).catch(() => {}).finally(() => setTimeout(poll, 1000))
115
189
  poll()
116
190
  </script>
117
191
  </html>
@@ -34,12 +34,36 @@ export const RPC = new Set([
34
34
  'skills/list', 'commands/list', 'commands/execute',
35
35
  ])
36
36
  export const STREAMS = new Set(['session/follow', 'session/control', 'workspace/follow', 'userQuestions/attachWait'])
37
+ /** dsh-composer-plus routes the phone may call, with their methods: `/plus/<name>` → dsh `/api/composer-plus.<name>`.
38
+ * Only the chat-level features; never the switches (`{on}`), the updater, the screenshots or the Mobile connect route,
39
+ * so a phone cannot turn features off, install code or open ports. */
40
+ export const PLUS = new Map([
41
+ ['changes', ['GET']], ['diff', ['GET']], ['continue', ['POST']], ['grants', ['GET', 'POST']],
42
+ ['rollback', ['GET', 'POST']], ['delete-chat', ['POST']], ['ideas', ['POST']], ['later', ['GET', 'POST']], ['idea-history', ['GET', 'POST']],
43
+ ['queue-move', ['POST']], ['predict', ['POST']], ['complete', ['POST']],
44
+ ])
45
+ /** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
46
+ export const AUTO_REVIEW_ASK = 'Auto review denied tool '
47
+ export const isGrantedAsk = (v, grants) => v.event === 'approval/request' && typeof v.request?.reason === 'string'
48
+ && v.request.reason.startsWith(AUTO_REVIEW_ASK) && typeof v.request.toolName === 'string' && typeof v.agentId === 'string'
49
+ && Array.isArray(grants?.[v.agentId]) && grants[v.agentId].includes(v.request.toolName)
50
+ /** A switch body (`{on: true|false}`) or a setting (`{model}`) changes the Mac for every device: the phone never sends one. */
51
+ const isSwitch = b => Object.hasOwn(b, 'on') || Object.hasOwn(b, 'model')
37
52
  export const EVENTS = new Set([
38
53
  'agent-preset/selected', 'approval/request', 'api-session/activity', 'api-session/added', 'api-session/error',
39
54
  'api-session/removed', 'api-session/status', 'commands/change', 'goal/activation-changed',
40
55
  'permission-presets/catalog-changed', 'user-questions/request',
41
56
  ])
42
57
 
58
+ /** The QR / paste text: a tether:// link, so the iPhone Camera app offers "Open in Tether" (raw JSON only offered a web
59
+ * search). Tether still accepts the old JSON text. Opening the link never pairs by itself: the app asks first. */
60
+ export function pairLink({ host, port, fp, code, mac, ts }) {
61
+ const q = new URLSearchParams({ v: '1', host: String(host ?? ''), port: String(port), fp, code })
62
+ if (mac) q.set('mac', mac)
63
+ if (ts) q.set('ts', ts)
64
+ return 'tether://pair?' + String(q).replace(/\+/g, '%20') // URLSearchParams writes a space as '+'; iOS URL parsing reads '+' as '+'
65
+ }
66
+
43
67
  const sha = s => createHash('sha256').update(s).digest('hex')
44
68
  const safeEq = (a, b) => timingSafeEqual(Buffer.from(sha(a), 'hex'), Buffer.from(sha(b), 'hex'))
45
69
  const ip4 = s => s.split('.').reduce((n, o) => (n << 8) + Number(o), 0) >>> 0
@@ -211,8 +235,8 @@ function makeUpstream(upstreamUrl) {
211
235
  }
212
236
  }
213
237
  return {
214
- rpc: (path, body) => retry(async () => {
215
- const r = await send('POST', base + path, body, { 'content-type': 'application/json', cookie })
238
+ rpc: (path, body, method = 'POST') => retry(async () => {
239
+ const r = await send(method, base + path, body, method === 'GET' ? { cookie } : { 'content-type': 'application/json', cookie })
216
240
  if (r.status === 401 || r.status === 403) throw Object.assign(new Error('upstream auth'), { status: r.status })
217
241
  return r
218
242
  }),
@@ -230,6 +254,7 @@ export function createGateway(opts = {}) {
230
254
  })
231
255
  const mac = opts.macName ?? macName()
232
256
  const codeTtl = opts.codeTtlMs ?? 5 * 60e3
257
+ const approveTtl = opts.approveTtlMs ?? 120e3
233
258
  const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
234
259
  mkdirSync(dir, { recursive: true, mode: 0o700 })
235
260
  const upstream = makeUpstream(opts.upstreamUrl)
@@ -296,10 +321,13 @@ export function createGateway(opts = {}) {
296
321
  }
297
322
 
298
323
  // ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
324
+ // The latest outcome, kept 60 s so the Mac page can say what happened after the request card goes away.
325
+ let last = null
326
+ const record = (p, result) => { last = { name: p.name, result, at: Date.now() } }
299
327
  function newCode() {
300
328
  const net = bound ?? netInfo()
301
329
  code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
302
- code.payload = JSON.stringify({ v: 1, host: net?.address, port, fp, code: code.value, mac, ...(ts.online && ts.ip ? { ts: ts.ip } : {}) })
330
+ code.payload = pairLink({ host: net?.address, port, fp, code: code.value, mac, ts: ts.online ? ts.ip : null })
303
331
  qrReady = false
304
332
  if (opts.qr !== false) {
305
333
  const c = code
@@ -318,14 +346,17 @@ export function createGateway(opts = {}) {
318
346
  const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
319
347
  pending.set(p.id, p)
320
348
  notify('Pairing request from ' + p.name)
321
- const t = setTimeout(() => settle(p.id, false), 120e3)
322
- res.on('close', () => { clearTimeout(t); pending.delete(p.id) })
349
+ const t = setTimeout(() => { if (pending.has(p.id)) { record(p, 'expired'); settle(p.id, false, false) } }, approveTtl)
350
+ // The phone gave up (Cancel, app closed, network gone): say so instead of letting the card just vanish.
351
+ res.on('close', () => { clearTimeout(t); if (pending.delete(p.id)) record(p, 'cancelled') })
323
352
  }
324
- function settle(id, approve) {
353
+ function settle(id, approve, log = true) {
325
354
  const p = pending.get(id)
326
355
  if (!p) return false
327
356
  pending.delete(id)
328
- if (!approve) { reply(p.res, 403, { error: 'pairing denied' }); return true }
357
+ if (p.res.destroyed || p.res.socket?.destroyed) { record(p, 'cancelled'); return false }
358
+ if (log) record(p, approve ? 'approved' : 'denied')
359
+ if (!approve) { reply(p.res, 403, { error: log ? 'pairing denied' : 'pairing expired' }); return true }
329
360
  const token = randomBytes(32).toString('base64url') // 256-bit; only its hash is stored
330
361
  const d = { id: randomUUID(), name: p.name, tokenHash: sha(token), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + TOKEN_TTL).toISOString() }
331
362
  devices.push(d); saveDevices()
@@ -346,6 +377,7 @@ export function createGateway(opts = {}) {
346
377
  if (outcome) answer(id, outcome).catch(() => {})
347
378
  return true
348
379
  }
380
+ const looking = new Set() // approvals whose grants are being read
349
381
  function onEventFrame(text) {
350
382
  let f
351
383
  try { f = JSON.parse(text) } catch { return }
@@ -354,9 +386,25 @@ export function createGateway(opts = {}) {
354
386
  const v = f.value ?? {}
355
387
  if (v.type === 'ready') { ev.clientId = v.clientId; upState = 'connected'; return } // host.home is dropped here
356
388
  if (v.type === 'emit' && EVENTS.has(v.event)) return toPhones({ type: 'emit', event: v.event, args: v.args })
389
+ if (v.type === 'cancel') looking.delete(v.eventId)
357
390
  if (v.type === 'cancel' && dropPending(v.eventId)) return toPhones({ type: 'cancel', eventId: v.eventId })
358
391
  if (v.type !== 'waterfall') return
392
+ if (v.event === 'approval/request') return grantedOrAsk(v)
359
393
  if (!EVENTS.has(v.event) || !evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
394
+ askPhones(v)
395
+ }
396
+ /** Allow for this chat, also with no dsh window open: the gateway allows a granted tool itself (the grants that the
397
+ * desktop and the phone save in dsh-composer-plus). Any other approval goes to the phones. */
398
+ async function grantedOrAsk(v) {
399
+ let grants
400
+ looking.add(v.eventId)
401
+ try { const r = await upstream.rpc('/api/composer-plus.grants', undefined, 'GET'); if (r.status === 200) grants = JSON.parse(r.body) } catch {}
402
+ if (!looking.delete(v.eventId)) return // answered elsewhere (the desktop) while the grants were read
403
+ if (isGrantedAsk(v, grants)) return answer(v.eventId, { kind: 'result', value: 'allowed-once' }).catch(() => {})
404
+ if (!evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
405
+ askPhones(v)
406
+ }
407
+ function askPhones(v) {
360
408
  const frame = { type: 'waterfall', event: v.event, eventId: v.eventId, agentId: v.agentId, request: v.request }
361
409
  // shortcut: unanswered phone prompts fall back to 'next' (DSH desktop still decides) after a fixed timeout; make it configurable per event if users want longer.
362
410
  const timer = setTimeout(() => { if (dropPending(v.eventId, { kind: 'next' })) toPhones({ type: 'cancel', eventId: v.eventId }) }, waterfallTtl)
@@ -404,7 +452,8 @@ export function createGateway(opts = {}) {
404
452
  const dev = auth(req)
405
453
  if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
406
454
  track(dev.id, req.socket)
407
- if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac })
455
+ if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac, plus: [...PLUS.keys()] })
456
+ if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
408
457
  if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
409
458
  const method = path.slice(5)
410
459
  let body
@@ -417,6 +466,22 @@ export function createGateway(opts = {}) {
417
466
  res.writeHead(up.status, { 'content-type': up.headers['content-type'] ?? 'application/json' }); res.end(up.body)
418
467
  } catch { reply(res, 502, { error: 'DSH host unavailable' }) }
419
468
  }
469
+ /** A dsh-composer-plus route (allowlist `PLUS`): the query (GET) or the JSON body (POST) goes to dsh as it is. */
470
+ async function plus(req, res, name) {
471
+ if (!PLUS.get(name)?.includes(req.method)) return reply(res, 403, { error: 'endpoint not allowed' })
472
+ let body
473
+ if (req.method === 'POST') {
474
+ try { body = JSON.parse(await readBody(req, MAX_BODY)) } catch { return reply(res, 400, { error: 'bad request' }) }
475
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return reply(res, 400, { error: 'bad request' })
476
+ if (isSwitch(body)) return reply(res, 403, { error: 'endpoint not allowed' })
477
+ body = JSON.stringify(body)
478
+ }
479
+ try {
480
+ const up = await upstream.rpc('/api/composer-plus.' + name + new URL(req.url, 'https://x').search, body, req.method)
481
+ // An older dsh-composer-plus without this route answers 404: the phone hides the feature.
482
+ res.writeHead(up.status, { 'content-type': up.headers['content-type'] ?? 'application/json' }); res.end(up.body)
483
+ } catch { reply(res, 502, { error: 'DSH host unavailable' }) }
484
+ }
420
485
  function onUpgrade(req, socket, head) {
421
486
  const ip = socket.remoteAddress
422
487
  if (!allowReq(ip)) return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n')
@@ -518,7 +583,8 @@ export function createGateway(opts = {}) {
518
583
  mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
519
584
  fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
520
585
  code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
521
- pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at })),
586
+ pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
587
+ last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
522
588
  devices: devices.map(({ tokenHash, ...d }) => ({ ...d, connections: devSockets.get(d.id)?.size ?? 0 })),
523
589
  })
524
590
 
@@ -537,8 +603,9 @@ export function createGateway(opts = {}) {
537
603
  try { b = JSON.parse((await readBody(req, 4096)) || '{}') } catch { return reply(res, 400, { error: 'bad request' }) }
538
604
  try {
539
605
  if (path === '/pair') { if (!running) await start(); else await refreshTs(); newCode() }
540
- else if (path === '/approve') settle(b.id, true)
541
- else if (path === '/deny') settle(b.id, false)
606
+ else if (path === '/approve' || path === '/deny') {
607
+ if (!settle(b.id, path === '/approve')) return reply(res, 409, { error: 'This request is no longer waiting. The iPhone stopped waiting or the request timed out. Scan the code again.' })
608
+ }
542
609
  else if (path === '/revoke') revoke(b.id)
543
610
  else if (path === '/start') await start()
544
611
  else if (path === '/stop') await stop()