@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.1
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 +48 -26
- package/ci/README.md +36 -9
- package/ci/openshare.js +547 -82
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +18 -15
- package/dist/config.d.ts +34 -15
- package/dist/config.js +127 -35
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +13 -1
- package/dist/core.js +491 -157
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +11 -8
- package/dist/settings.js +233 -91
- package/dist/share-flow.d.ts +89 -9
- package/dist/share-flow.js +172 -22
- package/dist/tui.js +449 -115
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +18 -15
- package/src/config.ts +129 -32
- package/src/contract.ts +5 -5
- package/src/core.ts +468 -158
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +225 -83
- package/src/share-flow.ts +180 -23
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
package/dist/settings.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Sections: Connection, Sharing, Read auth, Advanced, This session.
|
|
7
7
|
import { configSourceMap, moduleDir, writeCandidates } from "./config.js";
|
|
8
|
-
import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, serverEntries,
|
|
8
|
+
import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, fmtHours, parseTtlHours, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, patchShareOpts, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, meClient, serverEntries, setShareOpts, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
|
|
9
9
|
import { DIALOG_WIDTH_COLUMNS, kvHeightPercentOf, kvWidthOf, nextDialogWidth, UI_HEIGHT_PERCENT_KV, UI_WIDTH_KV, clampHeightPercent } from "./ui-prefs.js";
|
|
10
10
|
/** description suffix: `[from <source>]` when a key is overridden by a
|
|
11
11
|
* file/env higher in the chain than the write target. */
|
|
@@ -58,11 +58,11 @@ const LABELS = {
|
|
|
58
58
|
},
|
|
59
59
|
shareMode: {
|
|
60
60
|
label: "Share mode",
|
|
61
|
-
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
61
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
|
|
62
62
|
kind: "text",
|
|
63
63
|
options: ["auto", "all", "manual"],
|
|
64
64
|
section: "Sharing",
|
|
65
|
-
info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared
|
|
65
|
+
info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared - share those explicitly. manual: sessions stay private until you run /share.\n\nDefault (empty): TUI presence decides - installing the TUI part means manual, server-only means all.\n\nNOTE: the mode is resolved once at plugin load. Changing it here saves immediately but only takes effect after opencode restarts - until then new sessions keep following the OLD mode.',
|
|
66
66
|
def: "auto (TUI presence)",
|
|
67
67
|
},
|
|
68
68
|
debounceMs: {
|
|
@@ -89,22 +89,13 @@ const LABELS = {
|
|
|
89
89
|
info: 'How the model name appears on the share page. "$Provider/$Model" renders e.g. "closedrouter/zai/glm-5.3". Any literal text is used as-is; empty string hides the model line entirely.',
|
|
90
90
|
def: '"$Provider/$Model"',
|
|
91
91
|
},
|
|
92
|
-
|
|
93
|
-
label: "TTL (
|
|
94
|
-
help: "Share lifetime
|
|
92
|
+
ttlHours: {
|
|
93
|
+
label: "TTL (hours)",
|
|
94
|
+
help: "Share lifetime in hours (2d writes 48); capped server-side.",
|
|
95
95
|
kind: "number",
|
|
96
96
|
section: "Sharing",
|
|
97
|
-
info: "How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's
|
|
98
|
-
def: "worker cap (
|
|
99
|
-
},
|
|
100
|
-
openTasksInNewTab: {
|
|
101
|
-
label: "Open tasks in new tab",
|
|
102
|
-
help: "Viewer: task rows open subagent pages in a new tab.",
|
|
103
|
-
kind: "text",
|
|
104
|
-
options: ["true", "false"],
|
|
105
|
-
section: "Sharing",
|
|
106
|
-
info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
|
|
107
|
-
def: "true",
|
|
97
|
+
info: "How long pages live before the server's sweeper deletes them, in HOURS. Capped server-side: the deployment's MAX_TTL_HOURS (legacy MAX_TTL_DAYS converted) and the token's limit both apply; the floor is one hour.\n\nA per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.",
|
|
98
|
+
def: "worker cap (2160h = 90d on the reference deployment)",
|
|
108
99
|
},
|
|
109
100
|
authLogin: {
|
|
110
101
|
label: "Read auth: login",
|
|
@@ -165,10 +156,8 @@ function helpForKey(key, cfg, sources) {
|
|
|
165
156
|
function displayValue(key, cfg) {
|
|
166
157
|
if (key === "createToken" || key === "authPassword")
|
|
167
158
|
return mask(String(cfg[key] ?? ""));
|
|
168
|
-
if (key === "openTasksInNewTab")
|
|
169
|
-
return String(cfg.openTasksInNewTab !== false);
|
|
170
159
|
if (key === "announceStderr")
|
|
171
|
-
return
|
|
160
|
+
return cfg.announceStderr !== false ? "on" : "off";
|
|
172
161
|
const value = cfg[key];
|
|
173
162
|
return value === undefined || value === "" ? "(not set)" : String(value);
|
|
174
163
|
}
|
|
@@ -176,8 +165,8 @@ async function editKey(host, cfg, key) {
|
|
|
176
165
|
const meta = LABELS[key];
|
|
177
166
|
if (!meta)
|
|
178
167
|
return undefined;
|
|
179
|
-
// selection-backed settings
|
|
180
|
-
//
|
|
168
|
+
// selection-backed settings: fixed vocabularies become menus instead of
|
|
169
|
+
// asking for a typed value
|
|
181
170
|
if (meta.options) {
|
|
182
171
|
const current = String(cfg[key] ?? "");
|
|
183
172
|
const picked = await showSelect(host.ui, {
|
|
@@ -194,7 +183,7 @@ async function editKey(host, cfg, key) {
|
|
|
194
183
|
const next = { ...cfg };
|
|
195
184
|
if (key === "shareMode")
|
|
196
185
|
next[key] = picked === "auto" ? undefined : picked;
|
|
197
|
-
else if (key === "
|
|
186
|
+
else if (key === "announceStderr")
|
|
198
187
|
next[key] = picked === "true";
|
|
199
188
|
else
|
|
200
189
|
next[key] = picked;
|
|
@@ -225,10 +214,14 @@ async function editKey(host, cfg, key) {
|
|
|
225
214
|
}
|
|
226
215
|
return next;
|
|
227
216
|
}
|
|
228
|
-
/** Connect (add + enroll) a server
|
|
217
|
+
/** Connect (add + enroll) a server - the /share first-run flow and the
|
|
229
218
|
* settings "Connect to a server" row share it. Prompts the URL, probes
|
|
230
|
-
* the auth mode,
|
|
231
|
-
*
|
|
219
|
+
* the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
|
|
220
|
+
* (public servers only; anonymous shares sit under the server's global
|
|
221
|
+
* caps). Onboarding detects whether a code is needed and only then prompts
|
|
222
|
+
* for one (a code already in config is used without asking). The outcome is
|
|
223
|
+
* remembered per server: a token means onboarded, an anonymous flag means
|
|
224
|
+
* declined. */
|
|
232
225
|
export async function connectServerFlow(host) {
|
|
233
226
|
const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" });
|
|
234
227
|
if (raw === undefined || raw === "")
|
|
@@ -236,33 +229,84 @@ export async function connectServerFlow(host) {
|
|
|
236
229
|
const url = normServerUrl(raw);
|
|
237
230
|
if (!/^https?:\/\//.test(url))
|
|
238
231
|
return { ok: false, message: "URL must start with http:// or https://" };
|
|
239
|
-
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}
|
|
232
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
|
|
240
233
|
const probe = await probeAuthMode(url);
|
|
241
234
|
if (!probe.ok)
|
|
242
235
|
return { ok: false, message: `${url}: ${probe.detail}` };
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
236
|
+
const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
|
|
237
|
+
const deactivateOthers = () => {
|
|
238
|
+
for (const other of serverEntries()) {
|
|
239
|
+
if (other.url !== url)
|
|
240
|
+
saveServerEntry(other.url, { active: undefined });
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
if (probe.mode === "public") {
|
|
244
|
+
// the ask: an account is a real thing (own shares, own limits,
|
|
245
|
+
// revocable credentials) - but anonymous sharing works too
|
|
246
|
+
const picked = await showSelect(host.ui, {
|
|
247
|
+
title: `${url} - account?`,
|
|
248
|
+
options: [
|
|
249
|
+
{
|
|
250
|
+
value: "onboard",
|
|
251
|
+
title: "Onboard (get an account)",
|
|
252
|
+
description: "your own user, tokens and share limits; survives the server's anonymous caps",
|
|
253
|
+
help: "An account gives you a revocable identity with your own share registry and limits. Depending on the server you may need a one-time access code from the hoster - you'll be asked only if it's required.",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
value: "anon",
|
|
257
|
+
title: "Stay anonymous",
|
|
258
|
+
description: "share without an account; counts against the server-wide anonymous limits",
|
|
259
|
+
help: "No identity, no token to manage. Shares are ownerless: whoever holds the share's secret controls them, and the server may throttle anonymous creates globally. You can onboard later from Servers -> the server -> Enroll now.",
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
if (picked === "anon") {
|
|
264
|
+
saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true });
|
|
265
|
+
return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
|
|
266
|
+
}
|
|
267
|
+
if (picked === undefined || picked === null)
|
|
268
|
+
return { ok: false, message: "Cancelled." };
|
|
269
|
+
// onboard: try without a code (codeless servers), fall through to
|
|
270
|
+
// the code prompt exactly when the server demands one
|
|
271
|
+
let code = existing.code ?? "";
|
|
272
|
+
if (!code) {
|
|
273
|
+
const free = await enrollAtServer(url, "");
|
|
274
|
+
if (free.ok) {
|
|
275
|
+
deactivateOthers();
|
|
276
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
|
|
277
|
+
return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
|
|
278
|
+
}
|
|
279
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
280
|
+
if (typed === undefined)
|
|
281
|
+
return { ok: false, message: "Cancelled - this server needs a code to onboard." };
|
|
282
|
+
code = typed.trim();
|
|
283
|
+
}
|
|
284
|
+
const res = await enrollAtServer(url, code);
|
|
285
|
+
if (!res.ok)
|
|
286
|
+
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
287
|
+
deactivateOthers();
|
|
288
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
|
|
289
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
290
|
+
}
|
|
291
|
+
// private server: a code is required, full stop
|
|
292
|
+
let code = existing.code ?? "";
|
|
293
|
+
if (!code) {
|
|
294
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
246
295
|
if (typed === undefined)
|
|
247
|
-
return { ok: false, message: "Cancelled
|
|
296
|
+
return { ok: false, message: "Cancelled - private servers need a code." };
|
|
248
297
|
code = typed.trim();
|
|
249
298
|
}
|
|
250
299
|
const res = await enrollAtServer(url, code);
|
|
251
300
|
if (!res.ok)
|
|
252
301
|
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
253
|
-
|
|
254
|
-
// then persist the new membership as active
|
|
255
|
-
for (const other of serverEntries()) {
|
|
256
|
-
if (other.url !== url)
|
|
257
|
-
saveServerEntry(other.url, { active: undefined });
|
|
258
|
-
}
|
|
302
|
+
deactivateOthers();
|
|
259
303
|
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
|
|
260
|
-
return { ok: true, message: `Connected to ${url} as "${res.label}"
|
|
304
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
261
305
|
}
|
|
262
306
|
/** Per-server submenu: identity status + membership actions. Root-token
|
|
263
|
-
* holders get "Get a user token" (admin mints their own user identity
|
|
264
|
-
* root stays as adminToken
|
|
265
|
-
* exclusive
|
|
307
|
+
* holders get "Get a user token" (the admin mints their own user identity;
|
|
308
|
+
* root stays as adminToken, since being admin and being a user are not
|
|
309
|
+
* mutually exclusive). */
|
|
266
310
|
async function serverMenu(host, url) {
|
|
267
311
|
while (true) {
|
|
268
312
|
const entries = serverEntries();
|
|
@@ -276,7 +320,9 @@ async function serverMenu(host, url) {
|
|
|
276
320
|
? `token ${mask(mine.entry.token)}`
|
|
277
321
|
: mine.entry.code
|
|
278
322
|
? "no token (code saved, enrolls on demand)"
|
|
279
|
-
:
|
|
323
|
+
: mine.entry.anonymous
|
|
324
|
+
? "anonymous (declined onboarding)"
|
|
325
|
+
: "no token (public server, shares anonymously)";
|
|
280
326
|
const options = [
|
|
281
327
|
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
|
|
282
328
|
];
|
|
@@ -294,6 +340,8 @@ async function serverMenu(host, url) {
|
|
|
294
340
|
options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
|
|
295
341
|
if (mine.entry.token && !holdsRoot)
|
|
296
342
|
options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
|
|
343
|
+
if (mine.entry.token && !holdsRoot)
|
|
344
|
+
options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
|
|
297
345
|
if (mine.entry.token && !holdsRoot)
|
|
298
346
|
options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
|
|
299
347
|
options.push({
|
|
@@ -309,12 +357,16 @@ async function serverMenu(host, url) {
|
|
|
309
357
|
if (choice === undefined || choice === "__back")
|
|
310
358
|
return;
|
|
311
359
|
if (choice === "active") {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
360
|
+
// make-primary WITHOUT collapsing fan-out: other explicit actives
|
|
361
|
+
// stay active (the engine targets every active entry); silently
|
|
362
|
+
// deactivating them destroyed hand-built multi-server setups
|
|
316
363
|
saveServerEntry(url, { active: true });
|
|
317
|
-
|
|
364
|
+
const stillActive = serverEntries().filter((s) => s.active);
|
|
365
|
+
host.ui.toast({
|
|
366
|
+
variant: "success",
|
|
367
|
+
title: "OpenShare",
|
|
368
|
+
message: stillActive.length > 1 ? `${url} is primary - ${stillActive.length} servers stay ACTIVE (fan-out): new sessions mirror to all of them.` : `${url} is now the active server.`,
|
|
369
|
+
});
|
|
318
370
|
return;
|
|
319
371
|
}
|
|
320
372
|
if (choice === "adopt") {
|
|
@@ -349,8 +401,20 @@ async function serverMenu(host, url) {
|
|
|
349
401
|
continue;
|
|
350
402
|
}
|
|
351
403
|
let code = mine.entry.code ?? "";
|
|
352
|
-
if (
|
|
353
|
-
|
|
404
|
+
if (!code) {
|
|
405
|
+
// codeless first: only prompt when the server actually demands
|
|
406
|
+
// a code (public + codeless-on boards silently, like connect)
|
|
407
|
+
const free = await enrollAtServer(url, "");
|
|
408
|
+
if (free.ok) {
|
|
409
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
|
|
410
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (!/code required/i.test(free.error ?? "")) {
|
|
414
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
354
418
|
if (typed === undefined)
|
|
355
419
|
continue;
|
|
356
420
|
code = typed.trim();
|
|
@@ -359,7 +423,7 @@ async function serverMenu(host, url) {
|
|
|
359
423
|
}
|
|
360
424
|
const res = await enrollAtServer(url, code);
|
|
361
425
|
if (res.ok) {
|
|
362
|
-
saveServerEntry(url, { token: res.token, label: res.label });
|
|
426
|
+
saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
|
|
363
427
|
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
|
|
364
428
|
}
|
|
365
429
|
else {
|
|
@@ -367,6 +431,32 @@ async function serverMenu(host, url) {
|
|
|
367
431
|
}
|
|
368
432
|
continue;
|
|
369
433
|
}
|
|
434
|
+
if (choice === "account") {
|
|
435
|
+
const me = meClient(url, mine.entry.token ?? "");
|
|
436
|
+
const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
|
|
437
|
+
if (!meRes.ok) {
|
|
438
|
+
await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.\nIf the token was revoked server-side, use Enroll now instead.` });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const d = meRes.data;
|
|
442
|
+
const lim = d.token?.limits ?? {};
|
|
443
|
+
const fmt = (v) => (v ? String(v) : "unlimited");
|
|
444
|
+
const lines = [
|
|
445
|
+
`User: ${d.label} (${d.userId})`,
|
|
446
|
+
`Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
|
|
447
|
+
`Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
|
|
448
|
+
];
|
|
449
|
+
if (sharesRes.ok) {
|
|
450
|
+
const shares = sharesRes.data?.shares ?? [];
|
|
451
|
+
lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
|
|
452
|
+
for (const s of shares.slice(0, 10))
|
|
453
|
+
lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
|
|
454
|
+
if (shares.length > 10)
|
|
455
|
+
lines.push(` ...and ${shares.length - 10} more`);
|
|
456
|
+
}
|
|
457
|
+
await showInfo(host.ui, { title: "Your account", message: lines.join("\n") });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
370
460
|
if (choice === "rotate") {
|
|
371
461
|
const res = await rotateAtServer(url, mine.entry.token ?? "");
|
|
372
462
|
if (res.ok && res.token) {
|
|
@@ -379,7 +469,12 @@ async function serverMenu(host, url) {
|
|
|
379
469
|
continue;
|
|
380
470
|
}
|
|
381
471
|
if (choice === "disconnect") {
|
|
382
|
-
const sure = await showConfirm(host.ui, {
|
|
472
|
+
const sure = await showConfirm(host.ui, {
|
|
473
|
+
title: "Disconnect",
|
|
474
|
+
message: `Revoke the token at ${url} and forget it?\nRevoking your last token freezes control of ALL your shares there (even with the share secrets) - pages stay readable until their TTL; only the server admin can restore control.`,
|
|
475
|
+
confirmLabel: "Disconnect",
|
|
476
|
+
danger: true,
|
|
477
|
+
});
|
|
383
478
|
if (!sure)
|
|
384
479
|
continue;
|
|
385
480
|
const res = await disconnectAtServer(url, mine.entry.token ?? "");
|
|
@@ -543,9 +638,9 @@ async function withBusy(host, title, work) {
|
|
|
543
638
|
catch { }
|
|
544
639
|
doneBusy?.();
|
|
545
640
|
}
|
|
546
|
-
/** Server info, the readable version (
|
|
547
|
-
*
|
|
548
|
-
*
|
|
641
|
+
/** Server info, the readable version (the raw JSON policy line escapes
|
|
642
|
+
* the dialog border): formatted policy lines, auth explained in words,
|
|
643
|
+
* YOUR standing on this server, and follow-up actions. */
|
|
549
644
|
async function serverInfoScreen(host, url, client) {
|
|
550
645
|
const h = await client.health();
|
|
551
646
|
if (!h.ok) {
|
|
@@ -809,16 +904,15 @@ async function adminSharesMenu(host, client, url, shares, users) {
|
|
|
809
904
|
}
|
|
810
905
|
}
|
|
811
906
|
/** The settings dialog: a compact ROOT of group entries (with live
|
|
812
|
-
* summaries) opening focused submenus. Two-level by design
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
|
|
816
|
-
export async function settingsDialog(host, startAtSession) {
|
|
907
|
+
* summaries) opening focused submenus. Two-level by design: one flat ~25-row
|
|
908
|
+
* dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
|
|
909
|
+
* submenu Back just returns. */
|
|
910
|
+
export async function settingsDialog(host, startAtSession, startGroup) {
|
|
817
911
|
let cfg = currentConfig();
|
|
818
912
|
let dirty = false;
|
|
819
|
-
// Write target
|
|
820
|
-
//
|
|
821
|
-
//
|
|
913
|
+
// Write target: global settings home by default; any other existing
|
|
914
|
+
// file in the read chain may be chosen explicitly. The TUI never
|
|
915
|
+
// creates deploy-time override files.
|
|
822
916
|
const candidates = writeCandidates(moduleDir());
|
|
823
917
|
let writeTarget = candidates[0];
|
|
824
918
|
const sources = configSourceMap(moduleDir());
|
|
@@ -967,17 +1061,25 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
967
1061
|
}
|
|
968
1062
|
}
|
|
969
1063
|
const binding = bindings[0];
|
|
1064
|
+
if (binding?.lastError || binding?.ttlClamped) {
|
|
1065
|
+
const bits = [];
|
|
1066
|
+
if (binding?.ttlClamped)
|
|
1067
|
+
bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
|
|
1068
|
+
if (binding?.lastError)
|
|
1069
|
+
bits.push(`last share error: ${binding.lastError}`);
|
|
1070
|
+
rows.push({ title: "Share status notes", value: "__notes", description: bits[0].slice(0, 60), disabled: true, help: `Engine-recorded notes for this session's share(s):\n\n${bits.join("\n")}\n\nThe clamp note appears when the server or your token's policy reduced a requested TTL. The error note is the engine's last create/attach refusal (quota, freeze, rate limit) - it clears on the next successful sync.` });
|
|
1071
|
+
}
|
|
970
1072
|
rows.push({
|
|
971
1073
|
title: "Session TTL override (days)",
|
|
972
1074
|
value: "session:ttl",
|
|
973
|
-
description: binding?.opts?.
|
|
974
|
-
help: `Per-session share lifetime. Overrides the config
|
|
1075
|
+
description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
|
|
1076
|
+
help: `Per-session share lifetime in HOURS (prompt accepts 6h / 2d / 48). Overrides the config TTL for THIS session's pages; still capped server-side. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none — config default)"}`,
|
|
975
1077
|
});
|
|
976
1078
|
rows.push({
|
|
977
1079
|
title: "Session read auth",
|
|
978
1080
|
value: "session:auth",
|
|
979
1081
|
description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
|
|
980
|
-
help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth.
|
|
1082
|
+
help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth; a login and password are both required (or neither). On a live share it applies immediately; otherwise when you share. Removing the login removes the gate.\n\nCurrent: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none — config default applies)"}`,
|
|
981
1083
|
});
|
|
982
1084
|
return rows;
|
|
983
1085
|
};
|
|
@@ -1161,11 +1263,11 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1161
1263
|
});
|
|
1162
1264
|
if (!pick || pick === "__cancel")
|
|
1163
1265
|
return;
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
saveServerEntry(s.url, { active: undefined });
|
|
1266
|
+
// fan-out add: activate the picked server WITHOUT deactivating the
|
|
1267
|
+
// current ones - this session (and new sessions) mirror everywhere
|
|
1167
1268
|
saveServerEntry(pick, { active: true });
|
|
1168
|
-
|
|
1269
|
+
const nActive = serverEntries().filter((s) => s.active).length;
|
|
1270
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
|
|
1169
1271
|
return;
|
|
1170
1272
|
}
|
|
1171
1273
|
if (choice === "session:unshare") {
|
|
@@ -1180,48 +1282,79 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1180
1282
|
if (how === undefined || how === "__cancel")
|
|
1181
1283
|
return;
|
|
1182
1284
|
if (how === "leave") {
|
|
1183
|
-
const
|
|
1285
|
+
const allB = await readBindings(host.client, root);
|
|
1184
1286
|
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
1185
|
-
for (const b of
|
|
1287
|
+
for (const b of allB) {
|
|
1186
1288
|
if (!b.server)
|
|
1187
1289
|
continue;
|
|
1188
|
-
|
|
1189
|
-
|
|
1290
|
+
// keep id/secret on the opt-out marker: the snapshot stays
|
|
1291
|
+
// deletable later, and re-sharing reuses the binding instead
|
|
1292
|
+
// of stalling on a secret-less marker that can never unshare
|
|
1293
|
+
// or re-share again.
|
|
1294
|
+
servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
|
|
1190
1295
|
}
|
|
1191
1296
|
});
|
|
1192
|
-
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." });
|
|
1297
|
+
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs (delete stays available here)." : "Could not update the binding." });
|
|
1193
1298
|
return;
|
|
1194
1299
|
}
|
|
1300
|
+
const sureAll = await showConfirm(host.ui, {
|
|
1301
|
+
title: "Delete every page?",
|
|
1302
|
+
message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
|
|
1303
|
+
confirmLabel: "Delete every page",
|
|
1304
|
+
danger: true,
|
|
1305
|
+
});
|
|
1306
|
+
if (!sureAll)
|
|
1307
|
+
return;
|
|
1195
1308
|
const result = await runUnshareFlow(host, startAtSession);
|
|
1196
1309
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
|
|
1197
1310
|
return;
|
|
1198
1311
|
}
|
|
1199
1312
|
if (choice === "session:ttl") {
|
|
1200
|
-
const raw = await showPrompt(host.ui, { title: "Session TTL
|
|
1313
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
|
|
1201
1314
|
if (raw === undefined)
|
|
1202
1315
|
return;
|
|
1203
1316
|
const opts = { ...(current?.opts ?? {}) };
|
|
1204
1317
|
if (raw === "")
|
|
1205
|
-
delete opts.
|
|
1318
|
+
delete opts.ttlHours;
|
|
1206
1319
|
else {
|
|
1207
|
-
const
|
|
1208
|
-
if (
|
|
1209
|
-
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of
|
|
1320
|
+
const hours = parseTtlHours(raw);
|
|
1321
|
+
if (hours === undefined) {
|
|
1322
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
|
|
1210
1323
|
return;
|
|
1211
1324
|
}
|
|
1212
|
-
opts.
|
|
1325
|
+
opts.ttlHours = hours;
|
|
1213
1326
|
}
|
|
1214
|
-
await
|
|
1215
|
-
|
|
1327
|
+
await setShareOpts(host.client, root, opts);
|
|
1328
|
+
const liveNow = live.length > 0;
|
|
1329
|
+
if (liveNow) {
|
|
1330
|
+
// REST push (#13): immediate on every server, socket or not
|
|
1331
|
+
const results = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, opts.ttlHours !== undefined ? { ttlHours: opts.ttlHours } : {}, b.secret) : Promise.resolve({ ok: false })));
|
|
1332
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
1333
|
+
if (failed)
|
|
1334
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
|
|
1335
|
+
}
|
|
1336
|
+
host.ui.toast({
|
|
1337
|
+
variant: "success",
|
|
1338
|
+
title: "OpenShare",
|
|
1339
|
+
message: liveNow
|
|
1340
|
+
? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
|
|
1341
|
+
: `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
|
|
1342
|
+
});
|
|
1216
1343
|
return;
|
|
1217
1344
|
}
|
|
1218
1345
|
if (choice === "session:auth") {
|
|
1219
|
-
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty =
|
|
1346
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
|
|
1220
1347
|
if (login === undefined)
|
|
1221
1348
|
return;
|
|
1222
|
-
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "
|
|
1349
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
|
|
1223
1350
|
if (password === undefined)
|
|
1224
1351
|
return;
|
|
1352
|
+
// both-or-none: the server builds a gate only from a full pair, and
|
|
1353
|
+
// rendering half a pair would show protection that does not exist
|
|
1354
|
+
if ((login === "") !== (password === "")) {
|
|
1355
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1225
1358
|
const opts = { ...(current?.opts ?? {}) };
|
|
1226
1359
|
if (login === "") {
|
|
1227
1360
|
delete opts.authLogin;
|
|
@@ -1229,13 +1362,18 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1229
1362
|
}
|
|
1230
1363
|
else {
|
|
1231
1364
|
opts.authLogin = login;
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1365
|
+
opts.authPassword = password;
|
|
1366
|
+
}
|
|
1367
|
+
await setShareOpts(host.client, root, opts);
|
|
1368
|
+
const liveNow2 = live.length > 0;
|
|
1369
|
+
if (liveNow2) {
|
|
1370
|
+
const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
|
|
1371
|
+
const results2 = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, pair, b.secret) : Promise.resolve({ ok: false })));
|
|
1372
|
+
const failed2 = results2.filter((r) => !r.ok).length;
|
|
1373
|
+
if (failed2)
|
|
1374
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `Read auth applied on ${results2.length - failed2}/${results2.length} server(s) - the rest sync on next connect.` });
|
|
1236
1375
|
}
|
|
1237
|
-
|
|
1238
|
-
host.ui.toast({ variant: "success", title: "OpenShare", message: "Session read auth saved (applies at the next attach/create)." });
|
|
1376
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: liveNow2 ? `Read auth ${login === "" ? "removed" : "saved"} - applied now.` : `Read auth ${login === "" ? "cleared" : "saved"} - applies when you share.` });
|
|
1239
1377
|
return;
|
|
1240
1378
|
}
|
|
1241
1379
|
}
|
|
@@ -1254,6 +1392,10 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1254
1392
|
}
|
|
1255
1393
|
};
|
|
1256
1394
|
// ---- root: compact group list with live summaries --------------------------
|
|
1395
|
+
// /share on a shared session lands HERE, straight in the session menu -
|
|
1396
|
+
// one selection sooner (Back still reaches the full root)
|
|
1397
|
+
if (startAtSession && startGroup === "session")
|
|
1398
|
+
await groupMenu("session");
|
|
1257
1399
|
const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
|
|
1258
1400
|
while (true) {
|
|
1259
1401
|
const servers = serverEntries(cfg);
|
|
@@ -1280,7 +1422,7 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1280
1422
|
});
|
|
1281
1423
|
groups.push({
|
|
1282
1424
|
id: "sharing",
|
|
1283
|
-
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.
|
|
1425
|
+
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
|
|
1284
1426
|
help: `What and how sessions stream: share mode (all/manual), sync cadence, the completed-delay, the model label on pages, share TTL, and the viewer's task-tab behavior.\n\nCadence values are clamped by the server's policy window.`,
|
|
1285
1427
|
});
|
|
1286
1428
|
groups.push({
|