dsh-composer-plus 1.12.0 → 1.14.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.12.0",
3
+ "version": "1.14.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,7 +77,7 @@
73
77
  "assets/features/*.webp"
74
78
  ],
75
79
  "scripts": {
76
- "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/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/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/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",
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/file-chats.test.mjs && node test/file-chats-client.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/github-issue.test.mjs && node test/project-status.test.mjs && node test/session-branch.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/project-picker.test.mjs && node test/worktree-chats.test.mjs && node test/worktree-chats-client.test.mjs && node test/default-workspace.test.mjs && node test/default-workspace-client.test.mjs && node test/find-in-chat.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/goal-check.test.mjs && node test/goal-check-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",
78
82
  "build:readme": "node scripts/build-docs.mjs",
79
83
  "prepublishOnly": "npm test"
@@ -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,19 @@ 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']], ['file-chats', ['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']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
49
+ ['queue-move', ['POST']], ['branches', ['GET']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
50
+ ['worktrees', ['GET', 'POST']], ['new-session-workspace', ['POST']],
44
51
  ])
45
52
  /** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
46
53
  export const AUTO_REVIEW_ASK = 'Auto review denied tool '
47
54
  export const isGrantedAsk = (v, grants) => v.event === 'approval/request' && typeof v.request?.reason === 'string'
48
55
  && v.request.reason.startsWith(AUTO_REVIEW_ASK) && typeof v.request.toolName === 'string' && typeof v.agentId === 'string'
49
56
  && 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')
57
+ /** A switch body (`{on: true|false}`) or a setting (`{model}`, the grant time `{ttlMs}`) changes the Mac for every device:
58
+ * the phone never sends one. */
59
+ const isSwitch = b => Object.hasOwn(b, 'on') || Object.hasOwn(b, 'model') || Object.hasOwn(b, 'ttlMs')
52
60
  export const EVENTS = new Set([
53
61
  'agent-preset/selected', 'approval/request', 'api-session/activity', 'api-session/added', 'api-session/error',
54
62
  'api-session/removed', 'api-session/status', 'commands/change', 'goal/activation-changed',
@@ -81,8 +89,9 @@ export const isTailnet = ip => {
81
89
  return /^fd7a:115c:a1e0:/.test(ip)
82
90
  }
83
91
  // 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) ||
92
+ // `tailnetOnly` (the Mac page's "Tailscale only"): tailnet peers only, so nobody on a shared Wi-Fi reaches the gateway.
93
+ export const allowed = (remote, local, net, tailnetOnly = false) =>
94
+ (!tailnetOnly && inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
86
95
  (isTailnet(remote) && isTailnet(local))
87
96
  const TS_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']
88
97
  export function tailscaleInfo(bins = TS_BINS) {
@@ -259,11 +268,36 @@ export function createGateway(opts = {}) {
259
268
  const codeTtl = opts.codeTtlMs ?? 5 * 60e3
260
269
  const approveTtl = opts.approveTtlMs ?? 120e3
261
270
  const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
271
+ const seenSaveMs = opts.seenSaveMs ?? 60e3 // the last connection times reach devices.json at most this often
262
272
  mkdirSync(dir, { recursive: true, mode: 0o700 })
263
273
  const upstream = makeUpstream(opts.upstreamUrl)
264
274
  const devFile = join(dir, 'devices.json')
265
275
  let devices = existsSync(devFile) ? JSON.parse(readFileSync(devFile, 'utf8')) : []
266
- const saveDevices = () => writeFileSync(devFile, JSON.stringify(devices, null, 2), { mode: 0o600 })
276
+ let seenDirty = false, seenSavedAt = 0 // a `lastSeenAt` that is only in memory yet; when devices.json was written
277
+ // The flags change only after a good write: a failed one is tried again at the next watch tick (and at close).
278
+ const saveDevices = () => { saveJson(devFile, devices); seenDirty = false; seenSavedAt = Date.now() }
279
+ // "Tailscale only" (the Mac page): kept in its own file, so it survives a restart of the gateway and of dsh.
280
+ const settingsFile = join(dir, 'settings.json')
281
+ let tailnetOnly = readJson(settingsFile, {})?.tailnetOnly === true
282
+ // ---- Access log: each connection attempt with its outcome, oldest first (memory and access.json). ----
283
+ const logFile = join(dir, 'access.json')
284
+ let log = [readJson(logFile, [])].flat().filter(e => typeof e?.at === 'string' && typeof e.ip === 'string' && Number.isSafeInteger(e.n)).slice(-LOG_MAX), logTimer
285
+ // ponytail: a failed write is dropped; the list in memory stays right and the next attempt writes it again.
286
+ const flushLog = () => { clearTimeout(logTimer); logTimer = undefined; try { saveJson(logFile, log) } catch {} }
287
+ /** `result`: allowed | refused. `reason`: lan, tailnet, paired (allowed); network, tailnet-only, tls, rate, token, code,
288
+ * denied, expired, cancelled (refused). The same attempt again within a minute counts up one entry (`n`), so a phone
289
+ * that retries or a scanner cannot push the other entries out at once. */
290
+ function logAttempt(ip, device, result, reason) {
291
+ ip = String(ip ?? '').replace(/^::ffff:/i, '')
292
+ const now = Date.now()
293
+ 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)
294
+ const e = i < 0 ? { ip, device, result, reason, n: 0 } : log.splice(i, 1)[0]
295
+ e.at = new Date(now).toISOString(); e.n++
296
+ log.push(e)
297
+ if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX)
298
+ logTimer ??= setTimeout(flushLog, 2000)
299
+ logTimer.unref?.()
300
+ }
267
301
  // Tailscale status: cached 30 s, refreshed on start and on new pairing codes. Tests inject opts.tailscale.
268
302
  let ts = { installed: false, online: false, ip: null, name: null }, tsAt = 0
269
303
  const refreshTs = async () => {
@@ -303,11 +337,15 @@ export function createGateway(opts = {}) {
303
337
  if (Date.parse(d.expiresAt) - Date.now() < TOKEN_TTL / 2) { d.expiresAt = new Date(Date.now() + TOKEN_TTL).toISOString(); saveDevices() }
304
338
  return d
305
339
  }
306
- function track(id, socket) {
307
- let s = devSockets.get(id)
308
- if (!s) devSockets.set(id, s = new Set())
340
+ /** A paired phone's request on a socket: a new socket is one connection (logged, and the device's last connection). */
341
+ function track(d, socket) {
342
+ let s = devSockets.get(d.id)
343
+ if (!s) devSockets.set(d.id, s = new Set())
309
344
  if (s.has(socket)) return
310
345
  s.add(socket); socket.once('close', () => s.delete(socket))
346
+ logAttempt(socket.remoteAddress, d.name, 'allowed', isTailnet(socket.remoteAddress) ? 'tailnet' : 'lan')
347
+ // In memory at once; the network watch writes it at most once a minute (and close() at the end).
348
+ d.lastSeenAt = new Date().toISOString(); seenDirty = true
311
349
  }
312
350
  function revoke(id) {
313
351
  const before = devices.length
@@ -326,7 +364,10 @@ export function createGateway(opts = {}) {
326
364
  // ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
327
365
  // The latest outcome, kept 60 s so the Mac page can say what happened after the request card goes away.
328
366
  let last = null
329
- const record = (p, result) => { last = { name: p.name, result, at: Date.now() } }
367
+ const record = (p, result) => {
368
+ last = { name: p.name, result, at: Date.now() }
369
+ logAttempt(p.ip, p.name, result === 'approved' ? 'allowed' : 'refused', result === 'approved' ? 'paired' : result)
370
+ }
330
371
  function newCode() {
331
372
  const net = bound ?? netInfo()
332
373
  code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
@@ -344,7 +385,7 @@ export function createGateway(opts = {}) {
344
385
  let b
345
386
  try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
346
387
  const ok = code && typeof b?.code === 'string' && Date.now() < code.expires && safeEq(b.code, code.value)
347
- if (!ok) { fail(ip); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
388
+ if (!ok) { fail(ip); logAttempt(ip, null, 'refused', 'code'); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
348
389
  code = null // one-time: a replayed code is rejected
349
390
  const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
350
391
  pending.set(p.id, p)
@@ -449,12 +490,12 @@ export function createGateway(opts = {}) {
449
490
  // ---- Phone-facing HTTPS ----
450
491
  async function onRequest(req, res) {
451
492
  const ip = req.socket.remoteAddress
452
- if (!allowReq(ip)) return reply(res, 429, { error: 'rate limited' })
493
+ if (!allowReq(ip)) { logAttempt(ip, null, 'refused', 'rate'); return reply(res, 429, { error: 'rate limited' }) }
453
494
  const path = new URL(req.url, 'https://x').pathname
454
495
  if (req.method === 'POST' && path === '/pair') return pair(req, res, ip)
455
496
  const dev = auth(req)
456
- if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
457
- track(dev.id, req.socket)
497
+ if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
498
+ track(dev, req.socket)
458
499
  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()] })
459
500
  if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
460
501
  if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
@@ -492,13 +533,13 @@ export function createGateway(opts = {}) {
492
533
  }
493
534
  function onUpgrade(req, socket, head) {
494
535
  const ip = socket.remoteAddress
495
- if (!allowReq(ip)) return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n')
536
+ 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') }
496
537
  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')
497
538
  const dev = auth(req)
498
- if (!dev) { fail(ip); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
539
+ if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
499
540
  const ws = acceptWs(req, socket, head)
500
541
  if (!ws) return
501
- track(dev.id, socket)
542
+ track(dev, socket)
502
543
  phoneMux(ws)
503
544
  }
504
545
  const isId = v => typeof v === 'string' && v.length > 0 && v.length <= 128
@@ -548,27 +589,42 @@ export function createGateway(opts = {}) {
548
589
  await refreshTs()
549
590
  const s = createHttps({ key, cert, minVersion: 'TLSv1.2' }, onRequest)
550
591
  s.on('connection', sock => {
551
- if (!allowed(sock.remoteAddress, sock.localAddress, net)) return sock.destroy() // LAN or tailnet only, decided by the socket's own addresses
592
+ // LAN or tailnet only (or tailnet only), decided by the socket's own addresses
593
+ if (!allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) {
594
+ logAttempt(sock.remoteAddress, null, 'refused', tailnetOnly && allowed(sock.remoteAddress, sock.localAddress, net) ? 'tailnet-only' : 'network')
595
+ return sock.destroy()
596
+ }
552
597
  conns.add(sock); sock.once('close', () => conns.delete(sock))
553
598
  })
554
599
  s.on('upgrade', onUpgrade)
555
600
  // Kept for diagnosis only (no secrets): why a phone's TLS handshake died, shown in admin /state.
556
- s.on('tlsClientError', (e, sock) => { lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }; sock.destroy() })
601
+ s.on('tlsClientError', (e, sock) => {
602
+ lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }
603
+ // A socket refused above also ends here: it is in the log once, with that reason.
604
+ if (allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) logAttempt(sock.remoteAddress, null, 'refused', 'tls')
605
+ sock.destroy()
606
+ })
557
607
  await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
558
608
  server = s; bound = net; port = s.address().port; running = true
559
- if (opts.bonjour !== false) {
560
- // The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
561
- bonjour = spawn('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
562
- bonjour.on('error', () => {})
563
- }
609
+ advertise()
564
610
  connectEvents()
565
611
  }
612
+ // Bonjour gives each device on the Wi-Fi the Mac name, the port and the certificate fingerprint: not while "Tailscale only"
613
+ // is on (the phone reaches the Mac over the tailnet with the address from pairing; mDNS does not cross Tailscale).
614
+ function advertise() {
615
+ const want = running && !tailnetOnly && opts.bonjour !== false
616
+ if (want === !!bonjour) return
617
+ if (!want) { bonjour.kill(); bonjour = null; return }
618
+ // The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
619
+ bonjour = (opts.spawn ?? spawn)('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
620
+ bonjour.on('error', () => {})
621
+ }
566
622
  async function stop(keepWanted = false) {
567
623
  wanted = keepWanted
568
624
  if (!running) return
569
625
  running = false
570
626
  clearTimeout(evTimer)
571
- bonjour?.kill(); bonjour = null
627
+ advertise()
572
628
  ev.ws?.close()
573
629
  for (const sock of conns) sock.destroy()
574
630
  await new Promise(ok => server.close(ok))
@@ -580,6 +636,8 @@ export function createGateway(opts = {}) {
580
636
  const now = Date.now()
581
637
  for (const m of [reqHits, fails]) for (const [k, b] of m) if (now - b.t > 60e3) m.delete(k)
582
638
  for (const set of devSockets.values()) for (const s of set) if (s.destroyed) set.delete(s)
639
+ // ponytail: a failed write keeps the times in memory; the next tick tries again.
640
+ if (seenDirty && now - seenSavedAt >= seenSaveMs) try { saveDevices() } catch {}
583
641
  if (now - tsAt > 30e3) refreshTs()
584
642
  const n = netInfo()
585
643
  if (running && (!n || n.address !== bound.address || n.netmask !== bound.netmask)) await stop(true).catch(() => {})
@@ -589,7 +647,8 @@ export function createGateway(opts = {}) {
589
647
 
590
648
  const state = () => ({
591
649
  mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
592
- fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
650
+ fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError, tailnetOnly,
651
+ log: log.slice(-50).reverse(), // newest first
593
652
  code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
594
653
  pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
595
654
  last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
@@ -615,6 +674,14 @@ export function createGateway(opts = {}) {
615
674
  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.' })
616
675
  }
617
676
  else if (path === '/revoke') revoke(b.id)
677
+ else if (path === '/settings') {
678
+ if (typeof b.tailnetOnly !== 'boolean') return reply(res, 400, { error: 'bad request' })
679
+ saveJson(settingsFile, { tailnetOnly: b.tailnetOnly }) // a failed save changes nothing (500 below)
680
+ tailnetOnly = b.tailnetOnly
681
+ // An open connection that the new rule refuses closes now, not at its next reconnect.
682
+ if (bound) for (const sock of conns) if (!allowed(sock.remoteAddress, sock.localAddress, bound, tailnetOnly)) sock.destroy()
683
+ advertise()
684
+ }
618
685
  else if (path === '/start') await start()
619
686
  else if (path === '/stop') await stop()
620
687
  else return reply(res, 404, { error: 'not found' })
@@ -625,7 +692,11 @@ export function createGateway(opts = {}) {
625
692
  adminServer = createHttp(onAdmin)
626
693
  return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
627
694
  }
628
- async function close() { clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections() }
695
+ async function close() {
696
+ clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections()
697
+ if (logTimer) flushLog()
698
+ if (seenDirty) try { saveDevices() } catch {}
699
+ }
629
700
 
630
701
  /** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
631
702
  const emit = (event, args) => toPhones({ type: 'emit', event, args })