dsh-composer-plus 1.11.3 → 1.13.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.11.3",
3
+ "version": "1.13.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",
@@ -34,12 +34,15 @@
34
34
  "ios",
35
35
  "iphone",
36
36
  "localization",
37
+ "mark-as-read",
38
+ "message-link",
37
39
  "mobile",
38
40
  "notifications",
39
41
  "plan-mode",
40
42
  "plugin",
41
43
  "queue",
42
44
  "recent-projects",
45
+ "redo",
43
46
  "remote-control",
44
47
  "restart",
45
48
  "resume",
@@ -50,7 +53,8 @@
50
53
  "tailscale",
51
54
  "tether",
52
55
  "translation",
53
- "undo"
56
+ "undo",
57
+ "whitespace"
54
58
  ],
55
59
  "exports": {
56
60
  ".": "./index.js",
@@ -73,8 +77,9 @@
73
77
  "assets/features/*.webp"
74
78
  ],
75
79
  "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/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",
80
+ "test": "node scripts/build-locales.mjs --check && node scripts/build-docs.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/queue-edit.test.mjs && node test/message-link.test.mjs && node test/mark-read.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/unread.test.mjs && node test/unread-client.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/grant-ttl.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/chat-changes.test.mjs && node test/changes-tree.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/prompt-improve.test.mjs && node test/prompt-improve-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 && node test/readme-labels.test.mjs && node test/feature-docs.test.mjs && node test/routes.test.mjs",
77
81
  "build:locales": "node scripts/build-locales.mjs",
82
+ "build:readme": "node scripts/build-docs.mjs",
78
83
  "prepublishOnly": "npm test"
79
84
  },
80
85
  "dsh": {
@@ -49,6 +49,10 @@
49
49
  #status-table td { text-align: left; overflow-wrap: anywhere; } #status-table td:first-child { color: var(--mute); width: 30%; }
50
50
  #status-table .link { margin-left: 8px; font-size: 12px; }
51
51
  .addrow { margin-top: 8px; }
52
+ .sub { font-size: 12px; color: var(--mute); }
53
+ label.opt { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; } label.opt input { margin: 3px 0 0; }
54
+ #log-table td { font-size: 12px; padding: 5px 0; text-align: left; } #log-table td:first-child { white-space: nowrap; padding-right: 8px; color: var(--mute); }
55
+ #log-table .bad { color: var(--red); } #log-table .ok { color: var(--green); }
52
56
 
53
57
  /* ?embed=1: inside DSH's "Mobile connect" popover */
54
58
  .embed body { max-width: none; margin: 0; padding: 4px 12px 12px; background: transparent; overflow: hidden; }
@@ -87,6 +91,16 @@
87
91
  <table id="devices"></table>
88
92
  <p class="addrow" id="addrow" hidden><button id="add">Add an iPhone</button></p>
89
93
 
94
+ <section id="sec">
95
+ <h2>Security</h2>
96
+ <label class="opt"><input type="checkbox" id="tailnet-only"><span><strong>Tailscale only</strong><br><span class="sub">Refuse connections from the local network (Wi‑Fi, cable). Only iPhones that reach this Mac over Tailscale can connect. Use it on shared Wi‑Fi, for example in a café or an office.</span></span></label>
97
+ <p class="note warn" id="tailnet-warn" hidden>Tailscale is off on this Mac: no iPhone can connect now.</p>
98
+ </section>
99
+
100
+ <details id="log"><summary id="log-sum">Access log</summary>
101
+ <table id="log-table"></table>
102
+ </details>
103
+
90
104
  <details id="gw"><summary>Connection details</summary>
91
105
  <table id="status-table"></table>
92
106
  <p style="margin-top:8px"><button id="start">Start</button> <button id="stop">Stop</button></p>
@@ -109,10 +123,19 @@ async function post(path, body) {
109
123
  try {
110
124
  const r = await fetch(path, { method: 'POST', headers: { 'x-dsh-admin': '1', 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) })
111
125
  const s = await r.json()
112
- if (s.error) say(s.error, 'warn'); else render(s)
126
+ if (s.error) say(s.error, 'warn'); else { busy = false; render(s); return true }
113
127
  } catch { say('The Tether gateway did not answer. Is DSH still running?', 'warn') }
114
128
  finally { busy = false }
129
+ return false
115
130
  }
131
+ document.addEventListener('change', e => {
132
+ if (e.target.id !== 'tailnet-only') return
133
+ const on = e.target.checked
134
+ post('/settings', { tailnetOnly: on }).then(ok => {
135
+ if (ok) say(on ? 'Tailscale only is on. iPhones on the local network cannot connect.' : 'Tailscale only is off. iPhones on the same Wi‑Fi can connect again.', 'ok')
136
+ else if (state) e.target.checked = state.tailnetOnly // refused: show the saved value again
137
+ })
138
+ })
116
139
  const left = iso => Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 1000))
