@mirrowel/opencode-openshare 0.2.1 → 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 +46 -26
- package/ci/README.md +30 -14
- package/ci/openshare.js +440 -61
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +8 -8
- package/dist/config.d.ts +34 -14
- package/dist/config.js +108 -33
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +12 -1
- package/dist/core.js +359 -138
- 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 +10 -7
- package/dist/settings.js +136 -54
- package/dist/share-flow.d.ts +30 -6
- package/dist/share-flow.js +73 -18
- package/dist/tui.js +251 -59
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/package.json +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +8 -8
- package/src/config.ts +112 -29
- package/src/contract.ts +5 -5
- package/src/core.ts +339 -137
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +132 -52
- package/src/share-flow.ts +76 -20
- 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/src/dialogs.tsx
CHANGED
|
@@ -163,11 +163,11 @@ function estimatedVisualRows(message: string, width: number) {
|
|
|
163
163
|
return message.split(/\r?\n/).reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / Math.max(1, width))), 0)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/** Deterministic character-level pre-wrap
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
166
|
+
/** Deterministic character-level pre-wrap: opentui's word wrap cannot
|
|
167
|
+
* break unbreakable tokens like a JSON policy blob, so "wrapped" text still
|
|
168
|
+
* escapes the dialog border. Both the [i] pane and InfoDialog render THESE
|
|
169
|
+
* lines; the row estimate uses the same output, so heights match what
|
|
170
|
+
* renders. */
|
|
171
171
|
export function preWrap(text: string, width: number): string[] {
|
|
172
172
|
const out: string[] = []
|
|
173
173
|
for (const raw of text.split(/\r?\n/)) {
|
|
@@ -229,10 +229,9 @@ function InfoLines(props: { api: TuiPluginApi; lines: string[] }): JSX.Element {
|
|
|
229
229
|
)
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* Capped + ellipsized. */
|
|
232
|
+
/** Title column cap (family menuTitleWidth): a server URL as a row title
|
|
233
|
+
* would otherwise stretch the column toward half the dialog. Full URLs
|
|
234
|
+
* belong in the [i] pane, not the row. Capped and ellipsized. */
|
|
236
235
|
function menuTitleWidth(width: DialogWidth, options: readonly { title: string }[]) {
|
|
237
236
|
const longest = Math.max(0, ...options.map((option) => option.title.length)) + 2
|
|
238
237
|
if (width === "xlarge") return Math.min(Math.max(20, longest), 28)
|
|
@@ -246,13 +245,12 @@ function ellipsize(text: string, width: number) {
|
|
|
246
245
|
|
|
247
246
|
/** OpenShare visual frame: rounded accent border + accent title row. The
|
|
248
247
|
* logic inside the menus stays the ported family pattern; this wrapper is
|
|
249
|
-
* purely presentation
|
|
250
|
-
*
|
|
251
|
-
// Frame note
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// identity kept.
|
|
248
|
+
* purely presentation. Natural width: the host dialog panel provides the
|
|
249
|
+
* container and the border hugs the content. */
|
|
250
|
+
// Frame note: the HOST dialog panel renders plugin content with
|
|
251
|
+
// paddingTop=1 (opencode dialog.tsx), so a BORDERED frame always shows a
|
|
252
|
+
// dead panel strip above its top border. Borderless family shape plus a
|
|
253
|
+
// solid accent rule under the title: zero dead space, accent identity kept.
|
|
256
254
|
function DialogFrame(props: { api: TuiPluginApi; title: string; children: JSX.Element }): JSX.Element {
|
|
257
255
|
const theme = () => props.api.theme.current
|
|
258
256
|
return (
|
|
@@ -323,8 +321,8 @@ export function MenuDialog<Value>(props: {
|
|
|
323
321
|
const dimensions = useTerminalDimensions()
|
|
324
322
|
const listHeight = createMemo(() => cappedHeight(props.options.length, maxRows(props.api, dimensions().height, 12, 4), 4))
|
|
325
323
|
// fixed title column shared by every row (family menuTitleWidth): flex
|
|
326
|
-
// columns stagger per
|
|
327
|
-
// width is what makes the value/description column align
|
|
324
|
+
// columns stagger per row because flex splits by content, so a computed
|
|
325
|
+
// width is what makes the value/description column align.
|
|
328
326
|
const width = dialogWidthOf(props.api)
|
|
329
327
|
const titleWidth = createMemo(() => menuTitleWidth(width, props.options))
|
|
330
328
|
const hasHelp = createMemo(() => props.options.some((option) => option.help))
|
|
@@ -352,8 +350,8 @@ export function MenuDialog<Value>(props: {
|
|
|
352
350
|
})
|
|
353
351
|
// [i] pane sections: "## Header" lines in help split sections; the pane
|
|
354
352
|
// height comes from the CONTENT (pre-wrapped rows), capped by the same
|
|
355
|
-
// dialog metrics
|
|
356
|
-
//
|
|
353
|
+
// dialog metrics rather than the spawning menu's list height (which gave
|
|
354
|
+
// a 3-row menu a 3-row info pane).
|
|
357
355
|
const helpSections = createMemo(() => {
|
|
358
356
|
const raw = current()?.help ?? ""
|
|
359
357
|
const sections: Array<{ title: string; lines: string[] }> = [{ title: "", lines: [] }]
|
|
@@ -602,11 +600,10 @@ function claimDialog(api: TuiPluginApi): () => void {
|
|
|
602
600
|
pendingDialog = release
|
|
603
601
|
return release
|
|
604
602
|
}
|
|
605
|
-
/** showMenuOnce, family discipline
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
* settings exit). */
|
|
603
|
+
/** showMenuOnce, family discipline: done() does NOT clear the host stack,
|
|
604
|
+
* because the next dialog's replace() is an atomic swap and clearing first
|
|
605
|
+
* paints one empty frame (the esc-to-parent flicker). Chain ends clear
|
|
606
|
+
* explicitly (command exits, settings exit). */
|
|
610
607
|
export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: MenuOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
|
|
611
608
|
return new Promise((resolve) => {
|
|
612
609
|
let settled = false
|
|
@@ -627,11 +624,11 @@ export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; o
|
|
|
627
624
|
}
|
|
628
625
|
|
|
629
626
|
// --- InfoDialog (config-studio port): a real message body ----------------------
|
|
630
|
-
// Content-derived height, pre-wrapped (char-level
|
|
627
|
+
// Content-derived height, pre-wrapped (char-level: word wrap cannot break
|
|
631
628
|
// JSON blobs), semantically styled lines, page/home/end keys, and an action
|
|
632
|
-
// footer with buttons + key shortcuts. Replaces the hint-as-message menus
|
|
633
|
-
//
|
|
634
|
-
// prompts mid-URL
|
|
629
|
+
// footer with buttons + key shortcuts. Replaces the hint-as-message menus for
|
|
630
|
+
// confirm/alert, whose single overflow-hidden line clipped destructive
|
|
631
|
+
// prompts mid-URL.
|
|
635
632
|
|
|
636
633
|
export type DialogAction = { title: string; value: string; key?: string; danger?: boolean }
|
|
637
634
|
|
|
@@ -772,9 +769,9 @@ export function showAlertMenu(api: TuiPluginApi, props: { title: string; message
|
|
|
772
769
|
|
|
773
770
|
// --- prompt (text entry — the one custom piece) -------------------------------
|
|
774
771
|
|
|
775
|
-
// Full printable ASCII
|
|
776
|
-
//
|
|
777
|
-
//
|
|
772
|
+
// Full printable ASCII: a lowercase-only set makes uppercase
|
|
773
|
+
// CREATE_TOKENs and codes UNTYPEABLE. Space is bound separately below;
|
|
774
|
+
// escape is the back binding, so it is excluded.
|
|
778
775
|
const PROMPT_CHARS = [
|
|
779
776
|
..."abcdefghijklmnopqrstuvwxyz",
|
|
780
777
|
..."ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-only redaction + light path transform (Wave 3 trust).
|
|
3
|
+
*
|
|
4
|
+
* Owner verdict: the server stays dumb - the client controls what leaves
|
|
5
|
+
* the machine. This pass runs once at enqueue (the single choke point
|
|
6
|
+
* every item crosses before any transport) on UNGATED shares only: a
|
|
7
|
+
* share with login+pass is private from casual observers already, and
|
|
8
|
+
* redacting there would corrupt content the server owner can see anyway
|
|
9
|
+
* (documented caveat: NOT private from the server owner until Wave 3b
|
|
10
|
+
* encryption lands).
|
|
11
|
+
*
|
|
12
|
+
* Extending: add a detector to BUILTINS (fork-friendly registry) or set
|
|
13
|
+
* `redact.patterns` in the config (regex source strings, global flag).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A detector: stable `kind` names the marker, e.g. `[redacted:aws-key]`. */
|
|
17
|
+
export type RedactRule = { kind: string; re: RegExp }
|
|
18
|
+
|
|
19
|
+
export const BUILTINS: RedactRule[] = [
|
|
20
|
+
{ kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
21
|
+
{ kind: "gh-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
|
|
22
|
+
{ kind: "gh-token", re: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g },
|
|
23
|
+
{ kind: "api-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
24
|
+
{ kind: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
25
|
+
{ kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
26
|
+
{ kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi },
|
|
27
|
+
// PEM private-key blocks (whole block)
|
|
28
|
+
{ kind: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
29
|
+
// .env style assignments for secret-shaped names
|
|
30
|
+
{ kind: "env-secret", re: /(?<![A-Za-z0-9_])(?:export\s+)?[A-Z0-9_]*(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?)[A-Z0-9_]*\s*=\s*\S+/g },
|
|
31
|
+
// URL connection strings with embedded passwords (password masked)
|
|
32
|
+
{ kind: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^:@/\s"']+:[^@/\s"']{4,}@/gi },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/** Config-supplied rules (regex source strings, applied globally). */
|
|
36
|
+
function compile(patterns: unknown): RedactRule[] {
|
|
37
|
+
if (!Array.isArray(patterns)) return []
|
|
38
|
+
const out: RedactRule[] = []
|
|
39
|
+
for (const p of patterns) {
|
|
40
|
+
if (typeof p !== "string" || !p) continue
|
|
41
|
+
try {
|
|
42
|
+
out.push({ kind: "user", re: new RegExp(p, "g") })
|
|
43
|
+
} catch {
|
|
44
|
+
// a broken user regex must never kill sharing - skip it
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RedactionHit = { kind: string; sample: string }
|
|
51
|
+
|
|
52
|
+
/** Home-directory prefix -> `~`; applied consistently to session.directory
|
|
53
|
+
* AND tool path fields so viewer-side relativization still works (both
|
|
54
|
+
* ends share the ~-prefixed form). */
|
|
55
|
+
export function tildePath(p: string, home: string): string {
|
|
56
|
+
if (!p || !home || p.length <= home.length) return p
|
|
57
|
+
const norm = (s: string) => s.replace(/[\\/]+/g, "/").replace(/\/$/, "")
|
|
58
|
+
const np = norm(p)
|
|
59
|
+
const nh = norm(home)
|
|
60
|
+
if (np === nh) return "~"
|
|
61
|
+
if (np.startsWith(nh + "/")) return "~" + np.slice(nh.length)
|
|
62
|
+
return p
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"])
|
|
66
|
+
|
|
67
|
+
type Opts = { rules: RedactRule[]; home: string; enabled: boolean }
|
|
68
|
+
type Stats = { hits: RedactionHit[]; changed: boolean }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply redaction (when enabled) + the light path transform to one
|
|
72
|
+
* contract item. Returns the ORIGINAL untouched when nothing matched
|
|
73
|
+
* (zero-copy fast path for clean sessions); otherwise a mutated deep
|
|
74
|
+
* clone - the host's live object is never touched.
|
|
75
|
+
*/
|
|
76
|
+
export function redactItem(item: unknown, opts: Opts): { item: unknown; hits: RedactionHit[] } {
|
|
77
|
+
const flat = JSON.stringify(item) ?? "null"
|
|
78
|
+
const needRedact = opts.enabled && opts.rules.some((r) => { r.re.lastIndex = 0; return r.re.test(flat) })
|
|
79
|
+
const needPaths = !!opts.home && flat.includes(opts.home)
|
|
80
|
+
if (!needRedact && !needPaths) return { item, hits: [] }
|
|
81
|
+
const clone = JSON.parse(flat) as unknown
|
|
82
|
+
const stats: Stats = { hits: [], changed: false }
|
|
83
|
+
walk(clone, opts, stats)
|
|
84
|
+
return { item: clone, hits: stats.hits }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function walk(node: unknown, opts: Opts, stats: Stats, key?: string): void {
|
|
88
|
+
if (Array.isArray(node)) {
|
|
89
|
+
for (let i = 0; i < node.length; i++) {
|
|
90
|
+
const v = node[i]
|
|
91
|
+
if (typeof v === "string") node[i] = applyString(v, opts, stats, key)
|
|
92
|
+
else walk(v, opts, stats, key)
|
|
93
|
+
}
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (node && typeof node === "object") {
|
|
97
|
+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
98
|
+
if (typeof v === "string") (node as Record<string, unknown>)[k] = applyString(v, opts, stats, k)
|
|
99
|
+
else walk(v, opts, stats, k)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applyString(v: string, opts: Opts, stats: Stats, key?: string): string {
|
|
105
|
+
let out = v
|
|
106
|
+
if (opts.enabled) {
|
|
107
|
+
for (const r of opts.rules) {
|
|
108
|
+
r.re.lastIndex = 0
|
|
109
|
+
out = out.replace(r.re, (full) => {
|
|
110
|
+
if (stats.hits.length < 8 && !stats.hits.some((h) => h.kind === r.kind)) {
|
|
111
|
+
stats.hits.push({ kind: r.kind, sample: full.slice(0, 60) })
|
|
112
|
+
}
|
|
113
|
+
stats.changed = true
|
|
114
|
+
if (r.kind === "conn-string") {
|
|
115
|
+
// keep scheme://user, mask the password, keep @host
|
|
116
|
+
const at = full.lastIndexOf("@")
|
|
117
|
+
const colon = full.indexOf(":", full.indexOf("://") + 3) + 1
|
|
118
|
+
if (at > colon && colon > 0) return full.slice(0, colon) + "[redacted:conn-string]" + full.slice(at)
|
|
119
|
+
}
|
|
120
|
+
return `[redacted:${r.kind}]`
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (key && PATH_KEYS.has(key) && opts.home && out.includes(opts.home)) {
|
|
125
|
+
out = tildePath(out, opts.home)
|
|
126
|
+
stats.changed = true
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** All active rules (built-ins + compiled user patterns). */
|
|
132
|
+
export function activeRules(userPatterns: unknown): RedactRule[] {
|
|
133
|
+
return [...BUILTINS, ...compile(userPatterns)]
|
|
134
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -15,12 +15,15 @@ import {
|
|
|
15
15
|
deleteServerEntry,
|
|
16
16
|
disconnectAtServer,
|
|
17
17
|
enrollAtServer,
|
|
18
|
+
fmtHours,
|
|
19
|
+
parseTtlHours,
|
|
18
20
|
hostOf,
|
|
19
21
|
isConfigured,
|
|
20
22
|
normServerUrl,
|
|
21
23
|
probeAuthMode,
|
|
22
24
|
probeWorker,
|
|
23
25
|
patchSessionBinding,
|
|
26
|
+
patchShareOpts,
|
|
24
27
|
readBinding,
|
|
25
28
|
readBindings,
|
|
26
29
|
resolveRoot,
|
|
@@ -146,13 +149,13 @@ const LABELS: Record<string, LabelMeta> = {
|
|
|
146
149
|
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.',
|
|
147
150
|
def: '"$Provider/$Model"',
|
|
148
151
|
},
|
|
149
|
-
|
|
150
|
-
label: "TTL (
|
|
151
|
-
help: "Share lifetime
|
|
152
|
+
ttlHours: {
|
|
153
|
+
label: "TTL (hours)",
|
|
154
|
+
help: "Share lifetime in hours (2d writes 48); capped server-side.",
|
|
152
155
|
kind: "number",
|
|
153
156
|
section: "Sharing",
|
|
154
|
-
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
|
|
155
|
-
def: "worker cap (
|
|
157
|
+
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.",
|
|
158
|
+
def: "worker cap (2160h = 90d on the reference deployment)",
|
|
156
159
|
},
|
|
157
160
|
authLogin: {
|
|
158
161
|
label: "Read auth: login",
|
|
@@ -219,8 +222,8 @@ function displayValue(key: string, cfg: ShareConfig): string {
|
|
|
219
222
|
async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promise<ShareConfig | undefined> {
|
|
220
223
|
const meta = LABELS[key]
|
|
221
224
|
if (!meta) return undefined
|
|
222
|
-
// selection-backed settings
|
|
223
|
-
//
|
|
225
|
+
// selection-backed settings: fixed vocabularies become menus instead of
|
|
226
|
+
// asking for a typed value
|
|
224
227
|
if (meta.options) {
|
|
225
228
|
const current = String((cfg as unknown as Record<string, unknown>)[key] ?? "")
|
|
226
229
|
const picked = await showSelect(host.ui, {
|
|
@@ -262,39 +265,91 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
|
|
|
262
265
|
return next as ShareConfig
|
|
263
266
|
}
|
|
264
267
|
|
|
265
|
-
/** Connect (add + enroll) a server
|
|
268
|
+
/** Connect (add + enroll) a server - the /share first-run flow and the
|
|
266
269
|
* settings "Connect to a server" row share it. Prompts the URL, probes
|
|
267
|
-
* the auth mode,
|
|
268
|
-
*
|
|
270
|
+
* the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
|
|
271
|
+
* (public servers only; anonymous shares sit under the server's global
|
|
272
|
+
* caps). Onboarding detects whether a code is needed and only then prompts
|
|
273
|
+
* for one (a code already in config is used without asking). The outcome is
|
|
274
|
+
* remembered per server: a token means onboarded, an anonymous flag means
|
|
275
|
+
* declined. */
|
|
269
276
|
export async function connectServerFlow(host: ShareHostApi): Promise<{ ok: boolean; message: string; url?: string }> {
|
|
270
277
|
const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" })
|
|
271
278
|
if (raw === undefined || raw === "") return { ok: false, message: "Cancelled." }
|
|
272
279
|
const url = normServerUrl(raw)
|
|
273
280
|
if (!/^https?:\/\//.test(url)) return { ok: false, message: "URL must start with http:// or https://" }
|
|
274
|
-
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}
|
|
281
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` })
|
|
275
282
|
const probe = await probeAuthMode(url)
|
|
276
283
|
if (!probe.ok) return { ok: false, message: `${url}: ${probe.detail}` }
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {}
|
|
285
|
+
const deactivateOthers = () => {
|
|
286
|
+
for (const other of serverEntries()) {
|
|
287
|
+
if (other.url !== url) saveServerEntry(other.url, { active: undefined })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (probe.mode === "public") {
|
|
291
|
+
// the ask: an account is a real thing (own shares, own limits,
|
|
292
|
+
// revocable credentials) - but anonymous sharing works too
|
|
293
|
+
const picked = await showSelect(host.ui, {
|
|
294
|
+
title: `${url} - account?`,
|
|
295
|
+
options: [
|
|
296
|
+
{
|
|
297
|
+
value: "onboard",
|
|
298
|
+
title: "Onboard (get an account)",
|
|
299
|
+
description: "your own user, tokens and share limits; survives the server's anonymous caps",
|
|
300
|
+
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.",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
value: "anon",
|
|
304
|
+
title: "Stay anonymous",
|
|
305
|
+
description: "share without an account; counts against the server-wide anonymous limits",
|
|
306
|
+
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.",
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
if (picked === "anon") {
|
|
311
|
+
saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true })
|
|
312
|
+
return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url }
|
|
313
|
+
}
|
|
314
|
+
if (picked === undefined || picked === null) return { ok: false, message: "Cancelled." }
|
|
315
|
+
// onboard: try without a code (codeless servers), fall through to
|
|
316
|
+
// the code prompt exactly when the server demands one
|
|
317
|
+
let code = existing.code ?? ""
|
|
318
|
+
if (!code) {
|
|
319
|
+
const free = await enrollAtServer(url, "")
|
|
320
|
+
if (free.ok) {
|
|
321
|
+
deactivateOthers()
|
|
322
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true })
|
|
323
|
+
return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url }
|
|
324
|
+
}
|
|
325
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
|
|
326
|
+
if (typed === undefined) return { ok: false, message: "Cancelled - this server needs a code to onboard." }
|
|
327
|
+
code = typed.trim()
|
|
328
|
+
}
|
|
329
|
+
const res = await enrollAtServer(url, code)
|
|
330
|
+
if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
|
|
331
|
+
deactivateOthers()
|
|
332
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true })
|
|
333
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
|
|
334
|
+
}
|
|
335
|
+
// private server: a code is required, full stop
|
|
336
|
+
let code = existing.code ?? ""
|
|
337
|
+
if (!code) {
|
|
338
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
|
|
339
|
+
if (typed === undefined) return { ok: false, message: "Cancelled - private servers need a code." }
|
|
281
340
|
code = typed.trim()
|
|
282
341
|
}
|
|
283
342
|
const res = await enrollAtServer(url, code)
|
|
284
343
|
if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
|
|
285
|
-
|
|
286
|
-
// then persist the new membership as active
|
|
287
|
-
for (const other of serverEntries()) {
|
|
288
|
-
if (other.url !== url) saveServerEntry(other.url, { active: undefined })
|
|
289
|
-
}
|
|
344
|
+
deactivateOthers()
|
|
290
345
|
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true })
|
|
291
|
-
return { ok: true, message: `Connected to ${url} as "${res.label}"
|
|
346
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
|
|
292
347
|
}
|
|
293
348
|
|
|
294
349
|
/** Per-server submenu: identity status + membership actions. Root-token
|
|
295
|
-
* holders get "Get a user token" (admin mints their own user identity
|
|
296
|
-
* root stays as adminToken
|
|
297
|
-
* exclusive
|
|
350
|
+
* holders get "Get a user token" (the admin mints their own user identity;
|
|
351
|
+
* root stays as adminToken, since being admin and being a user are not
|
|
352
|
+
* mutually exclusive). */
|
|
298
353
|
async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
299
354
|
while (true) {
|
|
300
355
|
const entries = serverEntries()
|
|
@@ -307,7 +362,9 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
307
362
|
? `token ${mask(mine.entry.token)}`
|
|
308
363
|
: mine.entry.code
|
|
309
364
|
? "no token (code saved, enrolls on demand)"
|
|
310
|
-
:
|
|
365
|
+
: mine.entry.anonymous
|
|
366
|
+
? "anonymous (declined onboarding)"
|
|
367
|
+
: "no token (public server, shares anonymously)"
|
|
311
368
|
const options: ShareDialogSelectOption<string>[] = [
|
|
312
369
|
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
|
|
313
370
|
]
|
|
@@ -381,15 +438,27 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
381
438
|
continue
|
|
382
439
|
}
|
|
383
440
|
let code = mine.entry.code ?? ""
|
|
384
|
-
if (
|
|
385
|
-
|
|
441
|
+
if (!code) {
|
|
442
|
+
// codeless first: only prompt when the server actually demands
|
|
443
|
+
// a code (public + codeless-on boards silently, like connect)
|
|
444
|
+
const free = await enrollAtServer(url, "")
|
|
445
|
+
if (free.ok) {
|
|
446
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined })
|
|
447
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` })
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
if (!/code required/i.test(free.error ?? "")) {
|
|
451
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" })
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
|
|
386
455
|
if (typed === undefined) continue
|
|
387
456
|
code = typed.trim()
|
|
388
457
|
if (code) saveServerEntry(url, { code })
|
|
389
458
|
}
|
|
390
459
|
const res = await enrollAtServer(url, code)
|
|
391
460
|
if (res.ok) {
|
|
392
|
-
saveServerEntry(url, { token: res.token, label: res.label })
|
|
461
|
+
saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined })
|
|
393
462
|
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` })
|
|
394
463
|
} else {
|
|
395
464
|
await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" })
|
|
@@ -599,9 +668,9 @@ async function withBusy(host: ShareHostApi, title: string, work: Promise<void> |
|
|
|
599
668
|
doneBusy?.()
|
|
600
669
|
}
|
|
601
670
|
|
|
602
|
-
/** Server info, the readable version (
|
|
603
|
-
*
|
|
604
|
-
*
|
|
671
|
+
/** Server info, the readable version (the raw JSON policy line escapes
|
|
672
|
+
* the dialog border): formatted policy lines, auth explained in words,
|
|
673
|
+
* YOUR standing on this server, and follow-up actions. */
|
|
605
674
|
async function serverInfoScreen(host: ShareHostApi, url: string, client: ReturnType<typeof adminClient>): Promise<void> {
|
|
606
675
|
const h = await client.health()
|
|
607
676
|
if (!h.ok) {
|
|
@@ -844,16 +913,15 @@ async function adminSharesMenu(host: ShareHostApi, client: ReturnType<typeof adm
|
|
|
844
913
|
}
|
|
845
914
|
|
|
846
915
|
/** The settings dialog: a compact ROOT of group entries (with live
|
|
847
|
-
* summaries) opening focused submenus. Two-level by design
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
* just returns. */
|
|
916
|
+
* summaries) opening focused submenus. Two-level by design: one flat ~25-row
|
|
917
|
+
* dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
|
|
918
|
+
* submenu Back just returns. */
|
|
851
919
|
export async function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void> {
|
|
852
920
|
let cfg = currentConfig()
|
|
853
921
|
let dirty = false
|
|
854
|
-
// Write target
|
|
855
|
-
//
|
|
856
|
-
//
|
|
922
|
+
// Write target: global settings home by default; any other existing
|
|
923
|
+
// file in the read chain may be chosen explicitly. The TUI never
|
|
924
|
+
// creates deploy-time override files.
|
|
857
925
|
const candidates = writeCandidates(moduleDir())
|
|
858
926
|
let writeTarget: WriteCandidate = candidates[0]
|
|
859
927
|
const sources = configSourceMap(moduleDir())
|
|
@@ -1017,8 +1085,8 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1017
1085
|
rows.push({
|
|
1018
1086
|
title: "Session TTL override (days)",
|
|
1019
1087
|
value: "session:ttl",
|
|
1020
|
-
description: binding?.opts?.
|
|
1021
|
-
help: `Per-session share lifetime. Overrides the config
|
|
1088
|
+
description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
|
|
1089
|
+
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)"}`,
|
|
1022
1090
|
})
|
|
1023
1091
|
rows.push({
|
|
1024
1092
|
title: "Session read auth",
|
|
@@ -1233,9 +1301,9 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1233
1301
|
for (const b of allB) {
|
|
1234
1302
|
if (!b.server) continue
|
|
1235
1303
|
// keep id/secret on the opt-out marker: the snapshot stays
|
|
1236
|
-
// deletable later and re-sharing reuses the binding instead
|
|
1237
|
-
// of stalling on a secret-less marker
|
|
1238
|
-
//
|
|
1304
|
+
// deletable later, and re-sharing reuses the binding instead
|
|
1305
|
+
// of stalling on a secret-less marker that can never unshare
|
|
1306
|
+
// or re-share again.
|
|
1239
1307
|
servers[b.server] = { ...(servers[b.server] as Record<string, unknown> | undefined), id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true }
|
|
1240
1308
|
}
|
|
1241
1309
|
})
|
|
@@ -1254,26 +1322,32 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1254
1322
|
return
|
|
1255
1323
|
}
|
|
1256
1324
|
if (choice === "session:ttl") {
|
|
1257
|
-
const raw = await showPrompt(host.ui, { title: "Session TTL
|
|
1325
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" })
|
|
1258
1326
|
if (raw === undefined) return
|
|
1259
1327
|
const opts: SessionOpts = { ...(current?.opts ?? {}) }
|
|
1260
|
-
if (raw === "") delete opts.
|
|
1328
|
+
if (raw === "") delete opts.ttlHours
|
|
1261
1329
|
else {
|
|
1262
|
-
const
|
|
1263
|
-
if (
|
|
1264
|
-
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of
|
|
1330
|
+
const hours = parseTtlHours(raw)
|
|
1331
|
+
if (hours === undefined) {
|
|
1332
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." })
|
|
1265
1333
|
return
|
|
1266
1334
|
}
|
|
1267
|
-
opts.
|
|
1335
|
+
opts.ttlHours = hours
|
|
1268
1336
|
}
|
|
1269
1337
|
await setShareOpts(host.client, root, opts)
|
|
1270
1338
|
const liveNow = live.length > 0
|
|
1339
|
+
if (liveNow) {
|
|
1340
|
+
// REST push (#13): immediate on every server, socket or not
|
|
1341
|
+
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 })))
|
|
1342
|
+
const failed = results.filter((r) => !r.ok).length
|
|
1343
|
+
if (failed) host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` })
|
|
1344
|
+
}
|
|
1271
1345
|
host.ui.toast({
|
|
1272
1346
|
variant: "success",
|
|
1273
1347
|
title: "OpenShare",
|
|
1274
1348
|
message: liveNow
|
|
1275
|
-
? `Session TTL: ${opts.
|
|
1276
|
-
: `Session TTL: ${opts.
|
|
1349
|
+
? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
|
|
1350
|
+
: `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
|
|
1277
1351
|
})
|
|
1278
1352
|
return
|
|
1279
1353
|
}
|
|
@@ -1298,6 +1372,12 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1298
1372
|
}
|
|
1299
1373
|
await setShareOpts(host.client, root, opts)
|
|
1300
1374
|
const liveNow2 = live.length > 0
|
|
1375
|
+
if (liveNow2) {
|
|
1376
|
+
const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password }
|
|
1377
|
+
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 })))
|
|
1378
|
+
const failed2 = results2.filter((r) => !r.ok).length
|
|
1379
|
+
if (failed2) 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.` })
|
|
1380
|
+
}
|
|
1301
1381
|
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.` })
|
|
1302
1382
|
return
|
|
1303
1383
|
}
|
|
@@ -1350,7 +1430,7 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1350
1430
|
})
|
|
1351
1431
|
groups.push({
|
|
1352
1432
|
id: "sharing",
|
|
1353
|
-
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.
|
|
1433
|
+
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
|
|
1354
1434
|
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.`,
|
|
1355
1435
|
})
|
|
1356
1436
|
groups.push({
|