synabun 2.0.0-beta → 2.0.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/THIRD-PARTY-LICENSES.md +5 -0
  3. package/launcher.mjs +40 -0
  4. package/lib/claude-hooks.js +61 -8
  5. package/lib/client-config-repair.js +4 -3
  6. package/lib/data-home-migration.js +4 -0
  7. package/lib/desktop-pwa.js +206 -0
  8. package/lib/external-tools.js +221 -0
  9. package/lib/first-launch-protection.js +5 -0
  10. package/lib/packaged-runtime.js +110 -0
  11. package/lib/start-launcher.js +1309 -0
  12. package/neural-interface/i18n/en.json +41 -2
  13. package/neural-interface/i18n/pt-BR.json +41 -2
  14. package/neural-interface/lib/assistant-envelope.js +9 -6
  15. package/neural-interface/lib/augmented-path.js +23 -3
  16. package/neural-interface/lib/browser-language.js +83 -0
  17. package/neural-interface/lib/claude-agent-bridge.js +54 -147
  18. package/neural-interface/lib/claude-executable.js +241 -0
  19. package/neural-interface/lib/claude-model-catalog.js +52 -91
  20. package/neural-interface/lib/native-binary-runtime.js +69 -337
  21. package/neural-interface/lib/native-loop-providers.js +30 -44
  22. package/neural-interface/lib/offline-page.js +54 -0
  23. package/neural-interface/lib/session-title-generator.js +5 -0
  24. package/neural-interface/lib/synabun-update-plan.js +19 -0
  25. package/neural-interface/package-lock.json +0 -251
  26. package/neural-interface/public/install-app.html +31 -0
  27. package/neural-interface/public/manifest.json +2 -0
  28. package/neural-interface/public/offline.html +401 -66
  29. package/neural-interface/public/onboarding.html +252 -146
  30. package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
  31. package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
  32. package/neural-interface/public/shared/assistant/asst-render.js +58 -49
  33. package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
  34. package/neural-interface/public/shared/command-runner-model.js +30 -0
  35. package/neural-interface/public/shared/html-shell.js +9 -5
  36. package/neural-interface/public/shared/pwa-install-page.js +34 -0
  37. package/neural-interface/public/shared/pwa-install.css +12 -0
  38. package/neural-interface/public/shared/pwa-install.js +28 -0
  39. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  40. package/neural-interface/public/shared/start-bridge.js +583 -0
  41. package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
  42. package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
  43. package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
  44. package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
  45. package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
  46. package/neural-interface/public/shared/styles.css +801 -392
  47. package/neural-interface/public/shared/term-ready.js +307 -0
  48. package/neural-interface/public/shared/ui-claude-panel.js +3 -3
  49. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  50. package/neural-interface/public/shared/ui-judgments.js +1 -1
  51. package/neural-interface/public/shared/ui-loading.js +221 -10
  52. package/neural-interface/public/shared/ui-menubar.js +11 -27
  53. package/neural-interface/public/shared/ui-settings.js +36 -59
  54. package/neural-interface/public/shared/ui-terminal.js +130 -144
  55. package/neural-interface/public/sw.js +3 -1
  56. package/neural-interface/public/variant/2d/main.js +2 -1
  57. package/neural-interface/public/variant/3d/main.js +2 -1
  58. package/neural-interface/scripts/rebuild-pty.js +15 -12
  59. package/neural-interface/server.js +220 -185
  60. package/package.json +13 -1
  61. package/postinstall.js +11 -81
  62. package/setup.js +72 -4
  63. package/docs/browser-v2.md +0 -109
  64. package/docs/hook-integrity.md +0 -89
  65. package/docs/judgments.md +0 -182
  66. package/docs/style-guide.md +0 -246
  67. package/scripts/audit-claude-hook-usage.mjs +0 -12
  68. package/scripts/audit-memory-duplicates.mjs +0 -44
  69. package/scripts/measure-claude-hook-overhead.mjs +0 -77
  70. package/scripts/recover-user-config.mjs +0 -254
package/CHANGELOG.md CHANGED
@@ -25,6 +25,8 @@ SynaBun 2.0 adds a central Assistant that plans work and hands it to Claude Code
25
25
  - **Recall before subagents** — In Claude Code, a Task or Agent dispatch now starts from a shared memory brief (5 memories, 1,200 tokens), so workers begin with the paths, earlier fixes and constraints the session already knows. On by default.