117
140
  const mmss = s => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
118
141
  const RESULT = {
@@ -121,6 +144,14 @@ const RESULT = {
121
144
  cancelled: n => [n + ' stopped waiting before you clicked Approve. Scan the code again on the iPhone.', 'warn'],
122
145
  expired: n => ['The request from ' + n + ' timed out. Scan the code again on the iPhone.', 'warn'],
123
146
  }
147
+ // Access log reasons, as the user reads them.
148
+ const REASON = {
149
+ lan: 'Wi‑Fi', tailnet: 'Tailscale', paired: 'paired', network: 'not this network', 'tailnet-only': 'Tailscale only',
150
+ tls: 'TLS handshake failed', rate: 'too many requests', token: 'unknown or removed key', code: 'wrong pairing code',
151
+ denied: 'pairing denied', expired: 'pairing timed out', cancelled: 'iPhone stopped waiting',
152
+ }
153
+ // Today: the time only; earlier: the date and the time.
154
+ const when = iso => { const d = new Date(iso); return d.toDateString() === new Date().toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) }
124
155
  function paintNote() {
125
156
  const l = state?.last, f = flash && flash.until > Date.now() ? flash : null
126
157
  const [text, kind] = f ? [f.text, f.kind] : l && RESULT[l.result] ? RESULT[l.result](l.name) : ['', '']
@@ -160,8 +191,20 @@ function render(s) {
160
191
  paintNote()
161
192
 
162
193
  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('')
194
+ ' <span class="mute">' + (d.connections ? 'online' : d.lastSeenAt ? 'last seen ' + esc(when(d.lastSeenAt)) : 'never connected') + '</span>' +
195
+ (d.createdAt ? '<br><span class="sub">Paired ' + esc(new Date(d.createdAt).toLocaleDateString()) + '</span>' : '') +
196
+ '</td><td><button class="link" data-revoke="' + esc(d.id) + '" data-name="' + esc(d.name) + '" aria-label="Remove ' + esc(d.name) + '">Remove</button></td></tr>').join('')
164
197
  || '<tr><td class="mute">No iPhone yet.</td></tr>')
198
+ // An older gateway (before a dsh restart) has no Tailscale only setting and no log: hide them.
199
+ const has = typeof s.tailnetOnly === 'boolean'
200
+ $('sec').hidden = $('log').hidden = !has
201
+ if (has && !busy) $('tailnet-only').checked = s.tailnetOnly
202
+ $('tailnet-warn').hidden = !(has && s.tailnetOnly && s.running && !t.online)
203
+ const log = s.log ?? [], day = log.filter(e => e.result === 'refused' && Date.now() - Date.parse(e.at) < 864e5).reduce((n, e) => n + e.n, 0)
204
+ $('log-sum').textContent = 'Access log' + (day ? ' · ' + day + ' refused in the last 24 hours' : '')
205
+ put('log-table', log.map(e => '<tr><td>' + esc(when(e.at)) + '</td><td><code>' + esc(e.ip) + '</code>' + (e.device ? ' · ' + esc(e.device) : '') +
206
+ '<br><span class="' + (e.result === 'allowed' ? 'ok' : 'bad') + '">' + (e.result === 'allowed' ? 'Allowed' : 'Refused') + '</span> · ' + esc(REASON[e.reason] ?? e.reason) +
207
+ (e.n > 1 ? ' · ×' + esc(e.n) : '') + '</td></tr>').join('') || '<tr><td class="mute">No connection attempts yet.</td></tr>')
165
208
  // The certificate hash is long: a short form fits the popover, Copy gives the whole value.
166
209
  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
