bothouse-dsh 0.1.62 → 0.1.63
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/collab/devices.html +6 -1
- package/collab/pairing.mjs +116 -6
- package/collab/panel.mjs +15 -6
- package/collab/plugin.mjs +5 -2
- package/node_modules/bothouse-dsh-channels/client.js +107 -7
- package/package.json +1 -1
package/collab/devices.html
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
<div class="code" id="codeText">------</div>
|
|
73
73
|
<p class="center muted" id="codeLeft"></p>
|
|
74
74
|
<p class="center muted">在另一台电脑点「连到另一台电脑」,选这台电脑,输入上面的数字。只能用一次。</p>
|
|
75
|
+
<p class="center muted" id="phoneUrl" hidden></p>
|
|
75
76
|
<div class="center"><button id="cancelCode">不连了</button></div>
|
|
76
77
|
</div>
|
|
77
78
|
<p class="err" id="hostErr"></p>
|
|
@@ -194,7 +195,11 @@ function hideCode(note) {
|
|
|
194
195
|
$('newCode').onclick = async () => {
|
|
195
196
|
$('hostErr').textContent = '';
|
|
196
197
|
$('newCode').disabled = true;
|
|
197
|
-
try {
|
|
198
|
+
try {
|
|
199
|
+
const r = await post('api/pair/code'); showCode(r.code, r.expires_at);
|
|
200
|
+
// #1327: a phone in the same tailnet opens this address in its browser and types the number there.
|
|
201
|
+
$('phoneUrl').hidden = !r.listening; $('phoneUrl').textContent = r.listening ? `手机:开着 Tailscale,用浏览器打开 http://${r.listening}/ 输入数字` : '';
|
|
202
|
+
}
|
|
198
203
|
catch (e) { $('hostErr').textContent = e.message; }
|
|
199
204
|
finally { $('newCode').disabled = false; }
|
|
200
205
|
};
|
package/collab/pairing.mjs
CHANGED
|
@@ -38,6 +38,11 @@ export const UNTIL_GRACE_MS = 10_000;
|
|
|
38
38
|
export const REMOTE_PREFIX = '/collab/remote';
|
|
39
39
|
// Marks a request the host listener authenticated with a device credential (plugin.mjs owner()).
|
|
40
40
|
export const REMOTE_OWNER = Symbol('collab.remoteOwner');
|
|
41
|
+
// #1327: a phone in the tailnet opens this machine's pairing port in a browser, types the same
|
|
42
|
+
// number and keeps the device credential in this cookie (no DSH on the phone to forward for it).
|
|
43
|
+
export const PAIR_COOKIE = 'dsh_pair';
|
|
44
|
+
// 「记住这台手机」的 cookie 存活:浏览器允许的上限 400 天(RFC 6265bis Max-Age 上限);踢掉即失效。
|
|
45
|
+
const REMEMBER_COOKIE_S = 400 * 24 * 3600;
|
|
41
46
|
|
|
42
47
|
const TAILSCALE_BINS = ['tailscale', '/opt/homebrew/bin/tailscale', '/usr/local/bin/tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale', 'C:\\Program Files\\Tailscale\\tailscale.exe'];
|
|
43
48
|
// 沿用 plugin.mjs MAX_BODY(256 KiB)。
|
|
@@ -136,7 +141,7 @@ export class PairHost {
|
|
|
136
141
|
* @param {{ code: string, mode: string, name: string, address?: string, duration_ms?: number|null }} claim
|
|
137
142
|
* @returns {{ token: string, device: object }}
|
|
138
143
|
*/
|
|
139
|
-
claim({ code, mode, name, address, duration_ms }) {
|
|
144
|
+
claim({ code, mode, name, address, duration_ms, kind }) {
|
|
140
145
|
if (!MODES.includes(mode)) throw new PairError('mode', '连接方式只能是「临时」或「记住这台电脑」');
|
|
141
146
|
const duration = tempDuration(mode, duration_ms);
|
|
142
147
|
if (this.code === undefined) throw new PairError('no-code', '那台电脑现在没有在等配对,请在那台电脑上点「让别的电脑连我」');
|
|
@@ -152,8 +157,8 @@ export class PairHost {
|
|
|
152
157
|
const token = randomBytes(32).toString('base64url');
|
|
153
158
|
const now = this.now();
|
|
154
159
|
const device = {
|
|
155
|
-
id: randomUUID(), name: String(name ?? '').trim().slice(0, 80) || '另一台电脑', address: String(address ?? ''),
|
|
156
|
-
mode, token_hash: sha256(token).toString('hex'), created_at: now, last_seen: now,
|
|
160
|
+
id: randomUUID(), name: String(name ?? '').trim().slice(0, 80) || (kind === 'browser' ? '手机浏览器' : '另一台电脑'), address: String(address ?? ''),
|
|
161
|
+
kind: kind === 'browser' ? 'browser' : 'dsh', mode, token_hash: sha256(token).toString('hex'), created_at: now, last_seen: now,
|
|
157
162
|
expires_at: duration === null ? null : now + duration,
|
|
158
163
|
};
|
|
159
164
|
this.devices.set(device.id, device);
|
|
@@ -212,11 +217,11 @@ export class PairHost {
|
|
|
212
217
|
// Same clock and length the number itself uses (a constructor may override codeTtl in tests).
|
|
213
218
|
kickGrace() { return this.lastRevokeAt !== undefined && this.now() - this.lastRevokeAt < this.codeTtl; }
|
|
214
219
|
|
|
215
|
-
view(d) { return { id: d.id, name: d.name, address: d.address, mode: d.mode, created_at: d.created_at, last_seen: d.last_seen, expires_at: d.expires_at }; }
|
|
220
|
+
view(d) { return { id: d.id, name: d.name, address: d.address, kind: d.kind ?? 'dsh', mode: d.mode, created_at: d.created_at, last_seen: d.last_seen, expires_at: d.expires_at }; }
|
|
216
221
|
|
|
217
222
|
save() {
|
|
218
223
|
saveJSON(this.file, { devices: [...this.devices.values()].filter(d => d.mode === 'remember')
|
|
219
|
-
.map(({ id, name, address, token_hash, created_at, last_seen }) => ({ id, name, address, token_hash, created_at, last_seen })) });
|
|
224
|
+
.map(({ id, name, address, kind, token_hash, created_at, last_seen }) => ({ id, name, address, kind, token_hash, created_at, last_seen })) });
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
|
|
@@ -515,6 +520,87 @@ function bearer(req) {
|
|
|
515
520
|
return m === null ? undefined : m[1];
|
|
516
521
|
}
|
|
517
522
|
|
|
523
|
+
/** The phone's device credential from its cookie, if it sent one. */
|
|
524
|
+
export function cookieToken(req) {
|
|
525
|
+
for (const part of String(req.headers.cookie ?? '').split(';')) {
|
|
526
|
+
const i = part.indexOf('=');
|
|
527
|
+
if (i > 0 && part.slice(0, i).trim() === PAIR_COOKIE) return decodeURIComponent(part.slice(i + 1).trim());
|
|
528
|
+
}
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* HttpOnly + SameSite=Strict; no Secure because the tailnet carries plain http (WireGuard already
|
|
534
|
+
* encrypts it). Remembered phones keep it across browser restarts, temporary ones only for the
|
|
535
|
+
* browser session (or the custom length).
|
|
536
|
+
*/
|
|
537
|
+
export function pairCookie(token, device) {
|
|
538
|
+
const base = `${PAIR_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict`;
|
|
539
|
+
if (device.mode === 'remember') return `${base}; Max-Age=${REMEMBER_COOKIE_S}`;
|
|
540
|
+
if (device.expires_at !== null) return `${base}; Max-Age=${Math.max(1, Math.round((device.expires_at - device.created_at) / 1000))}`;
|
|
541
|
+
return base;
|
|
542
|
+
}
|
|
543
|
+
const CLEAR_COOKIE = `${PAIR_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
|
544
|
+
|
|
545
|
+
const wantsHTML = req => req.method === 'GET' && String(req.headers.accept ?? '').includes('text/html');
|
|
546
|
+
|
|
547
|
+
/** The number page a phone sees at http://<tailnet address>:3082/. Plain on purpose (#1327 范围收窄). */
|
|
548
|
+
export function phonePairPage({ name, kicked = false }) {
|
|
549
|
+
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
550
|
+
const note = kicked ? '<p id="note">这台手机已被那台电脑断开。要再看,请在那台电脑上重新生成数字,在这里输入。</p>' : '';
|
|
551
|
+
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>连到 ${esc(name)}</title>
|
|
552
|
+
<style>:root{color-scheme:light dark}body{font:16px/1.6 -apple-system,"PingFang SC",sans-serif;margin:0;padding:24px 16px;max-width:420px;margin-inline:auto}input[type=text]{box-sizing:border-box;width:100%;font-size:28px;letter-spacing:6px;text-align:center;padding:10px}button{width:100%;font-size:18px;padding:12px;margin-top:12px}#msg{min-height:1.6em;color:#c0392b}label{display:block;margin-top:12px}</style>
|
|
553
|
+
<body><h1 style="font-size:20px">连到 ${esc(name)}</h1>${note}
|
|
554
|
+
<p>在那台电脑的「已连接的设备」里点「让别的电脑连我」,把显示的 6 位数字输到这里。</p>
|
|
555
|
+
<form id="f"><input id="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="7" placeholder="000000" required>
|
|
556
|
+
<label><input id="remember" type="checkbox"> 记住这台手机(不勾就是临时,关掉浏览器就断)</label>
|
|
557
|
+
<button type="submit">连接</button><p id="msg" role="alert"></p></form>
|
|
558
|
+
<script>
|
|
559
|
+
document.getElementById('f').addEventListener('submit', async e => {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
const msg = document.getElementById('msg');
|
|
562
|
+
msg.textContent = '';
|
|
563
|
+
try {
|
|
564
|
+
const r = await fetch('/collab/pair/browser', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin',
|
|
565
|
+
body: JSON.stringify({ code: document.getElementById('code').value, remember: document.getElementById('remember').checked }) });
|
|
566
|
+
const j = await r.json().catch(() => ({}));
|
|
567
|
+
if (r.ok) { location.href = '/collab'; return; }
|
|
568
|
+
msg.textContent = j.error || ('连接失败(' + r.status + ')');
|
|
569
|
+
} catch { msg.textContent = '连不上那台电脑,请确认手机的 Tailscale 开着'; }
|
|
570
|
+
});
|
|
571
|
+
</script></body></html>`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Added to pages a phone opens: when the host kicks it, say so in words instead of a page that
|
|
575
|
+
// quietly stops updating. Asks the same whoami the controller DSH asks, at its heartbeat pace.
|
|
576
|
+
const KICK_WATCH = `<script>(()=>{const t=setInterval(async()=>{let r;try{r=await fetch('/collab/pair/whoami',{credentials:'same-origin',cache:'no-store'})}catch{return}if(r.status!==401)return;clearInterval(t);document.body.innerHTML='<div style="font:16px/1.6 -apple-system,sans-serif;padding:24px 16px"><p>这台手机已被那台电脑断开。</p><p><a href="/">重新输数字连接</a></p></div>'},${ALIVE_EVERY_MS})})();</script>`;
|
|
577
|
+
|
|
578
|
+
/** Append a snippet to an HTML response the route writes (only HTML; streams and JSON pass). */
|
|
579
|
+
function withHTMLTail(res, tail) {
|
|
580
|
+
const { writeHead, write, end } = res;
|
|
581
|
+
let html = false;
|
|
582
|
+
const chunks = [];
|
|
583
|
+
res.writeHead = function (status, ...args) {
|
|
584
|
+
const headers = args.find(a => a !== null && typeof a === 'object' && !Array.isArray(a));
|
|
585
|
+
const type = String(headers?.['content-type'] ?? headers?.['Content-Type'] ?? res.getHeader('content-type') ?? '');
|
|
586
|
+
html = type.includes('text/html');
|
|
587
|
+
if (html) { if (headers) { delete headers['content-length']; delete headers['Content-Length']; } res.removeHeader('content-length'); }
|
|
588
|
+
return writeHead.call(this, status, ...args);
|
|
589
|
+
};
|
|
590
|
+
res.write = function (chunk, ...rest) {
|
|
591
|
+
if (!html) return write.call(this, chunk, ...rest);
|
|
592
|
+
chunks.push(Buffer.from(chunk));
|
|
593
|
+
return true;
|
|
594
|
+
};
|
|
595
|
+
res.end = function (chunk, ...rest) {
|
|
596
|
+
if (!html) return end.call(this, chunk, ...rest);
|
|
597
|
+
if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') chunks.push(Buffer.from(chunk));
|
|
598
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
599
|
+
const i = body.lastIndexOf('</body>');
|
|
600
|
+
return end.call(this, i < 0 ? body + tail : body.slice(0, i) + tail + body.slice(i));
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
518
604
|
/**
|
|
519
605
|
* Host listener request handler: pairing endpoints for anyone in the tailnet, everything else
|
|
520
606
|
* under /collab only with a device credential, handed to the plugin's own route as the owner.
|
|
@@ -529,13 +615,30 @@ export function hostHandler({ host, name, route, whois = whoisName, allowLoopbac
|
|
|
529
615
|
if (url.pathname === '/collab/pair/hello' && req.method === 'GET') {
|
|
530
616
|
return sendJSON(res, 200, { app: 'dsh-collab', name, waiting: host.codeLive() });
|
|
531
617
|
}
|
|
618
|
+
// #1327: a phone browser pairs here directly and keeps the credential as a cookie.
|
|
619
|
+
if (url.pathname === '/collab/pair/browser' && req.method === 'POST') {
|
|
620
|
+
const body = await readBody(req);
|
|
621
|
+
const named = isTailnetAddress(peer) ? await whois(String(peer).replace(/^::ffff:/, '')) : undefined;
|
|
622
|
+
const { token, device } = host.claim({ code: body?.code, mode: body?.remember === true ? 'remember' : 'temp', name: named, address: peer, kind: 'browser' });
|
|
623
|
+
res.setHeader('set-cookie', pairCookie(token, device));
|
|
624
|
+
return sendJSON(res, 200, { ok: true, device, host_name: name, to: '/collab' });
|
|
625
|
+
}
|
|
532
626
|
if (url.pathname === '/collab/pair/claim' && req.method === 'POST') {
|
|
533
627
|
const body = await readBody(req);
|
|
534
628
|
const named = isTailnetAddress(peer) ? await whois(String(peer).replace(/^::ffff:/, '')) : undefined;
|
|
535
629
|
const { token, device } = host.claim({ code: body?.code, mode: body?.mode, name: named || body?.name, address: peer, duration_ms: body?.duration_ms });
|
|
536
630
|
return sendJSON(res, 200, { token, device, host_name: name });
|
|
537
631
|
}
|
|
538
|
-
const
|
|
632
|
+
const fromCookie = bearer(req) === undefined ? cookieToken(req) : undefined;
|
|
633
|
+
const device = host.verify(bearer(req) ?? fromCookie);
|
|
634
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
635
|
+
if (device !== undefined) { res.writeHead(302, { location: '/collab', 'cache-control': 'no-store' }); return res.end(); }
|
|
636
|
+
// A cookie this machine no longer knows: the phone was kicked (or its time ran out).
|
|
637
|
+
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
|
|
638
|
+
if (fromCookie !== undefined) headers['set-cookie'] = CLEAR_COOKIE;
|
|
639
|
+
res.writeHead(200, headers);
|
|
640
|
+
return res.end(phonePairPage({ name, kicked: fromCookie !== undefined }));
|
|
641
|
+
}
|
|
539
642
|
if (url.pathname === '/collab/pair/whoami' && req.method === 'GET') {
|
|
540
643
|
// Answers with the device's own view, so a controller whose link was kicked can tell
|
|
541
644
|
// "revoked" from "that machine is off". Same 401 shape as every other refused request.
|
|
@@ -550,9 +653,15 @@ export function hostHandler({ host, name, route, whois = whoisName, allowLoopbac
|
|
|
550
653
|
return sendJSON(res, 200, { ok: true });
|
|
551
654
|
}
|
|
552
655
|
if (device === undefined) {
|
|
656
|
+
// A phone opening a page without (or with a dead) credential goes to the number page.
|
|
657
|
+
if (wantsHTML(req) && bearer(req) === undefined) { res.writeHead(302, { location: '/', 'cache-control': 'no-store' }); return res.end(); }
|
|
553
658
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'x-collab-pair': 'revoked' });
|
|
554
659
|
return res.end(JSON.stringify({ error: 'device not paired', code: 'revoked' }));
|
|
555
660
|
}
|
|
661
|
+
// The cookie rides along on any same-site request; writes must come from this origin's pages.
|
|
662
|
+
if (fromCookie !== undefined && req.method !== 'GET' && req.method !== 'HEAD' && req.headers.origin !== undefined && req.headers.origin !== `http://${req.headers.host}`) {
|
|
663
|
+
return sendJSON(res, 403, { error: 'cross-origin request refused' });
|
|
664
|
+
}
|
|
556
665
|
// Pairing management stays with the person at this machine.
|
|
557
666
|
if (url.pathname.startsWith('/collab/api/pair') || url.pathname.startsWith('/collab/api/remote') || url.pathname.startsWith(`${REMOTE_PREFIX}/`) || url.pathname === '/collab/devices') {
|
|
558
667
|
return sendJSON(res, 403, { error: '配对和设备管理只能在这台电脑上操作' });
|
|
@@ -560,6 +669,7 @@ export function hostHandler({ host, name, route, whois = whoisName, allowLoopbac
|
|
|
560
669
|
if (url.pathname !== '/collab' && !url.pathname.startsWith('/collab/')) return sendJSON(res, 404, { error: 'not found' });
|
|
561
670
|
req[REMOTE_OWNER] = true;
|
|
562
671
|
host.hold(device.id, res);
|
|
672
|
+
if (fromCookie !== undefined && req.method === 'GET') withHTMLTail(res, KICK_WATCH);
|
|
563
673
|
return await route(req, res);
|
|
564
674
|
} catch (err) {
|
|
565
675
|
if (err instanceof PairError) return sendJSON(res, PAIR_STATUS[err.code] ?? 400, { error: err.message, code: err.code });
|
package/collab/panel.mjs
CHANGED
|
@@ -83,13 +83,22 @@ const escapeHTML = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<':
|
|
|
83
83
|
*/
|
|
84
84
|
function sendNotice(res, status, reason, help) {
|
|
85
85
|
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
86
|
+
// #579: same look as the panel itself (bridge/web/src/index.css --ui-*): white canvas, 12px card
|
|
87
|
+
// with a 1px hairline, ink ramp, and the same values again for dark mode.
|
|
86
88
|
res.end(`<!doctype html><meta charset="utf-8"><title>bothouse</title><meta name="color-scheme" content="light dark">`
|
|
87
|
-
+ `<
|
|
88
|
-
+
|
|
89
|
-
+
|
|
90
|
-
+ (
|
|
91
|
-
+
|
|
92
|
-
+
|
|
89
|
+
+ `<style>:root{--c:#fff;--s:#fff;--l:#e6e9ef;--i:#17181b;--m:#5a5a55;--a:#16a34a}`
|
|
90
|
+
+ `@media (prefers-color-scheme:dark){:root{--c:#0d0d0f;--s:#16171a;--l:#2c2e34;--i:#eceef2;--m:#a5aab4;--a:#3dd68c}}`
|
|
91
|
+
+ `body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--c);color:var(--m);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}`
|
|
92
|
+
+ `main{box-sizing:border-box;width:min(520px,calc(100% - 32px));padding:24px;border:1px solid var(--l);border-radius:12px;background:var(--s)}`
|
|
93
|
+
+ `h3{margin:0 0 8px;font-size:16px;font-weight:600;color:var(--i)}p{margin:0 0 8px}`
|
|
94
|
+
+ `button{margin-top:8px;font:inherit;font-weight:500;height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--l);background:var(--s);color:var(--i);cursor:pointer}`
|
|
95
|
+
+ `button:hover{border-color:var(--m)}button:focus-visible{outline:2px solid var(--a);outline-offset:2px}</style>`
|
|
96
|
+
+ `<main>`
|
|
97
|
+
+ `<h3>打不开 bothouse 面板</h3>`
|
|
98
|
+
+ `<p>${escapeHTML(reason)}</p>`
|
|
99
|
+
+ (help ? `<p>${escapeHTML(help)}</p>` : '')
|
|
100
|
+
+ `<button type="button" onclick="location.reload()">重试</button>`
|
|
101
|
+
+ `</main>`);
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/**
|
package/collab/plugin.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { createRequire } from 'node:module';
|
|
|
9
9
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
10
10
|
import { isAbsolute, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
|
-
import { Store, CollabError, publicMessage } from './store.mjs';
|
|
12
|
+
import { Store, CollabError, MAX_TEXT, publicMessage } from './store.mjs';
|
|
13
13
|
import { LOCK_FILE, acquireLockWithOwner, clearLockOwner } from './instance-lock.mjs';
|
|
14
14
|
import { localConnection, isLocalOwner, mountSharedHost, refuse } from './local-host.mjs';
|
|
15
15
|
import { startBotPort } from './bot-port.mjs';
|
|
@@ -350,7 +350,10 @@ export async function apply(ctx, rawConfig) {
|
|
|
350
350
|
workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
|
|
351
351
|
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
|
|
352
352
|
queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])),
|
|
353
|
-
max_upload_bytes: config.maxUploadBytes
|
|
353
|
+
max_upload_bytes: config.maxUploadBytes,
|
|
354
|
+
// #1445: the text cap in the unit the store compares (characters), so the panel checks a
|
|
355
|
+
// typed message against the same number instead of keeping a second copy of it.
|
|
356
|
+
max_text_chars: MAX_TEXT });
|
|
354
357
|
}
|
|
355
358
|
if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
|
|
356
359
|
if (path === '/collab/api/lead' && req.method === 'GET') {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-client-source-sha256:
|
|
1
|
+
// dsh-client-source-sha256: f5b1b355fea92d3cfa21a9764dfa14f60d869ca0934ae716cbb13a4d1f6df57d
|
|
2
2
|
// dsh-style-sha256: 9ec6f9be158431d36e8c658f1b8def48515a0e80fe35ad14ccfa7202308bb0cd
|
|
3
3
|
window.__ModuleLoader__.load({
|
|
4
4
|
id: "bothouse-dsh-channels",
|
|
@@ -66,6 +66,18 @@ const attachmentUrl = (id) => `collab/api/attachments/${encodeURIComponent(id)}`
|
|
|
66
66
|
const MAX_MESSAGE_BODY = 8388608;
|
|
67
67
|
/** #197: fallback when the service does not report its cap (`max_upload_bytes`); matches collab's DEFAULT_MAX_UPLOAD_BYTES. */
|
|
68
68
|
const DEFAULT_MAX_UPLOAD_BYTES = 1073741824;
|
|
69
|
+
/**
|
|
70
|
+
* #1445: fallback text cap when the service does not report `max_text_chars`.
|
|
71
|
+
* 来源:dsh/collab/store.mjs:8 `MAX_TEXT = 128 * 1024`,同一数值也由 `/collab/api/state` 的
|
|
72
|
+
* `max_text_chars` 回传;这份抄写只覆盖还没有该字段的旧宿主。
|
|
73
|
+
*/
|
|
74
|
+
const DEFAULT_MAX_TEXT_CHARS = 131072;
|
|
75
|
+
/**
|
|
76
|
+
* #1445: whether the typed text alone exceeds the service's character cap. The service compares
|
|
77
|
+
* `text.length` against MAX_TEXT (collab/store.mjs), so this must count characters too — a byte
|
|
78
|
+
* count would let 131072 Chinese characters (about 400 KB) through to a `too-long` refusal.
|
|
79
|
+
*/
|
|
80
|
+
const textTooLarge = (text, maxChars) => text.length > maxChars;
|
|
69
81
|
/** Whether a message with these attachments would exceed the server's body cap (checked before sending). */
|
|
70
82
|
const messageTooLarge = (text, attachments) => new TextEncoder().encode(JSON.stringify({
|
|
71
83
|
text,
|
|
@@ -121,7 +133,8 @@ const INITIAL = {
|
|
|
121
133
|
taskChat: void 0,
|
|
122
134
|
hasMoreOlder: false,
|
|
123
135
|
outbox: [],
|
|
124
|
-
maxUploadBytes: DEFAULT_MAX_UPLOAD_BYTES
|
|
136
|
+
maxUploadBytes: DEFAULT_MAX_UPLOAD_BYTES,
|
|
137
|
+
maxTextChars: DEFAULT_MAX_TEXT_CHARS
|
|
125
138
|
};
|
|
126
139
|
/** Position of message `id` in rows ascending by id, or -1. */
|
|
127
140
|
function indexOfId(rows, id) {
|
|
@@ -218,18 +231,20 @@ function memberNameRefused(name) {
|
|
|
218
231
|
* @param t - the panel's dictionary, so an English UI stays English.
|
|
219
232
|
* @param place - the field being edited when the action failed.
|
|
220
233
|
* @param typed - the name the person typed, for the copy that names it.
|
|
234
|
+
* @param limits - the caps the panel knows about, for the copy that quotes one back (#1445).
|
|
221
235
|
* @returns one line to show under the form.
|
|
222
236
|
*/
|
|
223
|
-
function errorText(error, t, place = "other", typed = "") {
|
|
237
|
+
function errorText(error, t, place = "other", typed = "", limits = {}) {
|
|
224
238
|
const message = error instanceof Error ? error.message : String(error);
|
|
225
239
|
if (!(error instanceof ServiceError)) return message;
|
|
240
|
+
if (error.code === "too-long") return t("messageTooLarge", { limit: t("charCount", { count: limits.textChars ?? 131072 }) });
|
|
226
241
|
const code = error.code;
|
|
227
242
|
const name = place === "room" ? typed.trim().replace(/^#/, "") : typed.trim();
|
|
228
243
|
if (code === "conflict" && place === "room") return WORKSPACE_NAME.test(name) ? t("channelNameReserved", { name }) : t("channelNameTaken", { name });
|
|
229
244
|
if (code === "invalid" && place === "room") return t("channelNameInvalid");
|
|
230
245
|
if (code === "conflict" && place === "member") return t("memberNameTaken", { name });
|
|
231
246
|
if (code === "invalid" && place === "member" && memberNameRefused(name)) return t("memberNameInvalid");
|
|
232
|
-
return t("actionFailed", { message });
|
|
247
|
+
return place === "send" ? t("sendFailed", { message }) : t("actionFailed", { message });
|
|
233
248
|
}
|
|
234
249
|
function json(method, body) {
|
|
235
250
|
return {
|
|
@@ -303,6 +318,8 @@ function createChannelSource(transport) {
|
|
|
303
318
|
/** Unconfirmed messages for every room; the snapshot exposes the selected room's. */
|
|
304
319
|
/** #197: how big one attachment may be, as the service reported it (state) or the collab default. */
|
|
305
320
|
let uploadLimit = DEFAULT_MAX_UPLOAD_BYTES;
|
|
321
|
+
/** #1445: how many characters one message may hold, as `/collab/api/state` reported it (`max_text_chars`). */
|
|
322
|
+
let textLimit = DEFAULT_MAX_TEXT_CHARS;
|
|
306
323
|
let outbox = [];
|
|
307
324
|
/**
|
|
308
325
|
* Page-back request in flight per room, tagged with the ticket and the epoch
|
|
@@ -562,6 +579,7 @@ function createChannelSource(transport) {
|
|
|
562
579
|
const state = await request("collab/api/state", { signal: read.controller.signal });
|
|
563
580
|
if (!current()) return;
|
|
564
581
|
if (Number.isSafeInteger(state.max_upload_bytes) && (state.max_upload_bytes ?? 0) > 0) uploadLimit = state.max_upload_bytes;
|
|
582
|
+
if (Number.isSafeInteger(state.max_text_chars) && (state.max_text_chars ?? 0) > 0) textLimit = state.max_text_chars;
|
|
565
583
|
const remembered = previous.roomId === void 0 ? rememberedRoom() : void 0;
|
|
566
584
|
const roomId = state.rooms.some((r) => r.id === previous.roomId) ? previous.roomId : state.rooms.some((r) => r.id === remembered) ? remembered : state.rooms[0]?.id;
|
|
567
585
|
const rows = [];
|
|
@@ -631,6 +649,7 @@ function createChannelSource(transport) {
|
|
|
631
649
|
leadId: state.lead_id ?? null,
|
|
632
650
|
effectiveLeadId: state.effective_lead_id ?? state.lead_id ?? null,
|
|
633
651
|
maxUploadBytes: uploadLimit,
|
|
652
|
+
maxTextChars: textLimit,
|
|
634
653
|
tasks: keepBoard ? previous.tasks : [],
|
|
635
654
|
taskChat: keepBoard ? previous.taskChat : void 0,
|
|
636
655
|
outbox: outbox.filter((entry) => entry.roomId === roomId)
|
|
@@ -22329,6 +22348,10 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
|
|
|
22329
22348
|
}));
|
|
22330
22349
|
};
|
|
22331
22350
|
const limit = state.maxUploadBytes ?? 1073741824;
|
|
22351
|
+
/** #1445: the character cap the store enforces, read back from /state (MAX_TEXT there). */
|
|
22352
|
+
const textLimit = state.maxTextChars ?? 131072;
|
|
22353
|
+
/** #1445: a failure worded in the panel's language, quoting the caps this panel knows about. */
|
|
22354
|
+
const failure = (error, place = "other", typed = "") => errorText(error, t, place, typed, { textChars: textLimit });
|
|
22332
22355
|
const attach = useComposerAttachments((picked) => {
|
|
22333
22356
|
const roomId = state.roomId;
|
|
22334
22357
|
if (roomId === void 0) return;
|
|
@@ -23045,7 +23068,7 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
|
|
|
23045
23068
|
}, (error) => {
|
|
23046
23069
|
setErrors((previous) => ({
|
|
23047
23070
|
...previous,
|
|
23048
|
-
[roomId]:
|
|
23071
|
+
[roomId]: failure(error, "send")
|
|
23049
23072
|
}));
|
|
23050
23073
|
return false;
|
|
23051
23074
|
});
|
|
@@ -23124,6 +23147,14 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
|
|
|
23124
23147
|
}));
|
|
23125
23148
|
return;
|
|
23126
23149
|
}
|
|
23150
|
+
const maxTextChars = state.maxTextChars ?? 131072;
|
|
23151
|
+
if (textTooLarge(text, maxTextChars)) {
|
|
23152
|
+
setErrors((previous) => ({
|
|
23153
|
+
...previous,
|
|
23154
|
+
[roomId]: t("messageTooLarge", { limit: t("charCount", { count: maxTextChars }) })
|
|
23155
|
+
}));
|
|
23156
|
+
return;
|
|
23157
|
+
}
|
|
23127
23158
|
if (messageTooLarge(text, [])) {
|
|
23128
23159
|
setErrors((previous) => ({
|
|
23129
23160
|
...previous,
|
|
@@ -23175,7 +23206,7 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
|
|
|
23175
23206
|
}).catch((error) => {
|
|
23176
23207
|
setErrors((previous) => ({
|
|
23177
23208
|
...previous,
|
|
23178
|
-
[roomId]:
|
|
23209
|
+
[roomId]: failure(error, "send")
|
|
23179
23210
|
}));
|
|
23180
23211
|
});
|
|
23181
23212
|
};
|
|
@@ -24556,6 +24587,8 @@ const zh = {
|
|
|
24556
24587
|
removeAttachment: "移除附件",
|
|
24557
24588
|
attachmentsTooLarge: "附件太大:一条消息合计不能超过 {limit}",
|
|
24558
24589
|
messageTooLarge: "消息太长:一条消息不能超过 {limit}",
|
|
24590
|
+
/** #1445: the text cap counts characters, so the number reads with its unit in the panel language. */
|
|
24591
|
+
charCount: "{count} 字符",
|
|
24559
24592
|
attachmentTooLarge: "「{name}」{size},超过单个文件上限 {limit}",
|
|
24560
24593
|
attachmentUploading: "上传中",
|
|
24561
24594
|
attachmentQueued: "等待上传",
|
|
@@ -24924,6 +24957,7 @@ const en = {
|
|
|
24924
24957
|
removeAttachment: "Remove attachment",
|
|
24925
24958
|
attachmentsTooLarge: "Attachments too large: one message can carry at most {limit} in total",
|
|
24926
24959
|
messageTooLarge: "Message too long: one message can hold at most {limit}",
|
|
24960
|
+
charCount: "{count} characters",
|
|
24927
24961
|
attachmentTooLarge: "\"{name}\" is {size}, over the per-file limit of {limit}",
|
|
24928
24962
|
attachmentUploading: "Uploading",
|
|
24929
24963
|
attachmentQueued: "Waiting to upload",
|
|
@@ -25015,6 +25049,71 @@ const en = {
|
|
|
25015
25049
|
replyDeleted: "Original message was deleted",
|
|
25016
25050
|
cancelReply: "Cancel reply"
|
|
25017
25051
|
};
|
|
25052
|
+
// src/client/panel-hash.ts
|
|
25053
|
+
/** Shape the fragment may take: one sidebar key (`plugins`, `schedules`, `collab-desk`, …). */
|
|
25054
|
+
const PANEL_ID$1 = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/;
|
|
25055
|
+
/** The main-panel id a fragment names; `null` for an empty or unrecognised one. */
|
|
25056
|
+
function panelIdFromHash(hash) {
|
|
25057
|
+
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
25058
|
+
return PANEL_ID$1.test(raw) ? raw : null;
|
|
25059
|
+
}
|
|
25060
|
+
/** The address of the current page with `id` as its fragment: query (the wizard token) untouched. */
|
|
25061
|
+
function addressFor(id) {
|
|
25062
|
+
return `${location.pathname}${location.search}${id === null ? "" : `#${id}`}`;
|
|
25063
|
+
}
|
|
25064
|
+
/**
|
|
25065
|
+
* Mirror every later panel selection into the fragment; the returned function stops following.
|
|
25066
|
+
* Opening a panel writes `#<id>`, going back to the conversation clears it.
|
|
25067
|
+
* @param layout - `ctx.layout` (panel selection and its shared source).
|
|
25068
|
+
* @returns unsubscribe.
|
|
25069
|
+
*/
|
|
25070
|
+
function followPanelHash(layout) {
|
|
25071
|
+
const source = layout.panelInfo;
|
|
25072
|
+
let last = source.getSnapshot().activePanelId;
|
|
25073
|
+
return source.subscribe(() => {
|
|
25074
|
+
const next = source.getSnapshot().activePanelId;
|
|
25075
|
+
if (next === last) return;
|
|
25076
|
+
last = next;
|
|
25077
|
+
if (addressFor(next) === `${location.pathname}${location.search}${location.hash}`) return;
|
|
25078
|
+
history.replaceState(history.state, "", addressFor(next));
|
|
25079
|
+
});
|
|
25080
|
+
}
|
|
25081
|
+
/** Whether a keyed `main` panel with this id is registered right now. */
|
|
25082
|
+
function hasMainPanel(slots, id) {
|
|
25083
|
+
return slots.entriesOfSlot("main").some((entry) => entry.options.key === id);
|
|
25084
|
+
}
|
|
25085
|
+
/**
|
|
25086
|
+
* Open the panel the fragment names, once its package has registered it; the returned function
|
|
25087
|
+
* stops waiting. Panels register when their own browser half loads, which can be after this
|
|
25088
|
+
* plugin: `layout.selectPanel` throws for a main key that is not registered yet (see
|
|
25089
|
+
* bothouse-dsh-panel/client.js), so this waits for the registration instead of firing once.
|
|
25090
|
+
* @param layout - `ctx.layout`.
|
|
25091
|
+
* @param slots - `ctx.slots` (main-slot registry and its change subscription).
|
|
25092
|
+
* @param hash - the fragment to honour; defaults to the current address.
|
|
25093
|
+
* @returns unsubscribe from the pending restore.
|
|
25094
|
+
*/
|
|
25095
|
+
function restorePanelFromHash(layout, slots, hash = location.hash) {
|
|
25096
|
+
const id = panelIdFromHash(hash);
|
|
25097
|
+
if (id === null) return () => {};
|
|
25098
|
+
if (hasMainPanel(slots, id)) {
|
|
25099
|
+
layout.selectPanel(id);
|
|
25100
|
+
return () => {};
|
|
25101
|
+
}
|
|
25102
|
+
if (layout.panelInfo.getSnapshot().activePanelId !== null) return () => {};
|
|
25103
|
+
let stopWaiting = () => {};
|
|
25104
|
+
let stopWatching = () => {};
|
|
25105
|
+
const done = () => {
|
|
25106
|
+
stopWaiting();
|
|
25107
|
+
stopWatching();
|
|
25108
|
+
};
|
|
25109
|
+
stopWaiting = slots.subscribe("main", () => {
|
|
25110
|
+
if (!hasMainPanel(slots, id)) return;
|
|
25111
|
+
done();
|
|
25112
|
+
if (layout.panelInfo.getSnapshot().activePanelId === null) layout.selectPanel(id);
|
|
25113
|
+
});
|
|
25114
|
+
stopWatching = layout.panelInfo.subscribe(done);
|
|
25115
|
+
return done;
|
|
25116
|
+
}
|
|
25018
25117
|
// src/client/index.ts
|
|
25019
25118
|
const PANEL_ID = "channels";
|
|
25020
25119
|
const REMOTE_PANEL_ID = "remote-channels";
|
|
@@ -25036,6 +25135,8 @@ function apply(ctx) {
|
|
|
25036
25135
|
zh,
|
|
25037
25136
|
en
|
|
25038
25137
|
}), "client-ui-discord: dictionaries");
|
|
25138
|
+
ctx.effect(() => followPanelHash(ctx.layout), "client-ui-discord: panel hash");
|
|
25139
|
+
ctx.effect(() => restorePanelFromHash(ctx.layout, ctx.slots), "client-ui-discord: panel hash restore");
|
|
25039
25140
|
const t = ctx.locale.bind(NS);
|
|
25040
25141
|
const channels = createChannelSource(browserTransport());
|
|
25041
25142
|
ctx.effect(() => channels.dispose, "client-ui-discord: channel stream");
|
|
@@ -25100,7 +25201,6 @@ function apply(ctx) {
|
|
|
25100
25201
|
};
|
|
25101
25202
|
bindPanel();
|
|
25102
25203
|
const unsubscribe = ctx.slots.subscribe("sidebar.settings", bindPanel);
|
|
25103
|
-
if (location.hash === `#${PANEL_ID}`) ctx.layout.selectPanel(PANEL_ID);
|
|
25104
25204
|
return () => {
|
|
25105
25205
|
unsubscribe();
|
|
25106
25206
|
disposePanel?.();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bothouse-dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.63",
|
|
4
4
|
"description": "BotHouse channels for DeepSeek Harness: Discord-style rooms where DSH models and local CLIs (Claude Code, Codex, Gemini) are members",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./collab/plugin.mjs",
|