dsh-composer-plus 1.4.1 → 1.5.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/index.js CHANGED
@@ -5,12 +5,15 @@
5
5
  * - Persistent Auto Review grants: per-session tool names saved under the dsh home, so "Allow for this chat" survives a restart.
6
6
  * - Update check: the latest GitHub release, offered only once npm serves that exact version (the client installs it
7
7
  * through dsh's own plugin manager).
8
+ * - Mobile connect (Tether): runs the iPhone gateway (tether/gateway/server.mjs) in this process while the user switches it
9
+ * on; the switch is saved under the dsh home and is off by default.
8
10
  */
9
11
  import { readFileSync } from 'node:fs';
10
12
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
11
13
  import { randomUUID } from 'node:crypto';
12
14
  import { homedir } from 'node:os';
13
15
  import { dirname, join } from 'node:path';
16
+ import { createGateway } from './tether/gateway/server.mjs';
14
17
 
15
18
  export const name = 'dsh-composer-plus';
16
19
  export const inject = ['connection', 'subprocess', 'sessionQuery', 'sessionController'];
@@ -21,9 +24,10 @@ export const GRANTS_PATH = '/api/composer-plus.grants';
21
24
  export const CONTINUE_PATH = '/api/composer-plus.continue';
22
25
  export const UPDATE_PATH = '/api/composer-plus.update';
23
26
  export const SHOT_PATH = '/api/composer-plus.shot';
27
+ export const MOBILE_PATH = '/api/composer-plus.mobile';
24
28
  /** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
25
- export const SHOTS = ['changesPanel', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'inputHistory', 'planToggle',
26
- 'queueReorder', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate'];
29
+ export const SHOTS = ['changesPanel', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'inputHistory', 'planToggle', 'keepFocus',
30
+ 'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate'];
27
31
  const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
28
32
  const NPM_NAME = 'dsh-composer-plus';
29
33
  const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
@@ -194,10 +198,64 @@ export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
194
198
  return { ...info, newer: npm.ok };
195
199
  }
196
200
 
201
+ /** Mobile connect switch: `{ on: true }` in a JSON file, off when the file is missing or unreadable. */
202
+ export function mobileSwitch(file) {
203
+ return {
204
+ read: async () => { try { return JSON.parse(await readFile(file, 'utf8'))?.on === true; } catch { return false; } },
205
+ write: async (on) => {
206
+ await mkdir(dirname(file), { recursive: true });
207
+ const tmp = `${file}.${process.pid}.tmp`;
208
+ await writeFile(tmp, JSON.stringify({ on }), { mode: 0o600 });
209
+ await rename(tmp, file);
210
+ },
211
+ };
212
+ }
213
+
214
+ /** Mobile connect: one gateway at a time. Start and stop calls run in order, so a fast toggle never leaves two gateways or a
215
+ * half-closed one. `upstreamUrl` gives dsh's authenticated loopback URL; it is asked again at each DSH login. */
216
+ export function mobileGateway({ upstreamUrl, dir, make = createGateway, retryMs = 300 }) {
217
+ let gateway, admin, error, closed = false, queue = Promise.resolve();
218
+ const run = (fn) => (queue = queue.then(fn, fn));
219
+ // A plugin reload starts the new gateway while the old one still closes its pairing page: wait for the port (about 3 s).
220
+ const listenAdmin = async (g) => {
221
+ for (let attempt = 1; ; attempt++) {
222
+ try { return await g.listenAdmin(); } catch (e) {
223
+ if (e?.code !== 'EADDRINUSE' || attempt >= 10) throw e;
224
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
225
+ }
226
+ }
227
+ };
228
+ const stop = async () => {
229
+ const g = gateway;
230
+ gateway = undefined; admin = undefined;
231
+ if (g !== undefined) await g.close().catch(() => {});
232
+ };
233
+ return {
234
+ start: () => run(async () => {
235
+ if (gateway !== undefined || closed) return;
236
+ error = undefined;
237
+ try {
238
+ gateway = make({ dir, upstreamUrl });
239
+ admin = `http://127.0.0.1:${await listenAdmin(gateway)}/`;
240
+ await gateway.start();
241
+ } catch (e) { error = String(e?.message ?? e); if (admin === undefined) await stop(); }
242
+ }),
243
+ stop: () => run(stop),
244
+ /** Plugin unload: stop, and ignore any start that comes later. */
245
+ close: () => { closed = true; return run(stop); },
246
+ state: () => {
247
+ const running = gateway?.state().running === true;
248
+ // The gateway retries by itself (for example when Wi-Fi comes back): a running gateway has no error to show.
249
+ return { running, admin, ...(error === undefined || running ? {} : { error }) };
250
+ },
251
+ };
252
+ }
253
+
197
254
  const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
198
255
  const isText = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max;
199
256
 
200
257
  export function apply(ctx) {
258
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
201
259
  // The empty wake marker opens the step; it is then replaced by a fresh context message. Only messages
202
260
  // claimed from the inbox render as a chat bubble, so the replacement stays out of Chat, but it gives the
203
261
  // model a final user turn: providers such as Claude reject a request that ends on an assistant message.
@@ -292,7 +350,7 @@ export function apply(ctx) {
292
350
  try { return json(await checkUpdate(fetch, PACKAGE, request.signal)); } catch (error) { return json({ error: String(error?.message ?? error) }, 502); }
293
351
  },
294
352
  });