26
26
  - **Claude accounts** — Several Claude accounts can be signed in side by side and chosen per session, the way Codex accounts already could.
27
27
  - **A new workspace tour** — The **?** button opens a hand-drawn tour of memory and the map, the whiteboard, coding tools, browser and automations, and the Assistant. It resumes where you left off, in English and Brazilian Portuguese.
28
+ - **Start Server button** — When the server is down, the offline page and the app's Server Offline overlay show a **Start Server** button. It works on macOS, Windows and Linux, for npm and GitHub installs, starts one server however often it is clicked, and the page reconnects by itself when the server answers. While a start runs, the page names each step the launcher reports (installing dependencies, the browser download, the build, the pre-update backup, the server starting) next to a clock of whole seconds; a failed start shows its reason, the log's path and **Try again**. The command under the button is one line that works in cmd.exe, PowerShell and POSIX shells. A page opened through a tunnel, an invite link or a LAN address shows the clock and general status only.
29
+ - **Command Runner as a searchable library** — Saved commands can be searched by name, command, group or directory, filtered by group, and sorted by saved order or most recently run. Commands on a local workspace stay runnable without an internet connection.
28
30
 
29
31
  ## 🛠 Fixed
30
32
 
@@ -42,12 +44,18 @@ SynaBun 2.0 adds a central Assistant that plans work and hands it to Claude Code
42
44
  - **Two ways around the X publish gate** — On loop tabs, `Cmd+Enter` and a scripted click on the post button skipped the publish gate and the write queue. Both are refused.
43
45
  - **BlueSky schedules stopped on an expired token** — The token is renewed in place, and every automation runs in the default MoreLogin profile.
44
46
  - **Session pills vanished when the toolbar collapsed** — They stay visible.
47
+ - **Saved commands started about 15 seconds late** — The Command Runner waited for a prompt it could recognise, and the default macOS zsh prompt (`%`) was not on its list, so the command was typed by a 15-second fallback. A shell tab now gets the command as soon as its prompt is ready, and the terminal says so when a command could not be delivered. Claude Code, Codex, Gemini and OpenCode tabs keep their timing.
48
+ - **The Settings save bar drifted into the middle of the page** — On a page with a save bar (Settings → Browser) the bar stays on the bottom edge of its column whether sections are open or closed, and its buttons stack in a narrow panel instead of being cut off.
49
+ - **The Assistant mascot covered your message after a send** — The character now moves at once into the Thinking row under the message.
50
+ - **Codex approvals with a policy choice** — When an approval offers a policy change (for example allowing a command from now on), the Assistant sends back exactly the choice Codex offered instead of only its label.
45
51
 
46
52
  ## ⚙️ Changed
47
53
 
48
54
  - **Plain semantic versioning** — 2.0.0 replaces the date-style versions on npm and on GitHub. The update check treats every date-style release as older than any 2.x release.
49
55
  - **The Assistant and its workers look at pages only through the SynaBun browser** — Public pages and `localhost` alike; ad-hoc Playwright, Puppeteer or headless browsers started from a shell are refused outside a project's own test suite.
50
56
  - **Hide all toolbars** — The top-right chevron now hides every toolbar, with a rebindable shortcut.
