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