dsh-composer-plus 1.24.0 → 1.25.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.
@@ -193,6 +193,11 @@ function render(s) {
193
193
  put('devices', s.devices.map(d => '<tr><td><span class="dot ' + (d.connections ? 'ok' : '') + '"></span>' + esc(d.name) +
194
194
  ' <span class="mute">' + (d.connections ? 'online' : d.lastSeenAt ? 'last seen ' + esc(when(d.lastSeenAt)) : 'never connected') + '</span>' +
195
195
  (d.createdAt ? '<br><span class="sub">Paired ' + esc(new Date(d.createdAt).toLocaleDateString()) + '</span>' : '') +
196
+ // Notifications while Tether is closed: on, off (the phone sent no device token), or why the last push failed.
197
+ (s.pushKey ? '<br><span class="sub">' + (d.pushFail ? 'Last push failed: ' + esc(d.pushFail.reason) + ' (' + esc(when(d.pushFail.at)) + ')'
198
+ : d.push ? 'Notifications while Tether is closed: on'
199
+ : d.pushReason ? 'Notifications while Tether is closed: off. The iPhone says: ' + esc(d.pushReason)
200
+ : 'Notifications while Tether is closed: off. In Tether, turn on Settings › Send notifications.') + '</span>' : '') +
196
201
  '</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('')
197
202
  || '<tr><td class="mute">No iPhone yet.</td></tr>')
198
203
  // An older gateway (before a dsh restart) has no Tailscale only setting and no log: hide them.
@@ -51,7 +51,7 @@ export const PLUS = new Map([
51
51
  ['queue-move', ['POST']], ['queue-edit', ['POST']], ['branches', ['GET']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
52
52
  ['worktrees', ['GET', 'POST']], ['new-session-workspace', ['POST']], ['memory-edit', ['GET', 'POST']], ['memory-history', ['GET', 'POST']], ['memory-export', ['POST']], ['memory-copy', ['GET', 'POST']], ['memory-stale', ['GET']], ['memory-chat', ['GET', 'POST']], ['memory-suggest', ['POST']], ['handoff', ['POST']], ['context', ['GET']],
53
53
  ['board-chats', ['GET', 'POST']], ['board-progress', ['GET']], ['secret-answer', ['GET', 'POST']], ['side-chat', ['GET', 'POST']],
54
- ['issue-report', ['POST']], ['assistant', ['GET']],
54
+ ['issue-report', ['POST']], ['assistant', ['GET']], ['assistant-status', ['GET']],
55
55
  ])
56
56
  /** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
57
57
  export const AUTO_REVIEW_ASK = 'Auto review denied tool '
@@ -125,6 +125,26 @@ function reply(res, status, obj) {
125
125
  res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
126
126
  res.end(JSON.stringify(obj))
127
127
  }
128
+ const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
129
+ /** A message of the relay as a page: in the middle of the window, in dsh's light or dark colors, with no script.
130
+ * `retry`: a link that loads the same address again (for a computer that was asleep). */
131
+ function messagePage(title, hint, retry) {
132
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(title)}</title><style>
133
+ :root{color-scheme:light dark;--bg:#fff;--fg:#0f1115;--sub:#61666b;--line:rgba(0,0,0,.12)}
134
+ @media (prefers-color-scheme:dark){:root{--bg:#151517;--fg:#f9fafb;--sub:#cfd3d6;--line:rgba(255,255,255,.16)}}
135
+ html,body{height:100%;margin:0}
136
+ body{display:grid;place-items:center;padding:24px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
137
+ main{max-width:420px;text-align:center}
138
+ svg{color:var(--sub)}
139
+ h1{font-size:17px;font-weight:600;line-height:1.35;margin:12px 0 6px}
140
+ p{margin:0;color:var(--sub)}
141
+ a{display:inline-block;margin-top:20px;padding:6px 16px;border:.5px solid var(--line);border-radius:8px;background:rgba(127,127,127,.12);color:var(--fg);font-size:13px;font-weight:500;text-decoration:none}
142
+ a:hover{background:rgba(127,127,127,.2)}
143
+ </style></head><body><main>
144
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="2"/><path d="M8.5 20h7M12 16.5V20"/></svg>
145
+ <h1>${escHtml(title)}</h1>${hint ? `<p>${escHtml(hint)}</p>` : ''}${retry ? '<a href="">Try again</a>' : ''}
146
+ </main></body></html>`
147
+ }
128
148
  function readBody(req, max) {
129
149
  return new Promise((ok, bad) => {
130
150
  const parts = []; let n = 0
@@ -337,11 +357,16 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
337
357
  return site === undefined || site === 'same-origin' || site === 'none'
338
358
  }
339
359
  const ancestors = ["'self'", ...frameAncestors].join(' ')
340
- const text = (res, status, body) => { if (!res.headersSent) res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(body) }
360
+ // The relay's own message: a page for a browser that asks for HTML (the window, dsh's Browser tab), else plain text.
361
+ const text = (res, status, title, hint = '', retry = false) => {
362
+ const html = /text\/html/.test(res.req?.headers?.accept ?? '')
363
+ if (!res.headersSent) res.writeHead(status, html ? { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors ${ancestors}` } : { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
364
+ res.end(html ? messagePage(title, hint, retry) : hint ? `${title} ${hint}` : title)
365
+ }
341
366
  const next = (req) => ({ agent, headers: hopHeaders(req.headers, { host: `${host}:${port}`, authorization: `Bearer ${token}`, ...(req.headers.origin !== undefined ? { origin: `https://${host}:${port}` } : {}) }) })
342
367
  // The computer no longer knows this Mac (it removed it, or the token is too old): say so, and drop the computer's answer.
343
368
  const status = (code) => { if (code === 401) { onUnauthorized?.(); return 'skip' } }
344
- const UNPAIRED = 'That Mac removed this one, or the pairing is too old. Remove it in Settings → Composer Plus → Remote computers and pair again.'
369
+ const UNPAIRED = ['That Mac removed this one, or the pairing is too old.', 'Remove it in Settings → Composer Plus → Remote computers and pair again.']
345
370
  async function onRequest(req, res) {
346
371
  if (!trusted(req)) return text(res, 403, 'Forbidden')
347
372
  const url = new URL(req.url, 'http://x')
@@ -349,7 +374,7 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
349
374
  if (ticket !== null) {
350
375
  const exp = tickets.get(ticket)
351
376
  tickets.delete(ticket) // one use, also when it is too old
352
- if (req.method !== 'GET' || url.pathname !== '/' || exp === undefined || exp < Date.now()) return text(res, 403, 'This link was already used or is too old. Pick the computer again.')
377
+ if (req.method !== 'GET' || url.pathname !== '/' || exp === undefined || exp < Date.now()) return text(res, 403, 'This link was already used or is too old.', 'Pick the computer again.')
353
378
  res.writeHead(303, { location: './', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': `frame-ancestors ${ancestors}`, 'set-cookie': `${cookieName()}=${cookieValue}; Path=/; HttpOnly; SameSite=Strict` })
354
379
  return res.end()
355
380
  }
@@ -360,8 +385,13 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
360
385
  if (url.pathname.startsWith(RELAY_OWN)) return ownPath(req, res, url.pathname.slice(RELAY_OWN.length))
361
386
  let unpaired = false
362
387
  await pipeHttp(req, res, next(req), { onStatus: (code) => { const r = status(code); if (r === 'skip') unpaired = true; return r },
363
- onError: (r) => text(r, 502, fail === 'pin' ? 'This computer shows another certificate. Pair it again in dsh.' : 'This computer does not answer over Tailscale. Make sure that it is awake and that Tailscale is on.') })
364
- if (unpaired) text(res, 401, UNPAIRED)
388
+ onError: (r) => {
389
+ // A page (not a fetch) got the relay's own page in place of dsh: this Mac loads the computer again at the next pick.
390
+ if (/text\/html/.test(req.headers.accept ?? '')) peer?.signal({ failed: fail ?? 'unreachable' })
391
+ return fail === 'pin' ? text(r, 502, 'This computer shows another certificate.', 'Pair it again in dsh.')
392
+ : text(r, 502, 'This computer does not answer over Tailscale.', 'Make sure that it is awake and that Tailscale is on.', true)
393
+ } })
394
+ if (unpaired) text(res, 401, ...UNPAIRED)
365
395
  }
366
396
  /** The other Mac's page (in this Mac's window) reads this Mac's computers and switches the window: nothing else. */
367
397
  async function ownPath(req, res, name) {
@@ -594,6 +624,7 @@ export function createGateway(opts = {}) {
594
624
  // A Remote computers front door has no phones.
595
625
  const apns = computer ? {} : opts.apns ? { push: apnsSender(opts.apns, { origin: opts.apns.origin }) } : loadApns(join(dir, 'apns.json'))
596
626
  const pushTo = () => apns.push ? devices.filter(d => d.push?.on === true && typeof d.push.token === 'string') : []
627
+ const pushFails = new Map() // device id → { reason, at } of its last failed push (memory only)
597
628
  /** One alert to each phone that wants this Mac's notifications now (switched on, inside its hours). The subtitle names
598
629
  * this Mac and the chat's project; the body is the chat's title, then `detail` (the reply, tool, question or error).
599
630
  * `opts.about(sessionId)` gives `{ chat, project }` (the Host half); without it the subtitle is the Mac name only. */
@@ -610,6 +641,8 @@ export function createGateway(opts = {}) {
610
641
  for (const d of to) {
611
642
  const token = d.push.token
612
643
  apns.push.send(token, d.push.sandbox === true, { aps: { alert: { title, subtitle, body }, sound: 'default' } }).then(r => {
644
+ // The Mac page shows why the last push to this phone failed (no secret in it: APNs' reason or the HTTP status).
645
+ if (r.status === 200) pushFails.delete(d.id); else pushFails.set(d.id, { reason: String(r.reason || (r.status ? 'HTTP ' + r.status : 'no answer')).slice(0, 100), at: new Date().toISOString() })
613
646
  // The app left the iPhone (or the token is not for this app): forget the token; the phone sends a new one.
614
647
  if ((r.status === 410 || r.reason === 'BadDeviceToken') && d.push?.token === token) { delete d.push.token; saveDevices() }
615
648
  }, () => {})
@@ -622,7 +655,9 @@ export function createGateway(opts = {}) {
622
655
  const mins = v => Number.isInteger(v) && v >= 0 && v < 1440
623
656
  const hours = b?.hours === null ? null : mins(b?.hours?.from) && mins(b.hours.to) && typeof b.hours.tz === 'string' && b.hours.tz.length <= 64 ? { from: b.hours.from, to: b.hours.to, tz: b.hours.tz } : undefined
624
657
  if (typeof b?.on !== 'boolean' || (b.on && (!/^[0-9a-f]{64,200}$/i.test(b.token ?? '') || typeof b.sandbox !== 'boolean' || hours === undefined))) return reply(res, 400, { error: 'bad request' })
625
- dev.push = b.on ? { on: true, token: b.token.toLowerCase(), sandbox: b.sandbox, hours } : { on: false }
658
+ // Switched on but no device token: the phone says why (iOS's error), and the Mac page shows it.
659
+ const reason = !b.on && typeof b.reason === 'string' && b.reason.trim() !== '' ? b.reason.trim().slice(0, 200) : undefined
660
+ dev.push = b.on ? { on: true, token: b.token.toLowerCase(), sandbox: b.sandbox, hours } : { on: false, ...(reason ? { reason } : {}) }
626
661
  saveDevices()
627
662
  reply(res, 200, { push: b.on })
628
663
  }
@@ -1027,7 +1062,9 @@ export function createGateway(opts = {}) {
1027
1062
  pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
1028
1063
  last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
1029
1064
  // The APNs device token stays here: the Mac page shows only whether the phone gets pushes.
1030
- devices: devices.map(({ tokenHash, push, ...d }) => ({ ...d, push: push?.on === true && typeof push.token === 'string', connections: devSockets.get(d.id)?.size ?? 0 })),
1065
+ // `pushKey`: this Mac has an APNs key (else no phone gets pushes). `pushFail`: why the last push to a phone failed.
1066
+ devices: devices.map(({ tokenHash, push, ...d }) => ({ ...d, push: push?.on === true && typeof push.token === 'string', ...(pushFails.has(d.id) ? { pushFail: pushFails.get(d.id) } : {}), ...(push?.reason ? { pushReason: push.reason } : {}), connections: devSockets.get(d.id)?.size ?? 0 })),
1067
+ pushKey: apns.push !== undefined,
1031
1068
  ...(apns.error ? { pushError: apns.error } : {}),
1032
1069
  })
1033
1070