57
+ - **Onboarding installs the rules** — Step 6 of the setup wizard is **Install SynaBun Rules**: **Install for all**, or **Install**, **Update** or **Replace** per tool, with the file each tool reads. Copying by hand remains only where SynaBun cannot write (Cursor's User Rules and the optional coexistence snippet), under **Manual setup**.
58
+ - **Style Guide panel** — The header has a close button and wraps in a narrow panel, and each section is labelled for screen readers.
51
59
  - **Dependencies** — `@anthropic-ai/claude-agent-sdk` 0.3.258 → 0.3.288, `@openai/codex-sdk` 0.121 → 0.160, `@opencode-ai/sdk` 1.14 → 1.18.34 (pinned); `@xterm/headless` and `@xterm/addon-serialize` are new.
52
60
  - **Removed** — The legacy OpenCode sidepanel, the standalone Claude chat page, the 3D graphics presets, and `CLAUDE-template.md` (replaced by the installed rulesets).
53
61
 
@@ -129,3 +129,8 @@ information is in [NOTICE](./NOTICE). The
129
129
  `@anthropic-ai/claude-agent-sdk` lockfile metadata declares
130
130
  `SEE LICENSE IN README.md`; review the dependency's distributed README and
131
131
  license materials for its authoritative package-specific terms.
132
+
133
+ Claude Code, Codex, OpenCode and Gemini CLI are not dependencies of SynaBun and
134
+ are not distributed with it: the user installs them separately, under their
135
+ publishers' terms. The agent SDKs listed above are installed without the
136
+ executables they would otherwise carry (`lib/external-tools.js`).
package/launcher.mjs ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * SynaBun — start launcher
5
+ *
6
+ * What the operating system runs for a synabun://start link (the Start Server
7
+ * button on the offline page). Starts the server through the normal supervisor
8
+ * (setup.js) unless it already answers or a start is under way, then exits.
9
+ *
10
+ * Registered by lib/start-launcher.js; can also be run by hand:
11
+ * node launcher.mjs
12
+ */
13
+
14
+ import { dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { BEACON_LINGER_MS, runLauncher } from './lib/start-launcher.js';
17
+
18
+ const packageRoot = dirname(fileURLToPath(import.meta.url));
19
+
20
+ try {
21
+ // The page that asked is offline and cannot ask a server how the start is
22
+ // going: the launch reports itself on its beacon, and keeps a failure
23
+ // readable there for a moment.
24
+ const result = await runLauncher({ argv: process.argv.slice(2), packageRoot, beacon: true, lingerMs: BEACON_LINGER_MS });
25
+ if (process.stdout.isTTY) {
26
+ const said = {
27
+ ignored: 'Nothing to do for that link.',
28
+ 'already-running': `SynaBun is already running on port ${result.port}.`,
29
+ 'already-starting': 'SynaBun is already starting.',
30
+ started: `SynaBun is running on port ${result.port}.`,
31
+ spawned: 'SynaBun is starting; it has not answered yet.',
32
+ failed: 'SynaBun could not be started. Run "npm start" in its folder to see why.',
33
+ }[result.outcome];
34
+ if (said) console.log(said);
35
+ }
36
+ process.exit(result.code);
37
+ } catch (error) {
38
+ console.error(`SynaBun launcher: ${error.message}`);
39
+ process.exit(1);
40
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { PACKAGE_ROOT } from './paths.js';
3
+ import { durableEntry, packagedRuntime } from './packaged-runtime.js';
3
4
 
4
5
  // Handler identity must be byte-for-byte identical across settings scopes.
5
6
  // Claude Code deduplicates matching settings handlers before executing them.
@@ -32,14 +33,41 @@ export function hookDefinitionsForEvent(event) {
32
33
  // both cmd.exe and POSIX shells. Keep this versioned literal stable.
33
34
  const BOOTSTRAP = "const f=require('node:fs'),p=require('node:path'),u=require('node:url'),s=process.argv[1];let r;const ok=d=>{try{return JSON.parse(f.readFileSync(p.join(d,'package.json'),'utf8')).name==='synabun'&&f.existsSync(p.join(d,'hooks','claude-code',s))}catch{return false}};for(let d=p.resolve(process.env.CLAUDE_PROJECT_DIR||process.cwd());;d=p.dirname(d)){if(ok(d)){r=d;break}if(d===p.dirname(d))break}if(!r&&process.env.SYNABUN_HOOK_ROOT&&ok(process.env.SYNABUN_HOOK_ROOT))r=process.env.SYNABUN_HOOK_ROOT;if(!r)throw Error('SynaBun hook root unavailable; repair the hook installation');import(u.pathToFileURL(p.join(r,'hooks','claude-code',s)).href)";
34
35
 
35
- export function hookCommandString(scriptName) {
36
+ /**
37
+ * A packaged application registers its own entry executable instead: the
38
+ * person has no Node of their own, and an IDE started by itself never sees the
39
+ * application's PATH. `<entry> claude-hook <script>` runs the handler with the
40
+ * Node that came with the application (packaging/runtime/bootstrap.mjs).
41
+ */
42
+ export const PACKAGED_HOOK_MODE = 'claude-hook';
43
+
44
+ // The entry inside the command's double quotes. A POSIX shell still reads
45
+ // \ " $ ` there. A Windows path has no quote in it, and forward slashes keep
46
+ // it one argument for cmd.exe and Git Bash alike.
47
+ function quoteHookEntry(entry) {
48
+ if (/^[A-Za-z]:[\\/]|^\\\\/.test(entry)) return `"${entry.replace(/\\/g, '/')}"`;
49
+ return `"${entry.replace(/[\\"$`]/g, '\\$&')}"`;
50
+ }
51
+
52
+ const nodeHookCommand = scriptName => `node -e "${BOOTSTRAP}" ${scriptName}`;
53
+
54
+ // Whatever entry wrote it: an application that moved must still find, and
55
+ // repair, its own handlers.
56
+ const PACKAGED_HOOK_COMMAND = new RegExp(`^"(?:[^"\\\\]|\\\\.)+"\\s+${PACKAGED_HOOK_MODE}\\s+(\\S+)$`);
57
+
58
+ export function hookCommandString(scriptName, _targetProjectPath, runtime = packagedRuntime()) {
36
59
  if (!HOOK_SCRIPTS.some(h => h.script === scriptName)) throw new Error('Unknown SynaBun hook');
37
- return `node -e "${BOOTSTRAP}" ${scriptName}`;
60
+ const entry = durableEntry(runtime);
61
+ return entry ? `${quoteHookEntry(entry)} ${PACKAGED_HOOK_MODE} ${scriptName}` : nodeHookCommand(scriptName);
38
62
  }
39
63
 
40
64
  export function isSynaBunHookCommand(command, scriptName) {
41
65
  if (typeof command !== 'string') return false;
42
- if (command === hookCommandString(scriptName)) return true;
66
+ // Both canonical forms, whichever kind of install is reading: moving between
67
+ // an npm install and the application must not leave two handlers behind.
68
+ if (command === nodeHookCommand(scriptName)) return true;
69
+ const packaged = command.trim().match(PACKAGED_HOOK_COMMAND);
70
+ if (packaged) return packaged[1] === scriptName;
43
71
  // Recognize only the old direct-node path forms, including cross-OS installs.
44
72
  const match = command.trim().match(/^node\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))\s*$/);