295
- const store = grantsStore(join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-composer-plus', 'grants.json'));
353
+ const store = grantsStore(join(home, 'storages', 'dsh-composer-plus', 'grants.json'));
296
354
  ctx.connection.fetch.register({
297
355
  path: GRANTS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
298
356
  fetch: async (request) => {
@@ -304,4 +362,44 @@ export function apply(ctx) {
304
362
  return json({ error: 'bad request' }, 400);
305
363
  },
306
364
  });
365
+ // Mobile connect (Tether). The phone gateway needs dsh's web server for its loopback URL; a profile without one
366
+ // (headless) never starts it. Off until the user switches it on in Settings.
367
+ const mobileSaved = mobileSwitch(join(home, 'storages', 'dsh-composer-plus', 'mobile.json'));
368
+ let mobile;
369
+ let mobileOn; // the user's choice; the saved value applies only until the user changes it
370
+ const mobileRead = mobileSaved.read().then((on) => { mobileOn ??= on; });
371
+ let mobileQueue = Promise.resolve(); // one switch change at a time: save, then start or stop
372
+ ctx.inject?.(['webServer'], (webCtx) => {
373
+ const gateway = mobileGateway({
374
+ dir: process.env.DSH_MOBILE_DIR || join(home, 'storages', 'dsh-composer-plus', 'mobile'),
375
+ upstreamUrl: () => ctx.connection.authenticatedUrl(`http://127.0.0.1:${webCtx.webServer.port}`),
376
+ });
377
+ mobile = gateway;
378
+ webCtx.effect(() => {
379
+ mobileRead.then(() => { if (mobileOn && mobile === gateway) gateway.start(); });
380
+ return () => { if (mobile === gateway) mobile = undefined; gateway.close(); };
381
+ }, 'dsh-composer-plus: mobile connect');
382
+ });
383
+ ctx.connection.fetch.register({
384
+ path: MOBILE_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
385
+ fetch: async (request) => {
386
+ if (request.method === 'POST') {
387
+ let body;
388
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
389
+ if (typeof body?.on !== 'boolean') return json({ error: 'bad request' }, 400);
390
+ const change = async () => {
391
+ await mobileRead;
392
+ await mobileSaved.write(body.on); // a failed save changes nothing
393
+ mobileOn = body.on;
394
+ if (mobile !== undefined) await (body.on ? mobile.start() : mobile.stop());
395
+ };
396
+ const done = mobileQueue.then(change);
397
+ mobileQueue = done.catch(() => {}); // a failed save answers only its own request
398
+ try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
399
+ }
400
+ await mobileRead;
401
+ const on = mobileOn === true;
402
+ return json({ on, available: mobile !== undefined, ...(on && mobile !== undefined ? mobile.state() : { running: false }) });
403
+ },
404
+ });
307
405
  }
package/locale/de.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Änderungsansicht, Eingabeverlauf, Shift+Tab /plan, Fortsetzen mit einem Klick, Warteschlange per Drag-and-drop, Links, Benachrichtigungen, 11 Sprachen und automatische Updates."
4
+ "description": "Änderungsansicht, Eingabeverlauf, Shift+Tab /plan, Fortsetzen mit einem Klick, Warteschlange per Drag-and-drop, Links, Benachrichtigungen, iPhone-Zugriff (Tether), 11 Sprachen und automatische Updates."
5
5
  }
6
6
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Changes panel, input history, Shift+Tab /plan, one-click Continue, queue drag-and-drop, deeplinks, notifications, 11 UI languages and automatic updates."
4
+ "description": "Changes panel, input history, Shift+Tab /plan, one-click Continue, queue drag-and-drop, deeplinks, notifications, iPhone access (Tether), 11 UI languages and automatic updates."
5
5
  }
6
6
  }
package/locale/es.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Panel de cambios, historial de entrada, Shift+Tab /plan, Continuar con un clic, reordenar la cola arrastrando, enlaces, notificaciones, 11 idiomas y actualizaciones automáticas."
4
+ "description": "Panel de cambios, historial de entrada, Shift+Tab /plan, Continuar con un clic, reordenar la cola arrastrando, enlaces, notificaciones, acceso desde iPhone (Tether), 11 idiomas y actualizaciones automáticas."
5
5
  }
