sidebud 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +37 -0
- package/dist/main.js +11284 -3582
- package/package.json +3 -1
- package/skills/widget-packs/SKILL.md +9 -8
- package/src/manage/guide.md +5 -0
- package/src/manage/index.html +4 -0
- package/src/manage/manage.css +129 -0
- package/src/manage/manage.js +244 -29
- package/src/manage/packs.js +37 -6
- package/src/manage/settings.js +232 -6
- package/src/manage/setup.js +128 -23
package/src/manage/packs.js
CHANGED
|
@@ -31,10 +31,35 @@ let libraryConfirmed = false;
|
|
|
31
31
|
/** What a tool may do, as the pack pages show it. */
|
|
32
32
|
const accessPill = (access) => `<span class="pill ${access === 'write' ? 'warn' : ''}">${access === 'write' ? 'changes things' : 'reads'}</span>`;
|
|
33
33
|
|
|
34
|
+
/** Whether a pack can control the whole computer; adding or turning one on then needs an explicit OK. */
|
|
35
|
+
const controlsComputer = (overview) => overview?.level === 'computer';
|
|
36
|
+
|
|
37
|
+
/** How much a pack can control, as a badge for lists: red for the whole computer, amber for changes. */
|
|
38
|
+
function accessBadge(overview) {
|
|
39
|
+
if (!overview) return '';
|
|
40
|
+
return `<span class="badge ${{ computer: 'bad', changes: 'warn' }[overview.level] || ''}">${esc(overview.headline)}</span>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The first thing shown about a pack: how much it can control, then each point, most serious first. */
|
|
44
|
+
function accessOverviewHtml(overview) {
|
|
45
|
+
if (!overview) return '';
|
|
46
|
+
return `<div class="card stack access-overview level-${esc(overview.level)}" role="${controlsComputer(overview) ? 'alert' : 'note'}"><div class="row"><h3>What it can control</h3>${accessBadge(overview)}</div><ul class="access-points">${overview.points.map((point) => `<li class="${esc(point.tone)}">${esc(point.text)}</li>`).join('')}</ul></div>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The question asked before a full-control pack is added or turned on. */
|
|
50
|
+
const computerConfirmText = (name, overview) => `${name} can control this whole computer.\n\n${overview.points.filter((point) => point.tone !== 'info').map((point) => `• ${point.text}`).join('\n')}\n\nContinue only if you trust it with everything on this computer.`;
|
|
51
|
+
|
|
34
52
|
/** Who stands behind a pack, and what installing any pack means. Community packs get a warning. */
|
|
35
53
|
function trustNoticeHtml(publisher) {
|
|
36
|
-
const
|
|
37
|
-
|
|
54
|
+
const official = publisher?.official;
|
|
55
|
+
const community = publisher?.source === 'community' && !official;
|
|
56
|
+
const title = official ? `Official widget from ${esc(publisher.name)} (${esc(official.domain)})` : community ? `Community widget by ${esc(publisher.name)}` : 'Made by Sidebud';
|
|
57
|
+
const body = official
|
|
58
|
+
? `Published by a verified ${esc(official.domain)} account, and reviewed by Sidebud before it was listed.`
|
|
59
|
+
: community
|
|
60
|
+
? 'Sidebud reviewed this release before listing it, but did not make it and may not support it. It may stop working or behave differently than described. Install it only if you trust its author, and read what it can do first.'
|
|
61
|
+
: 'The Sidebud team builds and maintains this widget and checks it before it is listed.';
|
|
62
|
+
return `<div class="card trust-notice ${community ? 'community' : ''}" role="${community ? 'alert' : 'note'}"><strong>${title}</strong><p>${body}</p><p class="hint">Every widget runs on this computer with the access listed here. It installs switched off, and your agent sets it up; you choose when to turn it on, and you can turn off any tool.</p></div>`;
|
|
38
63
|
}
|
|
39
64
|
|
|
40
65
|
/** The library pack's page: what it can do, its trust, and its widget on a phone, before anything is installed. */
|
|
@@ -46,14 +71,20 @@ function libraryPackView() {
|
|
|
46
71
|
if (!page) return '<section class="section"><a href="#mcp">← Integrations</a><div class="card muted">Loading…</div></section>';
|
|
47
72
|
const a = page.access;
|
|
48
73
|
const community = page.publisher.source === 'community';
|
|
74
|
+
const computer = controlsComputer(page.overview);
|
|
49
75
|
const installed = page.installedVersion !== null;
|
|
76
|
+
const confirmText = computer
|
|
77
|
+
? community
|
|
78
|
+
? `I trust ${esc(page.publisher.name)} and understand ${esc(page.name)} can control this whole computer`
|
|
79
|
+
: `I understand ${esc(page.name)} can control this whole computer`
|
|
80
|
+
: `I trust ${esc(page.publisher.name)} and have read what this widget can do`;
|
|
50
81
|
const tools = `<table class="tool-table"><thead><tr><th>Tool</th><th>Access</th><th>Used by</th></tr></thead><tbody>${a.tools.map((tool) => `<tr><td>${code(tool.name)}${tool.description ? `<div class="hint">${esc(tool.description)}</div>` : ''}</td><td>${accessPill(tool.access)}</td><td class="hint">${tool.usedBy.map(esc).join(', ') || 'Your agent, when you ask'}</td></tr>`).join('')}</tbody></table><p class="hint">Your computer only ever calls these tools. After installing, you can turn off any of them on the pack's page; a tool that is off is never called by the widget, a control, or your agent.</p>`;
|
|
51
82
|
const runs = a.launch.transport === 'stdio' ? `<p>${code(a.launch.command)}</p><p class="hint">${a.launch.ownServer ? 'A small server that comes with this pack, run with Node.js while the pack is on. Its code is in the pack folder.' : 'A program already on this computer, started while the pack is on.'}${a.launch.env.length ? ` It receives ${a.launch.env.map(code).join(', ')}.` : ''}</p>` : `<p>${code(a.launch.url)}</p><p class="hint">A service on the internet this computer connects to. Keys go only to it.</p>`;
|
|
52
83
|
const keys = `${a.accountSignIn ? '<p>You sign in to an account in this computer\'s browser.</p>' : ''}${a.secrets.map((secret) => `<p>${code(secret.name)} <span class="hint">${esc(secret.description)}</span></p>`).join('')}${!a.accountSignIn && !a.secrets.length ? '<p class="hint">No keys or sign-in.</p>' : ''}<p class="hint">Keys and tokens are saved in this computer's keychain and never shown again.</p>`;
|
|
53
84
|
const action = installed
|
|
54
85
|
? `<a class="button" href="#mcp/${encodeURIComponent(page.id)}">Open its page · installed ${esc(page.installedVersion)}</a>`
|
|
55
|
-
: `${community ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}>
|
|
56
|
-
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community ? 'warn' : ''}">${community ? 'Community' : 'Sidebud'}</span></div>${trustNoticeHtml(page.publisher)}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>
|
|
86
|
+
: `${community || computer ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}> ${confirmText}</label>` : ''}<button class="primary" data-action="install-library-pack" data-id="${esc(page.slug)}" ${(community || computer) && !libraryConfirmed ? 'disabled' : ''}>${page.needsReinstall ? 'Reinstall' : 'Install'} ${esc(page.name)}</button>`;
|
|
87
|
+
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community && !page.publisher.official ? 'warn' : ''}">${page.publisher.official ? `Official · ${esc(page.publisher.official.domain)}` : community ? 'Community' : 'Sidebud'}</span></div>${accessOverviewHtml(page.overview)}${trustNoticeHtml(page.publisher)}${page.deprecated ? `<div class="card"><strong>Deprecated</strong><p class="hint">${esc(page.deprecated)}</p></div>` : ''}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Tools it may call</h3>${tools}</div><div class="card stack"><h3>What it runs</h3>${runs}</div><div class="card stack"><h3>Keys and accounts</h3>${keys}</div><div class="card stack"><h3>Network</h3><p class="hint">${esc(a.network.join(', ') || 'None declared beyond what it runs.')}</p></div>${a.setup.steps.length ? `<div class="card stack"><h3>Setting it up</h3><ol class="plain-steps">${a.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>${a.setup.prerequisites.length ? `<p class="hint">Needs: ${a.setup.prerequisites.map(esc).join(', ')}</p>` : ''}</div>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><p class="hint">${page.hasPreview ? 'Made-up sample data from the pack. Tap the tile to open it.' : 'This pack has no sample data, so its widget is drawn empty.'}</p><div id="library-preview">${libraryPreviewBody(page)}</div></aside></div></section>`;
|
|
57
88
|
}
|
|
58
89
|
|
|
59
90
|
function libraryPreviewBody(page) {
|
|
@@ -137,7 +168,7 @@ function detailHtml(tile, { back = false } = {}) {
|
|
|
137
168
|
if (!tile) return '';
|
|
138
169
|
const blocks = tile.blocks.map((block) => {
|
|
139
170
|
if (block.type === 'stats') return `<div class="phone-stats">${block.items.map((item) => `<div><strong class="${item.tone === 'neutral' ? '' : tone(item.tone)}">${esc(item.value)}</strong><span class="muted">${esc(item.label)}</span></div>`).join('')}</div>`;
|
|
140
|
-
if (block.type === 'list') return `<div class="phone-list">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}${block.items.slice(0, 6).map((item) => `<div><div><strong>${esc(item.title)}</strong>${item.subtitle ? `<span class="muted">${esc(item.subtitle)}</span>` : ''}</div><div class="phone-row-end">${item.status ? `<span class="${tone(item.status.tone)}">${esc(item.status.text)}</span>` : ''}${item.time ? `<span class="muted">${esc(relativeTime(item.time))}</span>` : ''}</div
|
|
171
|
+
if (block.type === 'list') return `<div class="phone-list">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}${block.items.slice(0, 6).map((item) => `<div><div><strong>${esc(item.title)}</strong>${item.subtitle ? `<span class="muted">${esc(item.subtitle)}</span>` : ''}</div><div class="phone-row-end">${item.status ? `<span class="${tone(item.status.tone)}">${esc(item.status.text)}</span>` : ''}${item.time ? `<span class="muted">${esc(relativeTime(item.time))}</span>` : ''}</div>${item.button ? `<span class="phone-row-button" title="A tap runs it on the phone">${esc(item.button.label)}</span>` : ''}</div>`).join('') || `<p class="muted">${esc(block.emptyText || 'Nothing here')}</p>`}${block.items.length > 6 ? `<span class="phone-more">Show ${block.items.length - 6} more</span>` : ''}</div>`;
|
|
141
172
|
if (block.type === 'board') return `<div class="phone-board">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}<div class="phone-columns">${block.columns.map((column) => `<div class="phone-column"><div class="phone-column-head"><span class="phone-dot bg-${esc(column.tone)}"></span><strong>${esc(column.title)}</strong><span class="muted">${column.count}</span></div>${column.items.map((item) => `<div class="phone-card"><span>${esc(item.title)}</span>${item.subtitle || item.time ? `<span class="muted">${esc(item.subtitle || '')}${item.subtitle && item.time ? ' · ' : ''}${item.time ? esc(relativeTime(item.time)) : ''}</span>` : ''}</div>`).join('') || '<span class="muted">Nothing here</span>'}${column.count > column.items.length ? `<span class="muted">+${column.count - column.items.length} more</span>` : ''}</div>`).join('')}</div></div>`;
|
|
142
173
|
if (block.type === 'text') return `<p class="phone-text ${block.tone === 'neutral' ? '' : tone(block.tone)}">${esc(block.text)}</p>`;
|
|
143
174
|
if (block.type === 'progress') return `<div class="phone-progress"><span>${esc(block.label)}</span><span class="phone-bar"><span data-progress="${Number(block.value) || 0}"></span></span></div>`;
|
|
@@ -274,7 +305,7 @@ function packPageView() {
|
|
|
274
305
|
const controls = page?.controls.length ? `<ul class="plain-list">${page.controls.map((control) => `<li><strong>${esc(control.label)}</strong> ${control.default ? '<span class="pill">shown by default</span>' : ''} ${control.consequential ? '<span class="pill warn">asks first</span>' : ''}<div class="hint">${control.prompt ? `Says: “${esc(control.prompt)}”` : `Runs ${code(control.tool)}`}</div></li>`).join('')}</ul><p class="hint">Each phone chooses which controls appear on the widget.</p>` : '<p class="hint">No controls.</p>';
|
|
275
306
|
const setup = page ? `${page.setup.steps.length ? `<ol class="plain-steps">${page.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>` : '<p class="hint">No setup steps.</p>'}${page.setup.prerequisites.length ? `<p class="hint">Needs: ${page.setup.prerequisites.map((item) => `${esc(item.name)}${item.found === null ? '' : item.found ? ' ✓' : ' (not found on this computer)'}`).join(', ')}</p>` : ''}` : '';
|
|
276
307
|
const runs = page ? `<p>${page.launch.transport === 'stdio' ? `Starts ${code(page.launch.command)}${page.ownServer ? ', the small server this pack ships' : ''}${page.launch.env.length ? ` with ${page.launch.env.map(code).join(', ')} set` : ''}.` : `Connects to ${code(page.launch.url)}.`}</p><p class="hint">Pack folder: ${code(page.folder)}. Keys and tokens are masked here and never leave this computer.</p>` : '';
|
|
277
|
-
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(p.name)}</h2><p class="hint">${esc(p.author)} · ${esc(p.license)} · version ${esc(p.version)}${page?.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(p.description)}</p></div><div class="stack"><span class="badge ${statusClass[p.status.state] || ''}">${esc(statusLabel[p.status.state] || p.status.state)}</span>${p.status.detail ? `<p class="hint">${esc(packStatusDetail(p))}</p>` : ''}</div></div><div class="actions"><button data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Turn off' : 'Turn on'}</button><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button data-action="export-pack" data-id="${esc(p.id)}">Export</button><button data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button
|
|
308
|
+
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(p.name)}</h2><p class="hint">${esc(p.author)} · ${esc(p.license)} · version ${esc(p.version)}${page?.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(p.description)}</p></div><div class="stack"><span class="badge ${statusClass[p.status.state] || ''}">${esc(statusLabel[p.status.state] || p.status.state)}</span>${p.status.detail ? `<p class="hint">${esc(packStatusDetail(p))}</p>` : ''}</div></div><div class="actions"><button data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Turn off' : 'Turn on'}</button><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button data-action="export-pack" data-id="${esc(p.id)}">Export</button><button data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button>${p.rollbackVersion ? `<button data-action="rollback-pack" data-id="${esc(p.id)}">Roll back to ${esc(p.rollbackVersion)}</button>` : ''}<button class="danger" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div>${p.pulled ? `<div class="card trust-notice community" role="alert"><strong>Pulled from the widget library</strong><p>Sidebud pulled version ${esc(p.pulled.version)}: ${esc(p.pulled.reason)}. It was turned off and stays off until you update it${p.rollbackVersion ? ` or roll back to ${esc(p.rollbackVersion)}` : ''}.</p></div>` : ''}<div id="pack-editor"></div>${packPageError ? `<div class="card muted">${esc(packPageError)}</div>` : ''}${accessOverviewHtml(page?.overview)}<div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Settings and keys</h3>${settings || '<p class="hint">No settings.</p>'}${keys}${settings || keys ? `<div class="actions"><button class="primary" data-action="save-pack" data-id="${esc(p.id)}">Save settings</button></div>` : ''}${account}</div><div class="card stack"><h3>Permissions</h3>${tools}</div><div class="card stack"><h3>Widget and voice</h3>${refresh}${announce}<h4>Controls</h4>${controls}</div><div class="card stack"><h3>Setup</h3>${setup}</div><div class="card stack"><h3>What it runs</h3>${runs}</div>${page?.skill ? `<details class="card"><summary><strong>Skill for the execution agent</strong></summary><p class="hint">Your agent reads this whenever the integration is on. Change it by editing ${code('SKILL.md')} in a copy of the pack, or ask your agent to.</p><pre class="skill">${esc(page.skill)}</pre></details>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><div id="pack-preview">${packPreviewBody(packId)}</div></aside></div></section>`;
|
|
278
309
|
}
|
|
279
310
|
|
|
280
311
|
async function loadPackPage() {
|
package/src/manage/settings.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Settings: the Sidebud account, this computer's name, and appearance. Loaded after manage.js and shares its globals.
|
|
1
|
+
// Settings: the Sidebud account and its settings sync, the network, this computer's name, crash reports, and appearance. Loaded after manage.js and shares its globals.
|
|
2
2
|
let renaming = false;
|
|
3
3
|
|
|
4
4
|
/** The sidebar's computer card: the saved name, and who this computer is signed in as. */
|
|
@@ -47,13 +47,78 @@ async function saveComputerName(value) {
|
|
|
47
47
|
await reload();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Account settings sync (ADR 0014): what each section carries, in the owner's words.
|
|
51
|
+
const SYNC_SECTIONS = [
|
|
52
|
+
{ id: 'voice', short: 'Voice', label: 'Voice choices' },
|
|
53
|
+
{ id: 'alerts', short: 'Alerts', label: 'Task and message alert choices and contacts' },
|
|
54
|
+
{ id: 'agents', short: 'Agents', label: 'Agent profiles, inline skills, and your default agent' },
|
|
55
|
+
{ id: 'packs', short: 'Integrations', label: 'Installed integrations and their non-folder settings' },
|
|
56
|
+
{ id: 'packCopies', short: 'Customized integrations', label: 'Customized integrations (as files, without keys)' },
|
|
57
|
+
{ id: 'surfaces', short: 'Built-in widgets', label: 'Built-in widgets on or off' },
|
|
58
|
+
];
|
|
59
|
+
const syncSectionName = (id) => SYNC_SECTIONS.find((section) => section.id === id)?.short || id;
|
|
60
|
+
|
|
61
|
+
/** The opt-in question, in Settings and setup's last step. Nothing is sent until the button. */
|
|
62
|
+
function syncOptInBlock() {
|
|
63
|
+
const { sync } = state;
|
|
64
|
+
const switches = SYNC_SECTIONS.map((section) => `<div class="check"><input id="sync-choice-${section.id}" type="checkbox" data-sync-choice="${section.id}" ${sync.sections?.[section.id] === false ? '' : 'checked'}><label for="sync-choice-${section.id}">${esc(section.label)}</label></div>`).join('');
|
|
65
|
+
return `<div class="stack sync-block"><div class="check"><input id="sync-opt-in" type="checkbox" checked><label for="sync-opt-in">Sync settings to my account</label></div><div><p class="hint">What syncs:</p><ul class="hint sync-list">${SYNC_SECTIONS.map((section) => `<li>${esc(section.label)}</li>`).join('')}</ul><p class="hint">Keys and sign-ins, this computer's name, folders, and paths stay here.</p></div><details><summary>Advanced</summary><div class="stack">${switches}</div></details><div class="actions"><button class="primary" data-action="sync-opt-in">${sync.decided ? 'Turn on sync' : 'Continue'}</button>${sync.decided ? '<button class="danger" data-action="sync-delete">Delete account copy</button>' : ''}</div></div>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function syncStatusText(sync) {
|
|
69
|
+
if (sync.state === 'syncing') return 'Syncing…';
|
|
70
|
+
if (sync.state === 'offline') return 'Offline, will retry';
|
|
71
|
+
if (sync.state === 'error') return sync.lastError || 'Sync failed. It will try again.';
|
|
72
|
+
return sync.lastSyncedAt ? `Synced ${new Date(sync.lastSyncedAt).toLocaleString()}` : 'Not synced yet';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sync while it is on: status, what waits to send, and the ways to stop or remove the account copy. */
|
|
76
|
+
function syncOnBlock() {
|
|
77
|
+
const { sync } = state;
|
|
78
|
+
const switches = SYNC_SECTIONS.map((section) => `<div class="check"><input id="sync-section-${section.id}" type="checkbox" data-sync-section="${section.id}" ${sync.sections[section.id] ? 'checked' : ''}><label for="sync-section-${section.id}">${esc(section.label)}</label></div>`).join('');
|
|
79
|
+
return `<div class="stack sync-block"><div class="row"><div><strong>Settings sync to your account</strong><p class="hint ${sync.state === 'error' ? 'error-text' : ''}" role="status">${esc(syncStatusText(sync))}</p></div><span class="badge ${{ idle: 'good', syncing: '', offline: 'warn', error: 'bad' }[sync.state] ?? ''}">${esc({ idle: 'On', syncing: 'Syncing', offline: 'Offline', error: 'Error' }[sync.state] || 'On')}</span></div>${sync.pending.length ? `<p class="hint">Waiting to send: ${esc(sync.pending.map(syncSectionName).join(', '))}.</p>` : ''}<details><summary>Advanced</summary><div class="stack"><p class="hint">Turn off a part to keep it on this computer only.</p>${switches}</div></details><div class="actions"><button data-action="sync-now" ${sync.state === 'syncing' ? 'disabled' : ''}>Sync now</button><button data-action="sync-stop">Stop syncing</button><button class="danger" data-action="sync-delete">Delete account copy</button></div></div>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The account card's sync part; setup shows only the question. */
|
|
83
|
+
function syncBlock({ setup = false } = {}) {
|
|
84
|
+
const { sync } = state;
|
|
85
|
+
if (!sync?.available) return '';
|
|
86
|
+
// A restart the account's changes need shows in the page's restart banner.
|
|
87
|
+
const notes = sync.notices.map((text) => `<p class="hint">${esc(text)}</p>`).join('');
|
|
88
|
+
if (!sync.enabled) return syncOptInBlock() + notes;
|
|
89
|
+
return (setup ? '<p class="hint">Your settings sync to your account. Manage sync under <a href="#settings">Settings</a>.</p>' : syncOnBlock()) + notes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Customized packs from the account that run code here wait for consent on each computer (ADR 0012). */
|
|
93
|
+
function syncConsentsCard() {
|
|
94
|
+
const consents = state.sync?.available ? state.sync.consents : [];
|
|
95
|
+
if (!consents.length) return '';
|
|
96
|
+
return `<div class="card stack"><div><h3>Integrations from your account</h3><p class="hint">These run their own code or use this computer’s keys, so each computer asks first. They stay off here until you allow them.</p></div>${consents.map((consent) => `<div class="row"><p>${esc(consent.name)} ${esc(consent.version)} from your account runs code on this computer.</p><div class="actions"><button class="primary" data-action="sync-consent" data-id="${esc(consent.packId)}" data-allow="true">Allow</button><button data-action="sync-consent" data-id="${esc(consent.packId)}" data-allow="false">Not on this computer</button></div></div>`).join('')}</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A synced value in one line: JSON for objects, cut to about 120 characters. */
|
|
100
|
+
function syncValue(value) {
|
|
101
|
+
if (value === undefined) return '(not set)';
|
|
102
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
103
|
+
return text.length > 120 ? `${text.slice(0, 119)}…` : text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** When this computer and the account changed the same setting, the account's value won; this is the way back. */
|
|
107
|
+
function syncConflictsCard() {
|
|
108
|
+
const conflicts = state.sync?.available ? state.sync.conflicts : [];
|
|
109
|
+
if (!conflicts.length) return '';
|
|
110
|
+
return `<div class="card stack"><div><h3>Changed in two places</h3><p class="hint">This computer and your account both changed these settings. Your account's value is in use; choose this computer's instead, or dismiss.</p></div>${conflicts.map((conflict) => `<div class="stack sync-conflict"><strong>${esc(conflict.label)}</strong><p class="hint">Your account's value: <span class="code">${esc(syncValue(conflict.remote))}</span></p><p class="hint">This computer had: <span class="code">${esc(syncValue(conflict.local))}</span></p><div class="actions"><button data-action="sync-conflict" data-id="${conflict.id}" data-choice="use-local">Use this instead</button><button data-action="sync-conflict" data-id="${conflict.id}" data-choice="dismiss">Dismiss</button></div></div>`).join('')}</div>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `setup` is setup's last step: the account is optional there, and signed in it asks only the sync question. */
|
|
114
|
+
function accountCard({ setup = false } = {}) {
|
|
51
115
|
const { account } = state;
|
|
52
116
|
const site = esc(account.server.replace(/^https?:\/\//, ''));
|
|
53
|
-
const head = (text) => `<div><h3>Sidebud account</h3><p class="hint">${text}</p></div>`;
|
|
117
|
+
const head = (text) => `<div><h3>Sidebud account${setup ? ' <span class="optional">· Optional</span>' : ''}</h3><p class="hint">${text}</p></div>`;
|
|
54
118
|
if (account.status === 'signed_in') {
|
|
55
119
|
const who = account.user.name && account.user.email ? `${esc(account.user.name)} · ${esc(account.user.email)}` : esc(account.user.email || account.user.name || 'Your account');
|
|
56
|
-
return `<div class="card stack">${head(`
|
|
120
|
+
if (setup) return `<div class="card stack">${head(`Signed in as ${who}.`)}${syncBlock({ setup })}</div>`;
|
|
121
|
+
return `<div class="card stack">${head(`This computer is signed in to ${site} and shows in your account's list of computers.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div>${syncBlock()}<div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
|
|
57
122
|
}
|
|
58
123
|
if (account.status === 'pending') {
|
|
59
124
|
const link = account.verificationUriComplete || account.verificationUri;
|
|
@@ -61,13 +126,81 @@ function accountCard() {
|
|
|
61
126
|
}
|
|
62
127
|
if (account.status === 'unavailable')
|
|
63
128
|
return `<div class="card stack">${head('Sign in to connect this computer to your account.')}<p class="hint">${esc(account.error)} Everything on this page keeps working without an account.</p><div class="actions"><button data-action="account-sign-in">Try again</button></div></div>`;
|
|
64
|
-
return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
|
|
129
|
+
return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account${setup ? ' and keep your settings in it. Setup finishes without an account' : ''}. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Only while the site sells plans (`billing`); the plan is managed on the site, so there are no prices here. */
|
|
133
|
+
function planCard() {
|
|
134
|
+
if (!state.capabilities.flags.billing || !state.plan.url) return '';
|
|
135
|
+
const inactive = state.capabilities.flags.paywall && state.plan.status === 'inactive';
|
|
136
|
+
const text = inactive
|
|
137
|
+
? "Your Sidebud plan isn't active, so calls, requests, and new tasks are paused on this computer. Choose or renew a plan on the Sidebud site. This computer checks again every hour and whenever you try to use it."
|
|
138
|
+
: 'Your Sidebud plan is managed on the Sidebud site.';
|
|
139
|
+
return `<div class="card stack"><div><h3>Plan</h3><p class="hint ${inactive ? 'error-text' : ''}"${inactive ? ' role="alert"' : ''}>${esc(text)}</p></div><div class="actions"><a class="button ${inactive ? 'primary' : ''}" href="${esc(state.plan.url)}" target="_blank" rel="noopener">Manage plan</a></div></div>`;
|
|
65
140
|
}
|
|
66
141
|
|
|
142
|
+
const NETWORK_CHOICES = [
|
|
143
|
+
{ id: 'private', label: 'Only this computer and my tailnet', hint: 'Phones reach this computer over Tailscale. Other devices on your Wi-Fi cannot connect. Recommended.' },
|
|
144
|
+
{ id: 'lan', label: 'This network (LAN)', hint: 'Any phone on the same Wi-Fi or network can reach this computer, as well as over Tailscale. Pairing still needs a code from this page.' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/** Who can reach this computer's phone listener: the choice, its addresses, and a change that waits for a restart. */
|
|
148
|
+
function networkChoice({ setup = false } = {}) {
|
|
149
|
+
const { exposure, saved, listening, port } = state.network;
|
|
150
|
+
const pending = saved !== exposure;
|
|
151
|
+
const current = exposure === 'custom' ? `a custom address (${listening.join(', ')})` : NETWORK_CHOICES.find((choice) => choice.id === exposure).label.toLowerCase();
|
|
152
|
+
const tailnet = listening.some((host) => host.startsWith('100.') || host.startsWith('fd7a:')) || exposure === 'lan';
|
|
153
|
+
return `<div class="stack" role="radiogroup" aria-label="Who can reach this computer">${NETWORK_CHOICES.map((choice) => `<label class="check"><input type="radio" name="network-exposure" value="${choice.id}" ${(pending ? saved : exposure) === choice.id ? 'checked' : ''}><span><strong>${esc(choice.label)}</strong><br><span class="hint">${esc(choice.hint)}</span></span></label>`).join('')}</div>${setup ? '' : `<p class="hint">Now: ${esc(current)}, port ${port}.${listening.length ? ` Listening on ${esc(listening.join(', '))}.` : ''}</p>`}${exposure === 'private' && !tailnet ? '<p class="hint">Tailscale is not connected on this computer, so only this computer can reach Sidebud. Connect Tailscale on both devices; the companion picks it up within a minute.</p>' : ''}${pending ? '<p class="hint">Your new choice applies after you apply changes.</p>' : ''}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function saveNetworkChoice() {
|
|
157
|
+
const exposure = document.querySelector('input[name="network-exposure"]:checked')?.value;
|
|
158
|
+
if (!exposure) throw new Error('Choose who can reach this computer.');
|
|
159
|
+
if (exposure !== state.network.saved) await api('network', { exposure });
|
|
160
|
+
await reload();
|
|
161
|
+
return !state.restart.pending || (await applyChanges());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Opt-in crash reports, in Settings and on setup's last step. Saved as it
|
|
166
|
+
* changes and applied at once. Builds without a crash report address (running
|
|
167
|
+
* from source) show it off and disabled; setup leaves it out.
|
|
168
|
+
*/
|
|
169
|
+
function crashReportsCard({ setup = false } = {}) {
|
|
170
|
+
const { available, enabled } = state.crashReports;
|
|
171
|
+
if (setup && !available) return '';
|
|
172
|
+
return `<div class="card stack"><div><h3>Crash reports${setup ? ' <span class="optional">· Optional</span>' : ''}</h3></div><div class="check"><input id="crash-reports" type="checkbox" data-crash-reports ${enabled && available ? 'checked' : ''} ${available ? '' : 'disabled'}><label for="crash-reports">Send crash reports to Sidebud</label></div><p class="hint">Sends error details when something breaks, with your prompts, transcripts, audio, messages, and keys removed and your home folder hidden in file paths. Off unless you turn it on.</p>${available ? '' : '<p class="hint">Not available in this copy of Sidebud (running from source).</p>'}</div>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* "Start Sidebud when I log in" (a macOS launchd agent). The page only writes or removes the login item: this
|
|
177
|
+
* companion already holds the ports, so it takes effect at the next login; `sidebud service install` starts it now.
|
|
178
|
+
*/
|
|
179
|
+
function loginItemCard({ setup = false } = {}) {
|
|
180
|
+
const item = state.install?.loginItem;
|
|
181
|
+
if (!item || item.platform !== 'supported') return '';
|
|
182
|
+
const head = `<div><h3>${setup ? 'Keep Sidebud running <span class="optional">· Recommended</span>' : 'Start Sidebud when I log in'}</h3><p class="hint">Sidebud runs in the background on this Mac, so your phone can reach it without a terminal window. It restarts by itself if it stops unexpectedly.</p></div>`;
|
|
183
|
+
if (item.installed)
|
|
184
|
+
return `<div class="card stack">${head}<p>${item.running ? 'On. This companion is running from the login item.' : 'On. Sidebud starts by itself the next time you log in. To switch now, quit this companion and run <span class="code">sidebud service install</span>.'}</p><p class="hint">Logs: <span class="code">${esc(item.logPath)}</span></p><div class="actions"><button data-action="login-item" data-enabled="false">Stop starting at login</button></div></div>`;
|
|
185
|
+
if (item.unavailable) return `<div class="card stack">${head}<p class="hint">${esc(item.unavailable).replace(/`([^`]+)`/g, '<span class="code">$1</span>')}</p></div>`;
|
|
186
|
+
return `<div class="card stack">${head}<div class="actions"><button ${setup ? 'class="primary" ' : ''}data-action="login-item" data-enabled="true">Start Sidebud when I log in</button></div></div>`;
|
|
187
|
+
}
|
|
188
|
+
/** Installed version and the daily update check (`updates.check` in config.json). */
|
|
189
|
+
function versionCard() {
|
|
190
|
+
const install = state.install;
|
|
191
|
+
if (!install) return '';
|
|
192
|
+
const { updates } = install;
|
|
193
|
+
const checked = updates.checkedAt ? `Last checked ${esc(new Date(updates.checkedAt).toLocaleString())}.` : 'Not checked yet.';
|
|
194
|
+
const status = !updates.enabled
|
|
195
|
+
? install.kind === 'source' ? 'Running from source; update checks are off.' : 'Update checks are off (<span class="code">updates.check</span> in config.json).'
|
|
196
|
+
: updates.available ? `Sidebud ${esc(updates.latest)} is available. Update with <span class="code">${esc(updates.command)}</span>.`
|
|
197
|
+
: `${updates.latest ? 'Up to date.' : ''} ${checked}${updates.error ? ` The last check failed: ${esc(updates.error)}.` : ''}`;
|
|
198
|
+
return `<div class="card stack"><div><h3>Version</h3><p class="hint">Sidebud ${esc(updates.current)}. Checks npm for a new release once a day and never updates by itself.</p></div><p>${status}</p></div>`;
|
|
199
|
+
}
|
|
67
200
|
function settingsView() {
|
|
68
201
|
const theme = document.documentElement.dataset.theme;
|
|
69
202
|
const renamed = state.savedName !== state.name;
|
|
70
|
-
return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}<div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div
|
|
203
|
+
return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}${planCard()}${syncConsentsCard()}${syncConflictsCard()}<div class="card stack"><div><h3>Network</h3><p class="hint">Who can reach this computer to pair and connect a phone. This page itself is only ever available on this computer.</p></div>${networkChoice()}<div class="actions"><button data-action="save-network">Save network choice</button></div></div><div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div>${crashReportsCard()}<div class="card stack"><div><h3>Appearance</h3><p class="hint">Remembered in this browser.</p></div><div class="segmented" role="group" aria-label="Theme"><button data-action="set-theme" data-id="dark" aria-pressed="${theme === 'dark'}">Dark</button><button data-action="set-theme" data-id="light" aria-pressed="${theme === 'light'}">Light</button></div></div>${loginItemCard()}${versionCard()}</section>`;
|
|
71
204
|
}
|
|
72
205
|
|
|
73
206
|
/** Handles Settings and sidebar actions; returns true when it took the action. */
|
|
@@ -76,10 +209,32 @@ async function handleSettingsAction(action, button) {
|
|
|
76
209
|
startInlineRename();
|
|
77
210
|
return true;
|
|
78
211
|
}
|
|
212
|
+
if (action === 'save-network') {
|
|
213
|
+
if (await saveNetworkChoice()) notice('Network choice saved.');
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
79
216
|
if (action === 'save-name') {
|
|
80
217
|
await saveComputerName($('#computer-name').value);
|
|
81
218
|
return true;
|
|
82
219
|
}
|
|
220
|
+
if (action === 'update') {
|
|
221
|
+
button.disabled = true;
|
|
222
|
+
try {
|
|
223
|
+
const result = await api('update', { when: button.dataset.when });
|
|
224
|
+
notice(result.message);
|
|
225
|
+
await reload();
|
|
226
|
+
} finally { button.disabled = false; }
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (action === 'login-item') {
|
|
230
|
+
button.disabled = true;
|
|
231
|
+
try {
|
|
232
|
+
const result = await api('login-item', { enabled: button.dataset.enabled === 'true' });
|
|
233
|
+
notice(result.message);
|
|
234
|
+
await reload();
|
|
235
|
+
} finally { button.disabled = false; }
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
83
238
|
if (action === 'set-theme') {
|
|
84
239
|
localStorage.setItem('rvaManageTheme', button.dataset.id);
|
|
85
240
|
applyTheme(button.dataset.id);
|
|
@@ -101,6 +256,54 @@ async function handleSettingsAction(action, button) {
|
|
|
101
256
|
await reload();
|
|
102
257
|
return true;
|
|
103
258
|
}
|
|
259
|
+
if (action === 'sync-opt-in') {
|
|
260
|
+
const enabled = $('#sync-opt-in').checked;
|
|
261
|
+
const sections = Object.fromEntries([...document.querySelectorAll('[data-sync-choice]')].map((box) => [box.dataset.syncChoice, box.checked]));
|
|
262
|
+
button.disabled = true;
|
|
263
|
+
try {
|
|
264
|
+
await api('sync/opt-in', { enabled, sections });
|
|
265
|
+
notice(enabled ? 'Sync is on. Your settings are on their way to your account.' : 'Settings stay on this computer only. Turn on sync any time under Settings.');
|
|
266
|
+
await reload();
|
|
267
|
+
} finally { button.disabled = false; }
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (action === 'sync-now') {
|
|
271
|
+
button.disabled = true;
|
|
272
|
+
try {
|
|
273
|
+
await api('sync/now', {});
|
|
274
|
+
await reload();
|
|
275
|
+
} finally { button.disabled = false; }
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (action === 'sync-stop') {
|
|
279
|
+
if (!confirm('Stop syncing? Settings on this computer stay as they are.')) return true;
|
|
280
|
+
await api('sync/stop', {});
|
|
281
|
+
notice('Sync stopped. Settings on this computer stay as they are.');
|
|
282
|
+
await reload();
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
if (action === 'sync-delete') {
|
|
286
|
+
if (!confirm('Delete the copy of your settings stored in your Sidebud account? Every computer keeps its own settings; sync stops.')) return true;
|
|
287
|
+
// A failed delete answers with the sync status and its error; the account copy is still there.
|
|
288
|
+
const next = await api('sync/delete-account-copy', {});
|
|
289
|
+
if (next.state === 'error' || next.state === 'offline') notice(`The account copy was not deleted: ${next.lastError || 'your account could not be reached'}. Try again.`, true);
|
|
290
|
+
else notice('The copy in your account is deleted. Settings on this computer stay as they are.');
|
|
291
|
+
await reload();
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
if (action === 'sync-conflict') {
|
|
295
|
+
await api('sync/conflict', { id: Number(button.dataset.id), action: button.dataset.choice });
|
|
296
|
+
if (button.dataset.choice === 'use-local') notice('This computer’s value is back in use and goes to your account.');
|
|
297
|
+
await reload();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (action === 'sync-consent') {
|
|
301
|
+
const allow = button.dataset.allow === 'true';
|
|
302
|
+
await api('sync/consent', { packId: button.dataset.id, allow });
|
|
303
|
+
notice(allow ? 'Allowed on this computer.' : 'It stays off on this computer.');
|
|
304
|
+
await reload();
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
104
307
|
if (action === 'account-sign-out') {
|
|
105
308
|
if (!confirm('Sign this computer out of your Sidebud account?')) return true;
|
|
106
309
|
await api('account/sign-out', {});
|
|
@@ -110,3 +313,26 @@ async function handleSettingsAction(action, button) {
|
|
|
110
313
|
}
|
|
111
314
|
return false;
|
|
112
315
|
}
|
|
316
|
+
|
|
317
|
+
// The crash report switch saves as it changes; no restart is needed.
|
|
318
|
+
document.addEventListener('change', (event) => {
|
|
319
|
+
if (!event.target.matches?.('[data-crash-reports]') || !state) return;
|
|
320
|
+
const enabled = event.target.checked;
|
|
321
|
+
void api('crash-reports', { enabled }).then(() => {
|
|
322
|
+
notice(enabled ? 'Crash reports are on. Thank you.' : 'Crash reports are off. Nothing more is sent.');
|
|
323
|
+
return reload();
|
|
324
|
+
}, (error) => {
|
|
325
|
+
notice(error.message, true);
|
|
326
|
+
return reload();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Sync's per-part switches (while sync is on) save as they change.
|
|
331
|
+
document.addEventListener('change', (event) => {
|
|
332
|
+
const section = event.target.dataset?.syncSection;
|
|
333
|
+
if (!section || !state) return;
|
|
334
|
+
void api('sync/section', { section, enabled: event.target.checked }).then(() => reload(), (error) => {
|
|
335
|
+
notice(error.message, true);
|
|
336
|
+
return reload();
|
|
337
|
+
});
|
|
338
|
+
});
|