dsh-composer-plus 1.4.1 → 1.6.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.md +28 -7
- package/assets/features/keepFocus.webp +0 -0
- package/assets/features/mobileConnect.webp +0 -0
- package/assets/features/resumeAfterRestart.webp +0 -0
- package/client.js +208 -20
- package/index.js +271 -3
- package/locale/de.json +1 -1
- package/locale/en.json +1 -1
- package/locale/es.json +1 -1
- package/locale/fr.json +1 -1
- package/locale/hi.json +1 -1
- package/locale/id.json +1 -1
- package/locale/ja.json +1 -1
- package/locale/ko.json +1 -1
- package/locale/pt-BR.json +1 -1
- package/locale/ru.json +1 -1
- package/locale/tr.json +1 -1
- package/locale/vi.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +17 -5
- package/tether/gateway/admin.html +117 -0
- package/tether/gateway/qr.swift +12 -0
- package/tether/gateway/server.mjs +547 -0
package/index.js
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
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.
|
|
10
|
+
* - Resume after restart: remembers the chats that were running when dsh quit and continues them at the next start; off by
|
|
11
|
+
* default (it starts model turns without a click).
|
|
8
12
|
*/
|
|
9
13
|
import { readFileSync } from 'node:fs';
|
|
10
14
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
11
15
|
import { randomUUID } from 'node:crypto';
|
|
12
16
|
import { homedir } from 'node:os';
|
|
13
17
|
import { dirname, join } from 'node:path';
|
|
18
|
+
import { createGateway } from './tether/gateway/server.mjs';
|
|
14
19
|
|
|
15
20
|
export const name = 'dsh-composer-plus';
|
|
16
21
|
export const inject = ['connection', 'subprocess', 'sessionQuery', 'sessionController'];
|
|
@@ -21,9 +26,11 @@ export const GRANTS_PATH = '/api/composer-plus.grants';
|
|
|
21
26
|
export const CONTINUE_PATH = '/api/composer-plus.continue';
|
|
22
27
|
export const UPDATE_PATH = '/api/composer-plus.update';
|
|
23
28
|
export const SHOT_PATH = '/api/composer-plus.shot';
|
|
29
|
+
export const MOBILE_PATH = '/api/composer-plus.mobile';
|
|
30
|
+
export const RESUME_PATH = '/api/composer-plus.resume';
|
|
24
31
|
/** 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'];
|
|
32
|
+
export const SHOTS = ['changesPanel', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus',
|
|
33
|
+
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate'];
|
|
27
34
|
const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
28
35
|
const NPM_NAME = 'dsh-composer-plus';
|
|
29
36
|
const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
|
|
@@ -194,10 +201,185 @@ export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
|
|
|
194
201
|
return { ...info, newer: npm.ok };
|
|
195
202
|
}
|
|
196
203
|
|
|
204
|
+
/** Mobile connect switch: `{ on: true }` in a JSON file, off when the file is missing or unreadable. */
|
|
205
|
+
export function mobileSwitch(file) {
|
|
206
|
+
return {
|
|
207
|
+
read: async () => { try { return JSON.parse(await readFile(file, 'utf8'))?.on === true; } catch { return false; } },
|
|
208
|
+
write: async (on) => {
|
|
209
|
+
await mkdir(dirname(file), { recursive: true });
|
|
210
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
211
|
+
await writeFile(tmp, JSON.stringify({ on }), { mode: 0o600 });
|
|
212
|
+
await rename(tmp, file);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Mobile connect: one gateway at a time. Start and stop calls run in order, so a fast toggle never leaves two gateways or a
|
|
218
|
+
* half-closed one. `upstreamUrl` gives dsh's authenticated loopback URL; it is asked again at each DSH login. */
|
|
219
|
+
export function mobileGateway({ upstreamUrl, dir, make = createGateway, retryMs = 300 }) {
|
|
220
|
+
let gateway, admin, error, closed = false, queue = Promise.resolve();
|
|
221
|
+
const run = (fn) => (queue = queue.then(fn, fn));
|
|
222
|
+
// A plugin reload starts the new gateway while the old one still closes its pairing page: wait for the port (about 3 s).
|
|
223
|
+
const listenAdmin = async (g) => {
|
|
224
|
+
for (let attempt = 1; ; attempt++) {
|
|
225
|
+
try { return await g.listenAdmin(); } catch (e) {
|
|
226
|
+
if (e?.code !== 'EADDRINUSE' || attempt >= 10) throw e;
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const stop = async () => {
|
|
232
|
+
const g = gateway;
|
|
233
|
+
gateway = undefined; admin = undefined;
|
|
234
|
+
if (g !== undefined) await g.close().catch(() => {});
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
start: () => run(async () => {
|
|
238
|
+
if (gateway !== undefined || closed) return;
|
|
239
|
+
error = undefined;
|
|
240
|
+
try {
|
|
241
|
+
gateway = make({ dir, upstreamUrl });
|
|
242
|
+
admin = `http://127.0.0.1:${await listenAdmin(gateway)}/`;
|
|
243
|
+
await gateway.start();
|
|
244
|
+
} catch (e) { error = String(e?.message ?? e); if (admin === undefined) await stop(); }
|
|
245
|
+
}),
|
|
246
|
+
stop: () => run(stop),
|
|
247
|
+
/** Plugin unload: stop, and ignore any start that comes later. */
|
|
248
|
+
close: () => { closed = true; return run(stop); },
|
|
249
|
+
state: () => {
|
|
250
|
+
const running = gateway?.state().running === true;
|
|
251
|
+
// The gateway retries by itself (for example when Wi-Fi comes back): a running gateway has no error to show.
|
|
252
|
+
return { running, admin, ...(error === undefined || running ? {} : { error }) };
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Resume after restart: `{ on, sessions: [{ id, at }] }` in one owner-only JSON file. A missing or broken file is off. */
|
|
258
|
+
export function resumeStore(file) {
|
|
259
|
+
let queue = Promise.resolve();
|
|
260
|
+
return {
|
|
261
|
+
async read() {
|
|
262
|
+
try {
|
|
263
|
+
const data = JSON.parse(await readFile(file, 'utf8'));
|
|
264
|
+
const sessions = Array.isArray(data?.sessions) ? data.sessions.filter((s) => isText(s?.id) && Number.isFinite(s?.at)) : [];
|
|
265
|
+
return { on: data?.on === true, sessions };
|
|
266
|
+
} catch { return { on: false, sessions: [] }; }
|
|
267
|
+
},
|
|
268
|
+
/** Writes run in order, each replacing the file atomically, so a quit in the middle never leaves a half-written file. */
|
|
269
|
+
write(data) {
|
|
270
|
+
const done = queue.then(async () => {
|
|
271
|
+
await mkdir(dirname(file), { recursive: true });
|
|
272
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
273
|
+
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
274
|
+
await rename(tmp, file);
|
|
275
|
+
});
|
|
276
|
+
queue = done.catch(() => {});
|
|
277
|
+
return done;
|
|
278
|
+
},
|
|
279
|
+
flush: () => queue,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** At most this many chats continue at one start (newest first): a safety cap, not a queue. */
|
|
284
|
+
export const RESUME_MAX = 20;
|
|
285
|
+
/** A turn end that dsh caused, not the user: a quit (`aborted`, cause `disposed`; the goal driver's own shutdown cancels a
|
|
286
|
+
* goal round with cause `parent`) or a crash (`interrupted`, written when dsh opens the chat again). */
|
|
287
|
+
export const stoppedByDsh = (end) => end?.kind === 'interrupted' || (end?.kind === 'aborted' && ['disposed', 'parent'].includes(end.reason?.kind));
|
|
288
|
+
|
|
289
|
+
/** Resume after restart. `status()` and `turnEnd()` follow the top-level chats; `wake()` continues, once per start, the chats
|
|
290
|
+
* that were running when dsh stopped. A chat stays listed while it runs and when dsh stops its turn; any other turn end
|
|
291
|
+
* (done, failed, stopped by you) removes it. The list is saved at each change, so a crash keeps it too. */
|
|
292
|
+
export function resumeTracker({ store, isRoot, running = () => [], wakeOne, log = () => {}, now = Date.now }) {
|
|
293
|
+
let on = false;
|
|
294
|
+
let closed = false;
|
|
295
|
+
let carried = []; // the previous run's chats, until wake() takes them
|
|
296
|
+
const live = new Map(); // id → time it started running
|
|
297
|
+
const ends = new Map(); // id → newest turn end of this run; null while a turn is open
|
|
298
|
+
const list = () => [...carried.filter((s) => !live.has(s.id)), ...[...live].map(([id, at]) => ({ id, at }))];
|
|
299
|
+
const save = () => { if (!closed) store.write({ on, sessions: list() }).catch((error) => log(`resume list not saved: ${error?.message ?? error}`)); };
|
|
300
|
+
// Chats already running (switched on mid-turn, or a plugin reload) are tracked from now on.
|
|
301
|
+
const adopt = () => {
|
|
302
|
+
try { for (const id of running()) if (!live.has(id)) { live.set(id, now()); ends.set(id, null); } } catch { /* services gone */ }
|
|
303
|
+
};
|
|
304
|
+
const ready = store.read().then((data) => { on = data.on; carried = data.on ? data.sessions : []; if (on) adopt(); });
|
|
305
|
+
return {
|
|
306
|
+
ready,
|
|
307
|
+
isOn: () => on,
|
|
308
|
+
/** Save the switch; a failed save changes nothing. Switching off forgets the list. */
|
|
309
|
+
async setOn(next) {
|
|
310
|
+
await ready;
|
|
311
|
+
const before = { on, carried, live: [...live] };
|
|
312
|
+
on = next;
|
|
313
|
+
if (next) adopt(); else { carried = []; live.clear(); }
|
|
314
|
+
try { await store.write({ on, sessions: list() }); } catch (error) {
|
|
315
|
+
on = before.on; carried = before.carried; live.clear(); for (const [id, at] of before.live) live.set(id, at);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
status(agent, status) {
|
|
320
|
+
const end = ends.get(agent.id);
|
|
321
|
+
if (status !== 'running') ends.delete(agent.id);
|
|
322
|
+
if (!on || closed || !isRoot(agent)) return;
|
|
323
|
+
if (status === 'running') { if (!live.has(agent.id)) { live.set(agent.id, now()); save(); } }
|
|
324
|
+
// An open turn without an end: the shutdown could not write it (dsh 0.2.0-rc.2 closes it as `interrupted` at the next
|
|
325
|
+
// start). Keep the chat; wakeSession() reads the saved log before it continues anything.
|
|
326
|
+
else if (end !== null && !stoppedByDsh(end) && live.delete(agent.id)) save();
|
|
327
|
+
},
|
|
328
|
+
turnStart(id) { ends.set(id, null); },
|
|
329
|
+
turnEnd(id, reason) { ends.set(id, reason); },
|
|
330
|
+
/** Plugin unload: no more writes (dsh's shutdown makes every chat idle); waits for the last write. */
|
|
331
|
+
close() { closed = true; return store.flush(); },
|
|
332
|
+
async wake() {
|
|
333
|
+
await ready;
|
|
334
|
+
const queue = [...carried].sort((a, b) => b.at - a.at).slice(0, RESUME_MAX);
|
|
335
|
+
if (queue.length === 0) return [];
|
|
336
|
+
carried = [];
|
|
337
|
+
save(); // forget them first: a crash while waking never continues the same chats twice
|
|
338
|
+
const results = [];
|
|
339
|
+
for (const { id } of queue) {
|
|
340
|
+
if (closed || !on) break;
|
|
341
|
+
try { results.push({ id, result: await wakeOne(id) }); } catch (error) { results.push({ id, result: 'failed' }); log(`resume ${id} failed: ${error?.message ?? error}`); }
|
|
342
|
+
}
|
|
343
|
+
return results;
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Continue one chat after a restart, if dsh (not the user) stopped its last turn: the chat gets the same hidden "continue"
|
|
349
|
+
* step as One-click Continue. When the stopped turn was a goal round, the goal is armed again instead (dsh disarms goals at
|
|
350
|
+
* every start and waits for `/goal resume`), and dsh's goal driver starts the next round, so the model can still mark the
|
|
351
|
+
* goal complete. A goal is never armed for an ordinary turn: dsh does not keep whether it was armed before the quit. */
|
|
352
|
+
export async function wakeSession({ sessionController, sessionQuery, goals, archived = () => false }, id) {
|
|
353
|
+
if (archived(id)) return 'archived';
|
|
354
|
+
const found = await sessionController.resolveAgent(id); // opens the chat; a crashed turn is closed as `interrupted` here
|
|
355
|
+
if ('error' in found) return 'unavailable';
|
|
356
|
+
const { agent } = found;
|
|
357
|
+
if (agent.status !== 'idle') return 'busy';
|
|
358
|
+
const observation = await sessionQuery.observeSession(id, { projectionMode: 'none' });
|
|
359
|
+
let start, end, goalRound;
|
|
360
|
+
try {
|
|
361
|
+
const events = observation.events;
|
|
362
|
+
end = events.findLast((e) => e.type === 'turn/start' || e.type === 'turn/end');
|
|
363
|
+
start = events.findLast((e) => e.type === 'turn/start');
|
|
364
|
+
goalRound = start !== undefined && events.some((e) => e.seq > start.seq && e.type === 'user/message' && e.data?.source?.kind === 'goal');
|
|
365
|
+
} finally { observation[Symbol.dispose]?.(); }
|
|
366
|
+
if (end?.type !== 'turn/end' || !stoppedByDsh(end.data?.reason)) return 'skipped';
|
|
367
|
+
let goal;
|
|
368
|
+
try { goal = goalRound ? goals?.get(agent) : undefined; } catch { goal = undefined; }
|
|
369
|
+
// A pause during the stopped turn would have ended it as stopped by the user; so a goal paused after that turn started was
|
|
370
|
+
// paused by dsh's shutdown (its goal driver pauses a round it cancels).
|
|
371
|
+
if (goal !== undefined && ((goal.phase === 'active' && goal.activation !== 'armed') || (goal.phase === 'paused' && goal.updatedAt >= start.time))) {
|
|
372
|
+
try { goals.resume(agent, { id: goal.id, revision: goal.revision }); return 'goal'; } catch { /* no goal rounds left: continue the turn */ }
|
|
373
|
+
}
|
|
374
|
+
agent.steer({ id: randomUUID(), role: 'user', content: [], source: { kind: CONTINUE_SOURCE } });
|
|
375
|
+
return 'continued';
|
|
376
|
+
}
|
|
377
|
+
|
|
197
378
|
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
198
379
|
const isText = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max;
|
|
199
380
|
|
|
200
381
|
export function apply(ctx) {
|
|
382
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
201
383
|
// The empty wake marker opens the step; it is then replaced by a fresh context message. Only messages
|
|
202
384
|
// claimed from the inbox render as a chat bubble, so the replacement stays out of Chat, but it gives the
|
|
203
385
|
// model a final user turn: providers such as Claude reject a request that ends on an assistant message.
|
|
@@ -292,7 +474,7 @@ export function apply(ctx) {
|
|
|
292
474
|
try { return json(await checkUpdate(fetch, PACKAGE, request.signal)); } catch (error) { return json({ error: String(error?.message ?? error) }, 502); }
|
|
293
475
|
},
|
|
294
476
|
});
|
|
295
|
-
const store = grantsStore(join(
|
|
477
|
+
const store = grantsStore(join(home, 'storages', 'dsh-composer-plus', 'grants.json'));
|
|
296
478
|
ctx.connection.fetch.register({
|
|
297
479
|
path: GRANTS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
298
480
|
fetch: async (request) => {
|
|
@@ -304,4 +486,90 @@ export function apply(ctx) {
|
|
|
304
486
|
return json({ error: 'bad request' }, 400);
|
|
305
487
|
},
|
|
306
488
|
});
|
|
489
|
+
// Mobile connect (Tether). The phone gateway needs dsh's web server for its loopback URL; a profile without one
|
|
490
|
+
// (headless) never starts it. Off until the user switches it on in Settings.
|
|
491
|
+
const mobileSaved = mobileSwitch(join(home, 'storages', 'dsh-composer-plus', 'mobile.json'));
|
|
492
|
+
let mobile;
|
|
493
|
+
let mobileOn; // the user's choice; the saved value applies only until the user changes it
|
|
494
|
+
const mobileRead = mobileSaved.read().then((on) => { mobileOn ??= on; });
|
|
495
|
+
let mobileQueue = Promise.resolve(); // one switch change at a time: save, then start or stop
|
|
496
|
+
ctx.inject?.(['webServer'], (webCtx) => {
|
|
497
|
+
const gateway = mobileGateway({
|
|
498
|
+
dir: process.env.DSH_MOBILE_DIR || join(home, 'storages', 'dsh-composer-plus', 'mobile'),
|
|
499
|
+
upstreamUrl: () => ctx.connection.authenticatedUrl(`http://127.0.0.1:${webCtx.webServer.port}`),
|
|
500
|
+
});
|
|
501
|
+
mobile = gateway;
|
|
502
|
+
webCtx.effect(() => {
|
|
503
|
+
mobileRead.then(() => { if (mobileOn && mobile === gateway) gateway.start(); });
|
|
504
|
+
return () => { if (mobile === gateway) mobile = undefined; gateway.close(); };
|
|
505
|
+
}, 'dsh-composer-plus: mobile connect');
|
|
506
|
+
});
|
|
507
|
+
ctx.connection.fetch.register({
|
|
508
|
+
path: MOBILE_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
509
|
+
fetch: async (request) => {
|
|
510
|
+
if (request.method === 'POST') {
|
|
511
|
+
let body;
|
|
512
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
513
|
+
if (typeof body?.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
514
|
+
const change = async () => {
|
|
515
|
+
await mobileRead;
|
|
516
|
+
await mobileSaved.write(body.on); // a failed save changes nothing
|
|
517
|
+
mobileOn = body.on;
|
|
518
|
+
if (mobile !== undefined) await (body.on ? mobile.start() : mobile.stop());
|
|
519
|
+
};
|
|
520
|
+
const done = mobileQueue.then(change);
|
|
521
|
+
mobileQueue = done.catch(() => {}); // a failed save answers only its own request
|
|
522
|
+
try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
523
|
+
}
|
|
524
|
+
await mobileRead;
|
|
525
|
+
const on = mobileOn === true;
|
|
526
|
+
return json({ on, available: mobile !== undefined, ...(on && mobile !== undefined ? mobile.state() : { running: false }) });
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
installResume(ctx, home);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Resume after restart: follow the top-level chats, and once dsh is ready, continue the ones its last shutdown stopped.
|
|
533
|
+
* Services that a profile may not have (`agents`, `goals`, `workspaceRegistry`, `appReady`) are read with `ctx.get`. */
|
|
534
|
+
function installResume(ctx, home) {
|
|
535
|
+
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
536
|
+
const isRoot = (agent) => {
|
|
537
|
+
const agents = get('agents');
|
|
538
|
+
return agents?.roots?.().includes(agent) === true && agent.session?.header?.origin !== 'subagent';
|
|
539
|
+
};
|
|
540
|
+
const tracker = resumeTracker({
|
|
541
|
+
store: resumeStore(join(home, 'storages', 'dsh-composer-plus', 'resume.json')),
|
|
542
|
+
isRoot,
|
|
543
|
+
running: () => (get('agents')?.roots?.() ?? []).filter((a) => a.status === 'running' && isRoot(a)).map((a) => a.id),
|
|
544
|
+
// ponytail: an unreadable archive list counts as "not archived"; dsh's own archived-session gate still blocks the step.
|
|
545
|
+
wakeOne: (id) => wakeSession({ sessionController: ctx.sessionController, sessionQuery: ctx.sessionQuery, goals: get('goals'),
|
|
546
|
+
archived: (sid) => { try { return get('workspaceRegistry')?.archivedSessionIds?.includes(sid) === true; } catch { return false; } } }, id),
|
|
547
|
+
log: (text) => ctx.logger?.warn?.(`dsh-composer-plus: ${text}`),
|
|
548
|
+
});
|
|
549
|
+
ctx.on('agent/status', ({ agent, status }) => tracker.status(agent, status));
|
|
550
|
+
ctx.on('session/event', (session, event) => {
|
|
551
|
+
if (event.type === 'turn/start') tracker.turnStart(session.id);
|
|
552
|
+
else if (event.type === 'turn/end') tracker.turnEnd(session.id, event.data?.reason);
|
|
553
|
+
});
|
|
554
|
+
// Wake after dsh's own startup (web server, plugins) is done; a profile without the ready signal waits a moment instead.
|
|
555
|
+
ctx.effect?.(() => {
|
|
556
|
+
let done = false;
|
|
557
|
+
const run = () => { if (!done) { done = true; tracker.wake().catch(() => {}); } };
|
|
558
|
+
const ready = get('appReady');
|
|
559
|
+
const cancel = ready?.onReady !== undefined ? ready.onReady(run) : (() => { const t = setTimeout(run, 3000); return () => clearTimeout(t); })();
|
|
560
|
+
return () => { done = true; cancel?.(); tracker.close(); };
|
|
561
|
+
}, 'dsh-composer-plus: resume after restart');
|
|
562
|
+
ctx.connection.fetch.register({
|
|
563
|
+
path: RESUME_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
564
|
+
fetch: async (request) => {
|
|
565
|
+
if (request.method === 'POST') {
|
|
566
|
+
let body;
|
|
567
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
568
|
+
if (typeof body?.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
569
|
+
try { await tracker.setOn(body.on); } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
570
|
+
}
|
|
571
|
+
await tracker.ready;
|
|
572
|
+
return json({ on: tracker.isOn() });
|
|
573
|
+
},
|
|
574
|
+
});
|
|
307
575
|
}
|
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, Fortsetzen nach Neustart, 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, resume after restart, 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, reanudar tras reiniciar, 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, reprise après redémarrage, 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, lanjutkan setelah mulai ulang, urutkan antrean dengan seret, tautan, notifikasi, akses dari iPhone (Tether), 11 bahasa, dan pembaruan otomatis."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/ja.json
CHANGED
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, retomar após reiniciar, 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, yeniden açılışta sürdürme, 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, tiếp tục sau khi khởi động lại, 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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composer-plus",
|
|
3
|
-
"version": "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.6.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, 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",
|
|
7
7
|
"type": "module",
|
|
@@ -28,13 +28,21 @@
|
|
|
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",
|
|
41
|
+
"restart",
|
|
42
|
+
"resume",
|
|
37
43
|
"sort-by-status",
|
|
44
|
+
"tailscale",
|
|
45
|
+
"tether",
|
|
38
46
|
"translation"
|
|
39
47
|
],
|
|
40
48
|
"exports": {
|
|
@@ -46,16 +54,19 @@
|
|
|
46
54
|
"files": [
|
|
47
55
|
"index.js",
|
|
48
56
|
"client.js",
|
|
57
|
+
"tether/gateway/server.mjs",
|
|
58
|
+
"tether/gateway/admin.html",
|
|
59
|
+
"tether/gateway/qr.swift",
|
|
49
60
|
"locale/*.json",
|
|
50
61
|
"assets/icon.svg",
|
|
51
62
|
"cordis.patch.yml",
|
|
52
|
-
"README.md",
|
|
63
|
+
"/README.md",
|
|
53
64
|
"LICENSE",
|
|
54
65
|
"locales/LICENSE",
|
|
55
66
|
"assets/features/*.webp"
|
|
56
67
|
],
|
|
57
68
|
"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",
|
|
69
|
+
"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/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
70
|
"build:locales": "node scripts/build-locales.mjs",
|
|
60
71
|
"prepublishOnly": "npm test"
|
|
61
72
|
},
|
|
@@ -76,7 +87,8 @@
|
|
|
76
87
|
"@deepseek-ai/dsh-client-ui-session",
|
|
77
88
|
"@deepseek-ai/dsh-api-remotes",
|
|
78
89
|
"@deepseek-ai/dsh-api-session-controller",
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
90
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
91
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
80
92
|
]
|
|
81
93
|
}
|
|
82
94
|
},
|
|
@@ -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())
|