6
6
  }
package/locale/fr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Panneau des modifications, historique de saisie, Shift+Tab /plan, Continuer en un clic, file réorganisable par glisser-déposer, liens, notifications, 11 langues et mises à jour automatiques."
4
+ "description": "Panneau des modifications, historique de saisie, Shift+Tab /plan, Continuer en un clic, file réorganisable par glisser-déposer, liens, notifications, accès depuis l’iPhone (Tether), 11 langues et mises à jour automatiques."
5
5
  }
6
6
  }
package/locale/hi.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "बदलाव पैनल, इनपुट इतिहास, Shift+Tab /plan, एक क्लिक में जारी रखें, कतार ड्रैग-एंड-ड्रॉप, लिंक, सूचनाएं, 11 भाषाएं और अपने-आप अपडेट।"
4
+ "description": "बदलाव पैनल, इनपुट इतिहास, Shift+Tab /plan, एक क्लिक में जारी रखें, कतार ड्रैग-एंड-ड्रॉप, लिंक, सूचनाएं, iPhone से एक्सेस (Tether), 11 भाषाएं और अपने-आप अपडेट।"
5
5
  }
6
6
  }
package/locale/id.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Panel perubahan, riwayat input, Shift+Tab /plan, Lanjutkan sekali klik, urutkan antrean dengan seret, tautan, notifikasi, 11 bahasa, dan pembaruan otomatis."
4
+ "description": "Panel perubahan, riwayat input, Shift+Tab /plan, Lanjutkan sekali klik, urutkan antrean dengan seret, tautan, notifikasi, akses dari iPhone (Tether), 11 bahasa, dan pembaruan otomatis."
5
5
  }
6
6
  }
package/locale/ja.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "変更パネル、入力履歴、Shift+Tab で /plan、ワンクリック続行、キューのドラッグ並べ替え、リンク、通知、11 言語、自動アップデート。"
4
+ "description": "変更パネル、入力履歴、Shift+Tab で /plan、ワンクリック続行、キューのドラッグ並べ替え、リンク、通知、iPhone からの操作(Tether)、11 言語、自動アップデート。"
5
5
  }
6
6
  }
package/locale/ko.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "변경 사항 패널, 입력 기록, Shift+Tab /plan, 원클릭 계속, 대기열 끌어서 정렬, 링크, 알림, 11개 언어와 자동 업데이트."
4
+ "description": "변경 사항 패널, 입력 기록, Shift+Tab /plan, 원클릭 계속, 대기열 끌어서 정렬, 링크, 알림, iPhone 연결(Tether), 11개 언어와 자동 업데이트."
5
5
  }
6
6
  }
package/locale/pt-BR.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Painel de alterações, histórico de entrada, Shift+Tab /plan, Continuar com um clique, reordenar a fila arrastando, links, notificações, 11 idiomas e atualizações automáticas."
4
+ "description": "Painel de alterações, histórico de entrada, Shift+Tab /plan, Continuar com um clique, reordenar a fila arrastando, links, notificações, acesso pelo iPhone (Tether), 11 idiomas e atualizações automáticas."
5
5
  }
6
6
  }
package/locale/ru.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Панель изменений, история ввода, Shift+Tab /plan, продолжение в один клик, перетаскивание очереди, ссылки, уведомления, 11 языков и автообновления."
4
+ "description": "Панель изменений, история ввода, Shift+Tab /plan, продолжение в один клик, перетаскивание очереди, ссылки, уведомления, доступ с iPhone (Tether), 11 языков и автообновления."
5
5
  }
6
6
  }
package/locale/tr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Değişiklikler paneli, giriş geçmişi, Shift+Tab /plan, tek tıkla devam, sıradaki mesajları sürükle-bırak, bağlantılar, bildirimler, 11 arayüz dili ve otomatik güncellemeler."
4
+ "description": "Değişiklikler paneli, giriş geçmişi, Shift+Tab /plan, tek tıkla devam, sıradaki mesajları sürükle-bırak, bağlantılar, bildirimler, iPhone’dan erişim (Tether), 11 arayüz dili ve otomatik güncellemeler."
5
5
  }
6
6
  }