210
  put('status-table', [['Gateway', s.running ? 'running' : 'stopped'], ['Address', (s.address ?? 'no Wi‑Fi address') + ':' + s.port], ['DSH', s.upstream]]
@@ -171,7 +214,8 @@ setInterval(() => { if (state) render(state) }, 1000)
171
214
  document.addEventListener('click', e => {
172
215
  const b = e.target.closest('button'); if (!b) return
173
216
  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 }) }
217
+ // One click: a lost phone is cut off at once. The iPhone pairs again to come back.
218
+ else if (b.dataset.revoke) post('/revoke', { id: b.dataset.revoke }).then(ok => { if (ok) say(b.dataset.name + ' was removed. It must pair again to connect.', 'ok') })
175
219
  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
220
  else if (b.dataset.copy) navigator.clipboard.writeText(b.dataset.copy).then(() => say('Certificate hash copied.', 'ok'), () => say('Could not copy.', 'warn'))
177
221
  else if (b.id === 'add' || b.id === 'cancel-add') { adding = b.id === 'add'; if (state) render(state) }
@@ -4,7 +4,7 @@
4
4
  import { createServer as createHttps, request as httpsRequest } from 'node:https'
5
5
  import { createServer as createHttp, request as httpRequest } from 'node:http'
6
6
  import { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
7
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
8
8
  import { execFile, execFileSync, spawn } from 'node:child_process'
9
9
  import { homedir, hostname, networkInterfaces } from 'node:os'
10
10
  import { dirname, join } from 'node:path'
@@ -13,6 +13,12 @@ import { fileURLToPath } from 'node:url'
13
13
  const HERE = dirname(fileURLToPath(import.meta.url))
14
14
  const TOKEN_TTL = 30 * 864e5
15
15
  const MAX_BODY = 16 << 20
16
+ /** Access log: at most this many entries on disk; the same attempt again within LOG_MERGE_MS counts up one entry. */
17
+ export const LOG_MAX = 200
18
+ const LOG_MERGE_MS = 60e3
19
+ /** A JSON file written owner-only and replaced atomically (a crash never leaves half a file). */
20
+ const saveJson = (file, data) => { const tmp = file + '.' + process.pid + '.tmp'; writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); renameSync(tmp, file) }
21
+ const readJson = (file, fallback) => { try { return JSON.parse(readFileSync(file, 'utf8')) ?? fallback } catch { return fallback } }
16
22
 
17
23
  /** The name the phone shows for this Mac in its Mac picker: the Mac's own "Computer Name", else the host name. */