45
73
  if (!match) return false;
@@ -47,7 +75,32 @@ export function isSynaBunHookCommand(command, scriptName) {
47
75
  return script === `hooks/claude-code/${scriptName}` || script.endsWith(`/hooks/claude-code/${scriptName}`);
48
76
  }
49
77
 
50
- export function ensureHookRoot(settings, targetProjectPath, packageRoot = PACKAGE_ROOT) {
78
+ /**
79
+ * Why this run must not write hook commands, or null when it may. A packaged
80
+ * application with no path that outlives the run (macOS started it from a
81
+ * temporary copy) would leave every handler pointing at nothing.
82
+ */
83
+ export function hookInstallBlocker(runtime = packagedRuntime()) {
84
+ if (!runtime || durableEntry(runtime)) return null;
85
+ return 'SynaBun is running from a temporary location, so its hooks would stop working when it closes. Move SynaBun to its permanent place (Applications on macOS), open it again, then turn the hooks on.';
86
+ }
87
+
88
+ /**
89
+ * What a request to enable hooks may do in this run: 'write' them, 'refuse'
90
+ * it, or 'register-only'. Adding a project is more than its hooks, so a run
91
+ * that cannot write them still registers a new project; a request that asks
92
+ * for nothing but hooks (all of them globally, one event, or a project that
93
+ * is already registered) is refused with the blocker's sentence.
94
+ */
95
+ export function hookInstallPlan({ target, registered = false, hook } = {}, runtime = packagedRuntime()) {
96
+ if (!hookInstallBlocker(runtime)) return 'write';
97
+ return target === 'project' && !registered && !hook ? 'register-only' : 'refuse';
98
+ }
99
+
100
+ export function ensureHookRoot(settings, targetProjectPath, packageRoot = PACKAGE_ROOT, runtime = packagedRuntime()) {
101
+ // A packaged application's command finds its own handlers, and its package
102
+ // root can be a mount that ends with the run.
103
+ if (runtime) return false;
51
104
  // The checked-in template must not acquire a machine-specific env path.
52
105
  if (targetProjectPath && resolve(targetProjectPath) === resolve(packageRoot)) return false;
53
106
  if (settings.env?.SYNABUN_HOOK_ROOT === resolve(packageRoot)) return false;
@@ -55,14 +108,14 @@ export function ensureHookRoot(settings, targetProjectPath, packageRoot = PACKAG
55
108
  return true;
56
109
  }
57
110
 
58
- export function sweepSettingsHooks(settings, targetProjectPath, stats = {}, packageRoot = PACKAGE_ROOT) {
59
- if (!settings?.hooks) return false;
111
+ export function sweepSettingsHooks(settings, targetProjectPath, stats = {}, packageRoot = PACKAGE_ROOT, runtime = packagedRuntime()) {
112
+ if (!settings?.hooks || hookInstallBlocker(runtime)) return false;
60
113
  let changed = false;
61
114
  let ownsHooks = false;
62
115
  for (const def of HOOK_SCRIPTS) {
63
116
  const entries = settings.hooks[def.event];
64
117
  if (!Array.isArray(entries)) continue;
65
- const command = hookCommandString(def.script);
118
+ const command = hookCommandString(def.script, targetProjectPath, runtime);
66
119
  const matches = entries.flatMap(e => (e.hooks || []).filter(h => isSynaBunHookCommand(h.command, def.script)).map(h => ({ entry: e, hook: h })));
67
120
  if (!matches.length) continue;
68
121
  ownsHooks = true;
@@ -79,6 +132,6 @@ export function sweepSettingsHooks(settings, targetProjectPath, stats = {}, pack
79
132
  settings.hooks[def.event] = [...rest, { matcher: def.matcher || '', hooks: [{ type: 'command', command, timeout: def.timeout }] }];
80
133
  changed = true;
81
134
  }
82
- if (ownsHooks && ensureHookRoot(settings, targetProjectPath, packageRoot)) changed = true;
135
+ if (ownsHooks && ensureHookRoot(settings, targetProjectPath, packageRoot, runtime)) changed = true;
83
136
  return changed;
84
137
  }
@@ -22,6 +22,7 @@ import {
22
22
  inspectCodexConfig,
23
23
  upsertCodexMcpConfig,
24
24
  } from '../mcp-server/dist/services/codex-config-heal.js';
25
+ import { mcpStdioCommand } from './packaged-runtime.js';
25
26
 
26
27
  function readJson(path) {
27
28
  try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
@@ -41,8 +42,8 @@ function jsonChanged(before, after) {
41
42
  export function buildCanonicalMcpDefinition({ dataHome, packageRoot }) {
42
43
  const canonicalDataHome = resolve(dataHome);
43
44
  return {
44
- command: 'node',
45
- args: [resolve(packageRoot, 'mcp-server', 'dist', 'preload.js').replace(/\\/g, '/')],
45
+ // `node <preload.js>`, or the entry executable of a packaged application.
46
+ ...mcpStdioCommand(resolve(packageRoot, 'mcp-server', 'dist', 'preload.js').replace(/\\/g, '/'), { packageRoot }),
46
47
  env: {
47
48
  DOTENV_PATH: resolve(canonicalDataHome, '.env').replace(/\\/g, '/'),
48
49
  SYNABUN_DATA_HOME: canonicalDataHome,
@@ -126,7 +127,7 @@ export function auditAndRepairClientConfigs({
126
127
  changed = before !== readFileSync(codexPath, 'utf-8');
127
128
  } else {
128
129
  const content = readFileSync(codexPath, 'utf-8');
129
- changed = !codexDefinition.args.every(value => content.includes(value))
130
+ changed = ![codexDefinition.command, ...codexDefinition.args].every(value => content.includes(value))
130
131
  || !Object.values(codexDefinition.env).every(value => content.includes(value));
131
132
  }
132
133
  results.codex = { exists: true, connected: true, changed, path: codexPath, health: codexHealth };
@@ -32,6 +32,10 @@ const MIGRATION_RUNTIME_ONLY_FILES = new Set([
32
32
  'data/active-profile.json',
33
33
  'mcp-data/active-profile.json',
34
34
  'mcp-data/tool-usage.json',
35
+ // The Start Server launcher and the supervisor write these on every start.
36
+ 'data/supervisor.json',
37
+ 'data/start-launcher.lock',
38
+ 'data/launcher.log',
35
39
  ]);
36
40
 
37
41
  function normalize(path) {
@@ -0,0 +1,206 @@
1
+ /** Browser-owned PWA shortcuts only. No browser databases and no --app= window. */
2
+ import { createHash } from 'node:crypto';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, join, sep } from 'node:path';
7
+
8
+ export function pwaAppId(url) {
9
+ return createHash('sha256').update(url).digest('hex').slice(0, 32)
10
+ .replace(/[0-9a-f]/g, c => String.fromCharCode(97 + parseInt(c, 16)));
11
+ }
12
+
13
+ export function pwaOrigins(port) {
14
+ return [`http://localhost:${port}/`, `http://127.0.0.1:${port}/`, `http://[::1]:${port}/`];
15
+ }
16
+
17
+ export function matchesPwaUrl(value, port, installRoute = false) {
18
+ try {
19
+ const url = new URL(value);
20
+ return !url.username && !url.password && url.protocol === 'http:'
21
+ && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
22
+ && Number(url.port || 80) === Number(port)
23
+ && (url.pathname === '/' || (installRoute && url.pathname === '/install-app.html')) && !url.search && !url.hash;
24
+ } catch { return false; }
25
+ }
26
+
27
+ /** Conservative argument reader for browser-generated shortcuts, never a shell. */
28
+ export function shortcutArgs(text) {
29
+ const args = [];
30
+ let token = '', quoted = false, active = false;
31
+ for (const c of String(text).trim()) {
32
+ if (c === '"') { quoted = !quoted; active = true; }
33
+ else if (/\s/.test(c) && !quoted) {
34
+ if (active) args.push(token);
35
+ token = ''; active = false;
36
+ } else { token += c; active = true; }
37
+ }
38
+ if (quoted) return [];
39
+ if (active) args.push(token);
40
+ return args;
41
+ }
42
+
43
+ /** Rebuild the launch arguments from an installed app identity; drop nothing silently. */
44
+ export function chromiumShortcut(args, port) {
45
+ let id = null, profile = null;
46
+ for (const arg of args) {
47
+ if (/^--app-id=[a-p]{32}$/.test(arg) && !id) id = arg.slice(9);
48
+ else if (/^--profile-directory=[\w -]{1,80}$/.test(arg) && !profile) profile = arg.slice(20);
49
+ else if (/^--app-launch-source=\d+$/.test(arg)) continue;
50
+ else return null; // Includes --app=URL, shell syntax and additional browser switches.
51
+ }
52
+ if (!id || !pwaOrigins(port).some(url => pwaAppId(url) === id)) return null;
53
+ return [`--app-id=${id}`, ...(profile ? [`--profile-directory=${profile}`] : [])];
54
+ }
55
+
56
+ function list(folder) {
57
+ try { return readdirSync(folder).sort(); } catch { return []; }
58
+ }
59
+ function inside(file, folder) {
60
+ try { return realpathSync(file).startsWith(realpathSync(folder) + sep); } catch { return false; }
61
+ }
62
+ function smallFile(file) {
63
+ try { return statSync(file).size <= 1024 * 1024; } catch { return false; }
64
+ }
65
+ function appName(name) { return /^synabun(?:\.app|\.lnk)?$/i.test(String(name || '')); }
66
+
67
+ function plistUrls(value) {
68
+ if (!value || typeof value !== 'object') return [];
69
+ return Object.entries(value).flatMap(([key, item]) =>
70
+ ['CrAppModeShortcutURL', 'LSWebApplicationURL', 'WebAppURL', 'WebsiteURL', 'URL', 'start_url', 'manifestId'].includes(key)
71
+ && typeof item === 'string' ? [item] : plistUrls(item));
72
+ }
73
+
74
+ export function macPwa(plist, file, port) {
75
+ if (!appName(plist.CFBundleDisplayName || plist.CFBundleName || basename(file))) return null;
76
+ const bundleId = String(plist.CFBundleIdentifier || '');
77
+ const safari = /^com\.apple\.Safari\.WebApp\./.test(bundleId);
78
+ const chrome = /^(com\.google\.Chrome|com\.microsoft\.edgemac)\.app\./.test(bundleId);
79
+ if (!(safari || chrome) || !plistUrls(plist).some(url => matchesPwaUrl(url, port, safari))) return null;
80
+ if (chrome && !/^[a-p]{32}$/.test(plist.CrAppModeShortcutID || bundleId.split('.').pop())) return null;
81
+ return { kind: safari ? 'Safari web app' : 'Chromium installed PWA', file: '/usr/bin/open', args: ['-a', file] };
82
+ }
83
+
84
+ function knownBrowsers(platform, env, home) {
85
+ if (platform === 'win32') {
86
+ return [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.ProgramFiles, env['ProgramFiles(x86)'], env.LOCALAPPDATA]
87
+ .filter(Boolean).flatMap(root => ['Google/Chrome/Application/chrome', 'Microsoft/Edge/Application/msedge']
88
+ .flatMap(tail => [join(root, `${tail}.exe`), join(root, `${tail}_proxy.exe`)]));
89
+ }
90
+ return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/google-chrome',
91
+ '/opt/google/chrome/chrome', '/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable',
92
+ '/opt/microsoft/msedge/msedge'];
93
+ }
94
+
95
+ export function browserPwa({ name, target, args }, port, allowed) {
96
+ if (!appName(name) || !allowed.includes(target)) return null;
97
+ const safe = chromiumShortcut(Array.isArray(args) ? args : shortcutArgs(args), port);
98
+ return safe ? { kind: 'Chromium installed PWA', file: target, args: safe } : null;
99
+ }
100
+
101
+ export function linuxPwa(text, port, allowed) {
102
+ const section = String(text).split('[Desktop Entry]')[1]?.split(/\n\[/)[0];
103
+ if (!section) return null;
104
+ const values = Object.fromEntries(section.split(/\r?\n/).map(line => line.match(/^([\w-]+)=(.*)$/))
105
+ .filter(Boolean).map(match => [match[1], match[2]]));
106
+ if (values.Type !== 'Application' || values.Hidden === 'true' || values.Terminal === 'true') return null;
107
+ // Desktop-entry strings escape backslashes once before the Exec quoting layer.
108
+ const [target, ...args] = shortcutArgs(String(values.Exec || '').replace(/\\\\/g, '\\'));
109
+ return browserPwa({ name: values.Name, target, args }, port, allowed);
110
+ }
111
+
112
+ /** Scan only app bundles or browser-generated application shortcuts bearing SynaBun's name. */
113
+ export function findInstalledPwa({ port, platform = process.platform, home = homedir(), env = process.env,
114
+ run = spawnSync } = {}) {
115
+ if (platform === 'darwin') {
116
+ const roots = [join(home, 'Applications'), join(home, 'Applications', 'Chrome Apps.localized'),
117
+ join(home, 'Applications', 'Edge Apps.localized'), '/Applications/Chrome Apps.localized', '/Applications/Edge Apps.localized'];
118
+ for (const root of roots) for (const name of list(root).filter(name => /^synabun\.app$/i.test(name))) {
119
+ const file = join(root, name), info = join(file, 'Contents', 'Info.plist');
120
+ if (!inside(file, root) || !smallFile(info)) continue;
121
+ const result = run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', info], { encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024 });
122
+ try { const app = macPwa(JSON.parse(result.stdout), file, port); if (app) return app; } catch {}
123
+ }
124
+ return null;
125
+ }
126
+ const allowed = knownBrowsers(platform, env, home).filter(existsSync);
127
+ if (platform === 'linux') {
128
+ const roots = [join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'applications'), '/usr/share/applications'];
129
+ for (const root of roots) for (const name of list(root).filter(name => /^(?:chrome|msedge)-[a-p]{32}-.+\.desktop$/.test(name))) {
130
+ // Only the app IDs for this exact loopback origin, before reading any entry.
131
+ if (!pwaOrigins(port).some(url => name.includes(pwaAppId(url)))) continue;
132
+ const file = join(root, name);
133
+ if (!inside(file, root) || !smallFile(file)) continue;
134
+ try { const app = linuxPwa(readFileSync(file, 'utf8'), port, allowed); if (app) return app; } catch {}
135
+ }
136
+ return null;
137
+ }
138
+ if (platform === 'win32') {
139
+ // WScript reads only SynaBun.lnk in these application folders. It never executes a link.
140
+ const roots = [env.APPDATA && join(env.APPDATA, 'Microsoft', 'Windows', 'Start Menu', 'Programs'), join(home, 'Desktop')].filter(Boolean);
141
+ const links = roots.flatMap(root => [root, join(root, 'Chrome Apps'), join(root, 'Edge Apps')])
142
+ .flatMap(root => list(root).filter(name => /^synabun\.lnk$/i.test(name)).map(name => join(root, name)))
143
+ .filter(file => smallFile(file));
144
+ if (!links.length) return null;
145
+ const script = "$w=New-Object -ComObject WScript.Shell; $r=@(); foreach($p in (ConvertFrom-Json $env:SYNABUN_PWA_LINKS)) { $s=$w.CreateShortcut($p); $r+=@{name='SynaBun';target=$s.TargetPath;args=$s.Arguments} }; ConvertTo-Json -Compress -InputObject @($r)";
146
+ const result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
147
+ encoding: 'utf8', windowsHide: true, timeout: 10000, maxBuffer: 1024 * 1024,
148
+ env: { ...env, SYNABUN_PWA_LINKS: JSON.stringify(links) },
149
+ });
150
+ try {
151
+ for (const item of JSON.parse(result.stdout.replace(/^\uFEFF/, ''))) {
152
+ // Windows paths are case-insensitive, but must still be a known installed browser.
153
+ const target = allowed.find(path => path.toLowerCase() === String(item.target).toLowerCase());
154
+ const app = target && browserPwa({ ...item, target }, port, allowed);
155
+ if (app) return app;
156
+ }
157
+ } catch {}
158
+ }
159
+ return null;
160
+ }
161
+
162
+ export async function openDesktopPwa({ port, env = process.env, platform = process.platform,
163
+ find = findInstalledPwa, spawnImpl = spawn } = {}) {
164
+ if (env.SYNABUN_OPEN_BROWSER === '0') return { kind: 'suppressed' };
165
+ const installed = find({ port, env, platform });
166
+ const url = `http://localhost:${port}/install-app.html`;
167
+ const fallback = platform === 'win32'
168
+ ? { file: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }
169
+ : platform === 'darwin' ? { file: '/usr/bin/open', args: [url] } : { file: 'xdg-open', args: [url] };
170
+ const launch = choice => new Promise((done, reject) => {
171
+ const child = spawnImpl(choice.file, choice.args, { env, stdio: 'ignore', detached: true, windowsHide: true });
172
+ child.once('error', reject);
173
+ child.once('spawn', () => { child.unref(); done(); });
174
+ });
175
+ if (installed) {
176
+ try { await launch(installed); return { kind: installed.kind }; } catch {}
177
+ }
178
+ await launch(fallback);
179
+ return { kind: 'one-time browser installation', url };
180
+ }
181
+
182
+ /** A re-executed AppImage owns a fresh mount for the entire supervisor lifetime. */
183
+ export function backgroundCommand(runtime, env) {
184
+ if (!runtime?.entry) throw new Error('the installed launcher is missing');
185
+ const next = { ...env, SYNABUN_OPEN_BROWSER: '0', SYNABUN_LAUNCH_ENV: 'resolved' };
186
+ const image = runtime.artifact === 'linux-appdir' && env.APPIMAGE;
187
+ if (image) {
188
+ const mount = String(env.APPDIR || '').replace(/\/+$/, '');
189
+ if (mount && next.PATH) next.PATH = next.PATH.split(':').filter(path => path !== mount && !path.startsWith(`${mount}/`)).join(':');
190
+ for (const key of ['APPIMAGE', 'APPDIR', 'ARGV0', 'OWD']) delete next[key];
191
+ }
192
+ // The first image may unmount after handoff. No cwd or PATH of the fresh
193
+ // runtime may depend on it; the launcher has already created the data home.
194
+ return { file: runtime.entry, args: ['background-server'], env: next,
195
+ cwd: image ? (next.SYNABUN_DATA_HOME || dirname(runtime.entry)) : undefined };
196
+ }
197
+
198
+ /** A port occupant prevents duplicates; only SynaBun's healthy API permits a PWA handoff. */
199
+ export async function pwaServerReady(port, { fetchImpl = globalThis.fetch } = {}) {
200
+ try {
201
+ const response = await fetchImpl(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) });
202
+ if (!response.ok) return false;
203
+ const data = await response.json();
204
+ return data.ok === true && data.storage === 'sqlite' && typeof data.projectDir === 'string' && !!data.startLauncher;
205
+ } catch { return false; }
206
+ }