package/locale/vi.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Bảng thay đổi, lịch sử nhập, Shift+Tab /plan, Tiếp tục một chạm, kéo thả hàng đợi, liên kết, thông báo, 11 ngôn ngữ và tự động cập nhật."
4
+ "description": "Bảng thay đổi, lịch sử nhập, Shift+Tab /plan, Tiếp tục một chạm, kéo thả hàng đợi, liên kết, thông báo, truy cập từ iPhone (Tether), 11 ngôn ngữ và tự động cập nhật."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "改动面板、输入历史、Shift+Tab /plan、一键继续、队列拖拽排序、会话链接、通知、11 种界面语言和自动更新。"
4
+ "description": "改动面板、输入历史、Shift+Tab /plan、一键继续、队列拖拽排序、会话链接、通知、iPhone 远程使用(Tether)、11 种界面语言和自动更新。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "1.4.1",
4
- "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, input history, Shift+Tab /plan, one-click Continue, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, automatic updates",
3
+ "version": "1.5.0",
4
+ "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, input history, Shift+Tab /plan, one-click Continue, 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",
7
7
  "type": "module",
@@ -28,13 +28,19 @@
28
28
  "git-diff",
29
29
  "i18n",
30
30
  "input-history",
31
+ "ios",
32
+ "iphone",
31
33
  "localization",
34
+ "mobile",
32
35
  "notifications",
33
36
  "plan-mode",
34
37
  "plugin",
35
38
  "queue",
36
39
  "recent-projects",
40
+ "remote-control",
37
41
  "sort-by-status",
42
+ "tailscale",
43
+ "tether",
38
44
  "translation"
39
45
  ],
40
46
  "exports": {
@@ -46,16 +52,19 @@
46
52
  "files": [
47
53
  "index.js",
48
54
  "client.js",
55
+ "tether/gateway/server.mjs",
56
+ "tether/gateway/admin.html",
57
+ "tether/gateway/qr.swift",
49
58
  "locale/*.json",
50
59
  "assets/icon.svg",
51
60
  "cordis.patch.yml",
52
- "README.md",
61
+ "/README.md",
53
62
  "LICENSE",
54
63
  "locales/LICENSE",
55
64
  "assets/features/*.webp"
56
65
  ],
57
66
  "scripts": {
58
- "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/queue-drag.test.mjs && node test/plan-toggle.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/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",
67
+ "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/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/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",
59
68
  "build:locales": "node scripts/build-locales.mjs",
60
69
  "prepublishOnly": "npm test"
61
70
  },
@@ -76,7 +85,8 @@
76
85
  "@deepseek-ai/dsh-client-ui-session",
77
86
  "@deepseek-ai/dsh-api-remotes",
78
87
  "@deepseek-ai/dsh-api-session-controller",
79
- "@deepseek-ai/dsh-client-ui-settings"
88
+ "@deepseek-ai/dsh-client-ui-settings",
89
+ "@deepseek-ai/dsh-client-ui-sidebar"
80
90
  ]
81
91
  }
82
92
  },
@@ -0,0 +1,117 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Tether</title>
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; }
30
+ </style>
31
+ <script>if (/[?&]embed\b/.test(location.search)) document.documentElement.classList.add('embed')</script>
32
+ <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>
38
+ </section>
39
+ <section class="card" id="pair-card">
40
+ <img id="qr" alt="Pairing QR code" hidden>
41
+ <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>
48
+ </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>
52
+ </div>
53
+ </section>
54
+ <h2>Paired devices</h2>
55
+ <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>
59
+ </details>
60
+ <script>
61
+ const $ = id => document.getElementById(id)
62
+ 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
+ const embed = document.documentElement.classList.contains('embed')
67
+ 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() }
86
+ }
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…'
100
+ }
101
+ // Embedded in DSH: report our height so the popover fits the content and never scrolls.
102
+ if (embed && parent !== window) {
103
+ let last = 0
104
+ // body's own box, not scrollHeight: scrollHeight never drops below the iframe's current height
105
+ const report = () => { const h = Math.ceil(document.body.getBoundingClientRect().height); if (h !== last) { last = h; parent.postMessage({ type: 'dshm-height', h }, '*') } }
106
+ new ResizeObserver(report).observe(document.body); addEventListener('load', report)
107
+ }
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))
115
+ poll()
116
+ </script>
117
+ </html>
@@ -0,0 +1,12 @@
1
+ // Usage: swift qr.swift <text> <out.png> (CoreImage QR, error correction M, 8x)
2
+ import CoreImage
3
+ import CoreImage.CIFilterBuiltins
4
+ import Foundation
5
+
6
+ let args = CommandLine.arguments
7
+ guard args.count == 3 else { FileHandle.standardError.write("usage: qr.swift <text> <out.png>\n".data(using: .utf8)!); exit(2) }
8
+ let filter = CIFilter.qrCodeGenerator()
9
+ filter.message = Data(args[1].utf8)
10
+ filter.correctionLevel = "M"
11
+ let image = filter.outputImage!.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
12
+ try CIContext().writePNGRepresentation(of: image, to: URL(fileURLWithPath: args[2]), format: .RGBA8, colorSpace: CGColorSpaceCreateDeviceRGB())