18
24
  export function macName(read = () => execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', timeout: 2000 })) {
@@ -38,17 +44,18 @@ export const STREAMS = new Set(['session/follow', 'session/control', 'workspace/
38
44
  * Only the chat-level features; never the switches (`{on}`), the updater, the screenshots or the Mobile connect route,
39
45
  * so a phone cannot turn features off, install code or open ports. */
40
46
  export const PLUS = new Map([
41
- ['changes', ['GET']], ['diff', ['GET']], ['continue', ['POST']], ['grants', ['GET', 'POST']],
47
+ ['changes', ['GET']], ['diff', ['GET']], ['graph', ['GET']], ['continue', ['POST']], ['grants', ['GET', 'POST']],
42
48
  ['rollback', ['GET', 'POST']], ['delete-chat', ['POST']], ['ideas', ['POST']], ['later', ['GET', 'POST']], ['idea-history', ['GET', 'POST']],
43
- ['queue-move', ['POST']], ['predict', ['POST']], ['complete', ['POST']],
49
+ ['queue-move', ['POST']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
44
50
  ])
45
51
  /** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
46
52
  export const AUTO_REVIEW_ASK = 'Auto review denied tool '
47
53
  export const isGrantedAsk = (v, grants) => v.event === 'approval/request' && typeof v.request?.reason === 'string'
48
54
  && v.request.reason.startsWith(AUTO_REVIEW_ASK) && typeof v.request.toolName === 'string' && typeof v.agentId === 'string'
49
55
  && 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')
56
+ /** A switch body (`{on: true|false}`) or a setting (`{model}`, the grant time `{ttlMs}`) changes the Mac for every device:
57
+ * the phone never sends one. */
58
+ const isSwitch = b => Object.hasOwn(b, 'on') || Object.hasOwn(b, 'model') || Object.hasOwn(b, 'ttlMs')
52
59
  export const EVENTS = new Set([
53
60
  'agent-preset/selected', 'approval/request', 'api-session/activity', 'api-session/added', 'api-session/error',
54
61
  'api-session/removed', 'api-session/status', 'commands/change', 'goal/activation-changed',
@@ -81,8 +88,9 @@ export const isTailnet = ip => {
81
88
  return /^fd7a:115c:a1e0:/.test(ip)
82
89
  }
83
90
  // Accept LAN peers that reached us on the LAN address, or tailnet peers that reached us on our tailnet address.
84
- export const allowed = (remote, local, net) =>
85
- (inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
91
+ // `tailnetOnly` (the Mac page's "Tailscale only"): tailnet peers only, so nobody on a shared Wi-Fi reaches the gateway.
92
+ export const allowed = (remote, local, net, tailnetOnly = false) =>
93
+ (!tailnetOnly && inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
86
94
  (isTailnet(remote) && isTailnet(local))
87
95
  const TS_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']
88
96
  export function tailscaleInfo(bins = TS_BINS) {
@@ -206,9 +214,10 @@ export function wsConnect(url, headers = {}, tls = {}) {
206
214
  }
207
215
 
208
216
  // ---- Upstream: the DSH host on loopback, authenticated with the URL that dsh-composer-plus (Mobile connect) gives. ----
209
- function send(method, url, body, headers = {}) {
217
+ function send(method, url, body, headers = {}, signal) {
210
218
  return new Promise((ok, bad) => {
211
- const r = httpRequest(url, { method, headers, agent: false }, res => {
219
+ // `signal`: the phone's request closed, so dsh's request goes too (a model call on the Mac stops).
220
+ const r = httpRequest(url, { method, headers, agent: false, signal }, res => {
212
221
  const parts = []
213
222
  res.on('data', d => parts.push(d))
214
223
  res.on('end', () => ok({ status: res.statusCode, headers: res.headers, body: Buffer.concat(parts) }))
@@ -237,8 +246,8 @@ function makeUpstream(upstreamUrl) {
237
246
  }
238
247
  }
239
248
  return {
240
- rpc: (path, body, method = 'POST') => retry(async () => {
241
- const r = await send(method, base + path, body, method === 'GET' ? { cookie } : { 'content-type': 'application/json', cookie })
249
+ rpc: (path, body, method = 'POST', signal) => retry(async () => {
250
+ const r = await send(method, base + path, body, method === 'GET' ? { cookie } : { 'content-type': 'application/json', cookie }, signal)
242
251
  if (r.status === 401 || r.status === 403) throw Object.assign(new Error('upstream auth'), { status: r.status })
243
252
  return r
244
253
  }),
@@ -258,11 +267,36 @@ export function createGateway(opts = {}) {
258
267
  const codeTtl = opts.codeTtlMs ?? 5 * 60e3
259
268
  const approveTtl = opts.approveTtlMs ?? 120e3
260
269
  const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
270
+ const seenSaveMs = opts.seenSaveMs ?? 60e3 // the last connection times reach devices.json at most this often
261
271
  mkdirSync(dir, { recursive: true, mode: 0o700 })
262
272
  const upstream = makeUpstream(opts.upstreamUrl)
263
273
  const devFile = join(dir, 'devices.json')
264
274
  let devices = existsSync(devFile) ? JSON.parse(readFileSync(devFile, 'utf8')) : []
265
- const saveDevices = () => writeFileSync(devFile, JSON.stringify(devices, null, 2), { mode: 0o600 })
275
+ let seenDirty = false, seenSavedAt = 0 // a `lastSeenAt` that is only in memory yet; when devices.json was written
276
+ // The flags change only after a good write: a failed one is tried again at the next watch tick (and at close).
277
+ const saveDevices = () => { saveJson(devFile, devices); seenDirty = false; seenSavedAt = Date.now() }
278
+ // "Tailscale only" (the Mac page): kept in its own file, so it survives a restart of the gateway and of dsh.
279
+ const settingsFile = join(dir, 'settings.json')
280
+ let tailnetOnly = readJson(settingsFile, {})?.tailnetOnly === true
281
+ // ---- Access log: each connection attempt with its outcome, oldest first (memory and access.json). ----
282
+ const logFile = join(dir, 'access.json')
283
+ let log = [readJson(logFile, [])].flat().filter(e => typeof e?.at === 'string' && typeof e.ip === 'string' && Number.isSafeInteger(e.n)).slice(-LOG_MAX), logTimer
284
+ // ponytail: a failed write is dropped; the list in memory stays right and the next attempt writes it again.
285
+ const flushLog = () => { clearTimeout(logTimer); logTimer = undefined; try { saveJson(logFile, log) } catch {} }
286
+ /** `result`: allowed | refused. `reason`: lan, tailnet, paired (allowed); network, tailnet-only, tls, rate, token, code,
287
+ * denied, expired, cancelled (refused). The same attempt again within a minute counts up one entry (`n`), so a phone
288
+ * that retries or a scanner cannot push the other entries out at once. */
289
+ function logAttempt(ip, device, result, reason) {
290
+ ip = String(ip ?? '').replace(/^::ffff:/i, '')
291
+ const now = Date.now()
292
+ const i = log.findLastIndex(e => now - Date.parse(e.at) < LOG_MERGE_MS && e.ip === ip && e.device === device && e.result === result && e.reason === reason)
293
+ const e = i < 0 ? { ip, device, result, reason, n: 0 } : log.splice(i, 1)[0]
294
+ e.at = new Date(now).toISOString(); e.n++
295
+ log.push(e)
296
+ if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX)
297
+ logTimer ??= setTimeout(flushLog, 2000)
298
+ logTimer.unref?.()
299
+ }
266
300
  // Tailscale status: cached 30 s, refreshed on start and on new pairing codes. Tests inject opts.tailscale.
267
301
  let ts = { installed: false, online: false, ip: null, name: null }, tsAt = 0
268
302
  const refreshTs = async () => {
@@ -302,11 +336,15 @@ export function createGateway(opts = {}) {
302
336
  if (Date.parse(d.expiresAt) - Date.now() < TOKEN_TTL / 2) { d.expiresAt = new Date(Date.now() + TOKEN_TTL).toISOString(); saveDevices() }
303
337
  return d
304
338
  }
305
- function track(id, socket) {
306
- let s = devSockets.get(id)
307
- if (!s) devSockets.set(id, s = new Set())
339
+ /** A paired phone's request on a socket: a new socket is one connection (logged, and the device's last connection). */
340
+ function track(d, socket) {
341
+ let s = devSockets.get(d.id)
342
+ if (!s) devSockets.set(d.id, s = new Set())
308
343
  if (s.has(socket)) return
309
344
  s.add(socket); socket.once('close', () => s.delete(socket))
345
+ logAttempt(socket.remoteAddress, d.name, 'allowed', isTailnet(socket.remoteAddress) ? 'tailnet' : 'lan')
346
+ // In memory at once; the network watch writes it at most once a minute (and close() at the end).
347
+ d.lastSeenAt = new Date().toISOString(); seenDirty = true
310
348
  }
311
349
  function revoke(id) {
312
350
  const before = devices.length
@@ -325,7 +363,10 @@ export function createGateway(opts = {}) {
325
363
  // ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
326
364
  // The latest outcome, kept 60 s so the Mac page can say what happened after the request card goes away.
327
365
  let last = null
328
- const record = (p, result) => { last = { name: p.name, result, at: Date.now() } }
366
+ const record = (p, result) => {
367
+ last = { name: p.name, result, at: Date.now() }
368
+ logAttempt(p.ip, p.name, result === 'approved' ? 'allowed' : 'refused', result === 'approved' ? 'paired' : result)
369
+ }
329
370
  function newCode() {
330
371
  const net = bound ?? netInfo()
331
372
  code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
@@ -343,7 +384,7 @@ export function createGateway(opts = {}) {
343
384
  let b
344
385
  try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
345
386
  const ok = code && typeof b?.code === 'string' && Date.now() < code.expires && safeEq(b.code, code.value)
346
- if (!ok) { fail(ip); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
387
+ if (!ok) { fail(ip); logAttempt(ip, null, 'refused', 'code'); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
347
388
  code = null // one-time: a replayed code is rejected
348
389
  const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
349
390
  pending.set(p.id, p)
@@ -448,12 +489,12 @@ export function createGateway(opts = {}) {
448
489
  // ---- Phone-facing HTTPS ----
449
490
  async function onRequest(req, res) {
450
491
  const ip = req.socket.remoteAddress
451
- if (!allowReq(ip)) return reply(res, 429, { error: 'rate limited' })
492
+ if (!allowReq(ip)) { logAttempt(ip, null, 'refused', 'rate'); return reply(res, 429, { error: 'rate limited' }) }
452
493
  const path = new URL(req.url, 'https://x').pathname
453
494
  if (req.method === 'POST' && path === '/pair') return pair(req, res, ip)
454
495
  const dev = auth(req)
455
- if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
456
- track(dev.id, req.socket)
496
+ if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
497
+ track(dev, req.socket)
457
498
  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()] })
458
499
  if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
459
500
  if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
@@ -478,21 +519,26 @@ export function createGateway(opts = {}) {
478
519
  if (isSwitch(body)) return reply(res, 403, { error: 'endpoint not allowed' })
479
520
  body = JSON.stringify(body)
480
521
  }
522
+ // Improve prompt only (a model call that changes nothing): when the phone gives up (time-out, Cancel, a new tap), stop
523
+ // dsh's request, so the call ends and frees its chat. Every other route runs to its end on the Mac: the phone's first
524
+ // address gives up after 5 s, and a stopped rollback or delete would leave half a change.
525
+ const gone = name === 'improve' ? new AbortController() : undefined
526
+ if (gone) res.on('close', () => { if (!res.writableFinished) gone.abort() })
481
527
  try {
482
- const up = await upstream.rpc('/api/composer-plus.' + name + new URL(req.url, 'https://x').search, body, req.method)
528
+ const up = await upstream.rpc('/api/composer-plus.' + name + new URL(req.url, 'https://x').search, body, req.method, gone?.signal)
483
529
  // An older dsh-composer-plus without this route answers 404: the phone hides the feature.
484
530
  res.writeHead(up.status, { 'content-type': up.headers['content-type'] ?? 'application/json' }); res.end(up.body)
485
531
  } catch { reply(res, 502, { error: 'DSH host unavailable' }) }
486
532
  }
487
533
  function onUpgrade(req, socket, head) {
488
534
  const ip = socket.remoteAddress
489
- if (!allowReq(ip)) return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n')
535
+ if (!allowReq(ip)) { logAttempt(ip, null, 'refused', 'rate'); return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n') }
490
536
  if (new URL(req.url, 'https://x').pathname !== '/api/remote.mux') return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
491
537
  const dev = auth(req)
492
- if (!dev) { fail(ip); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
538
+ if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
493
539
  const ws = acceptWs(req, socket, head)
494
540
  if (!ws) return
495
- track(dev.id, socket)
541
+ track(dev, socket)
496
542
  phoneMux(ws)
497
543
  }
498
544
  const isId = v => typeof v === 'string' && v.length > 0 && v.length <= 128
@@ -542,27 +588,42 @@ export function createGateway(opts = {}) {
542
588
  await refreshTs()
543
589
  const s = createHttps({ key, cert, minVersion: 'TLSv1.2' }, onRequest)
544
590
  s.on('connection', sock => {
545
- if (!allowed(sock.remoteAddress, sock.localAddress, net)) return sock.destroy() // LAN or tailnet only, decided by the socket's own addresses
591
+ // LAN or tailnet only (or tailnet only), decided by the socket's own addresses
592
+ if (!allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) {
593
+ logAttempt(sock.remoteAddress, null, 'refused', tailnetOnly && allowed(sock.remoteAddress, sock.localAddress, net) ? 'tailnet-only' : 'network')
594
+ return sock.destroy()
595
+ }
546
596
  conns.add(sock); sock.once('close', () => conns.delete(sock))
547
597
  })
548
598
  s.on('upgrade', onUpgrade)
549
599
  // Kept for diagnosis only (no secrets): why a phone's TLS handshake died, shown in admin /state.
550
- s.on('tlsClientError', (e, sock) => { lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }; sock.destroy() })
600
+ s.on('tlsClientError', (e, sock) => {
601
+ lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }
602
+ // A socket refused above also ends here: it is in the log once, with that reason.
603
+ if (allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) logAttempt(sock.remoteAddress, null, 'refused', 'tls')
604
+ sock.destroy()
605
+ })
551
606
  await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
552
607
  server = s; bound = net; port = s.address().port; running = true
553
- if (opts.bonjour !== false) {
554
- // The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
555
- bonjour = spawn('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
556
- bonjour.on('error', () => {})
557
- }
608
+ advertise()
558
609
  connectEvents()
559
610
  }
611
+ // Bonjour gives each device on the Wi-Fi the Mac name, the port and the certificate fingerprint: not while "Tailscale only"
612
+ // is on (the phone reaches the Mac over the tailnet with the address from pairing; mDNS does not cross Tailscale).
613
+ function advertise() {
614
+ const want = running && !tailnetOnly && opts.bonjour !== false
615
+ if (want === !!bonjour) return
616
+ if (!want) { bonjour.kill(); bonjour = null; return }
617
+ // The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
618
+ bonjour = (opts.spawn ?? spawn)('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
619
+ bonjour.on('error', () => {})
620
+ }
560
621
  async function stop(keepWanted = false) {
561
622
  wanted = keepWanted
562
623
  if (!running) return
563
624
  running = false
564
625
  clearTimeout(evTimer)
565
- bonjour?.kill(); bonjour = null
626
+ advertise()
566
627
  ev.ws?.close()
567
628
  for (const sock of conns) sock.destroy()
568
629
  await new Promise(ok => server.close(ok))
@@ -574,6 +635,8 @@ export function createGateway(opts = {}) {
574
635
  const now = Date.now()
575
636
  for (const m of [reqHits, fails]) for (const [k, b] of m) if (now - b.t > 60e3) m.delete(k)
576
637
  for (const set of devSockets.values()) for (const s of set) if (s.destroyed) set.delete(s)
638
+ // ponytail: a failed write keeps the times in memory; the next tick tries again.
639
+ if (seenDirty && now - seenSavedAt >= seenSaveMs) try { saveDevices() } catch {}
577
640
  if (now - tsAt > 30e3) refreshTs()
578
641
  const n = netInfo()
579
642
  if (running && (!n || n.address !== bound.address || n.netmask !== bound.netmask)) await stop(true).catch(() => {})
@@ -583,7 +646,8 @@ export function createGateway(opts = {}) {
583
646
 
584
647
  const state = () => ({
585
648
  mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
586
- fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
649
+ fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError, tailnetOnly,
650
+ log: log.slice(-50).reverse(), // newest first
587
651
  code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
588
652
  pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
589
653
  last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
@@ -609,6 +673,14 @@ export function createGateway(opts = {}) {
609
673
  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.' })
610
674
  }
611
675
  else if (path === '/revoke') revoke(b.id)
676
+ else if (path === '/settings') {
677
+ if (typeof b.tailnetOnly !== 'boolean') return reply(res, 400, { error: 'bad request' })
678
+ saveJson(settingsFile, { tailnetOnly: b.tailnetOnly }) // a failed save changes nothing (500 below)
679
+ tailnetOnly = b.tailnetOnly
680
+ // An open connection that the new rule refuses closes now, not at its next reconnect.
681
+ if (bound) for (const sock of conns) if (!allowed(sock.remoteAddress, sock.localAddress, bound, tailnetOnly)) sock.destroy()
682
+ advertise()
683
+ }
612
684
  else if (path === '/start') await start()
613
685
  else if (path === '/stop') await stop()
614
686
  else return reply(res, 404, { error: 'not found' })
@@ -619,8 +691,15 @@ export function createGateway(opts = {}) {
619
691
  adminServer = createHttp(onAdmin)
620
692
  return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
621
693
  }
622
- async function close() { clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections() }
694
+ async function close() {
695
+ clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections()
696
+ if (logTimer) flushLog()
697
+ if (seenDirty) try { saveDevices() } catch {}
698
+ }
699
+
700
+ /** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
701
+ const emit = (event, args) => toPhones({ type: 'emit', event, args })
623
702
 
624
- return { start, stop, close, state, newCode, settle, revoke, listenAdmin, get port() { return port }, get fingerprint() { return fp } }
703
+ return { start, stop, close, state, newCode, settle, revoke, listenAdmin, emit, get port() { return port }, get fingerprint() { return fp } }
625
704
  }
626
705