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.
- package/CHANGELOG.md +8 -0
- package/THIRD-PARTY-LICENSES.md +5 -0
- package/launcher.mjs +40 -0
- package/lib/claude-hooks.js +61 -8
- package/lib/client-config-repair.js +4 -3
- package/lib/data-home-migration.js +4 -0
- package/lib/desktop-pwa.js +206 -0
- package/lib/external-tools.js +221 -0
- package/lib/first-launch-protection.js +5 -0
- package/lib/packaged-runtime.js +110 -0
- package/lib/start-launcher.js +1309 -0
- package/neural-interface/i18n/en.json +41 -2
- package/neural-interface/i18n/pt-BR.json +41 -2
- package/neural-interface/lib/assistant-envelope.js +9 -6
- package/neural-interface/lib/augmented-path.js +23 -3
- package/neural-interface/lib/browser-language.js +83 -0
- package/neural-interface/lib/claude-agent-bridge.js +54 -147
- package/neural-interface/lib/claude-executable.js +241 -0
- package/neural-interface/lib/claude-model-catalog.js +52 -91
- package/neural-interface/lib/native-binary-runtime.js +69 -337
- package/neural-interface/lib/native-loop-providers.js +30 -44
- package/neural-interface/lib/offline-page.js +54 -0
- package/neural-interface/lib/session-title-generator.js +5 -0
- package/neural-interface/lib/synabun-update-plan.js +19 -0
- package/neural-interface/package-lock.json +0 -251
- package/neural-interface/public/install-app.html +31 -0
- package/neural-interface/public/manifest.json +2 -0
- package/neural-interface/public/offline.html +401 -66
- package/neural-interface/public/onboarding.html +252 -146
- package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
- package/neural-interface/public/shared/assistant/asst-render.js +58 -49
- package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
- package/neural-interface/public/shared/command-runner-model.js +30 -0
- package/neural-interface/public/shared/html-shell.js +9 -5
- package/neural-interface/public/shared/pwa-install-page.js +34 -0
- package/neural-interface/public/shared/pwa-install.css +12 -0
- package/neural-interface/public/shared/pwa-install.js +28 -0
- package/neural-interface/public/shared/settings/settings-rules.js +160 -0
- package/neural-interface/public/shared/start-bridge.js +583 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
- package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
- package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
- package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
- package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
- package/neural-interface/public/shared/styles.css +801 -392
- package/neural-interface/public/shared/term-ready.js +307 -0
- package/neural-interface/public/shared/ui-claude-panel.js +3 -3
- package/neural-interface/public/shared/ui-command-runner.js +251 -468
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-loading.js +221 -10
- package/neural-interface/public/shared/ui-menubar.js +11 -27
- package/neural-interface/public/shared/ui-settings.js +36 -59
- package/neural-interface/public/shared/ui-terminal.js +130 -144
- package/neural-interface/public/sw.js +3 -1
- package/neural-interface/public/variant/2d/main.js +2 -1
- package/neural-interface/public/variant/3d/main.js +2 -1
- package/neural-interface/scripts/rebuild-pty.js +15 -12
- package/neural-interface/server.js +220 -185
- package/package.json +13 -1
- package/postinstall.js +11 -81
- package/setup.js +72 -4
- package/docs/browser-v2.md +0 -109
- package/docs/hook-integrity.md +0 -89
- package/docs/judgments.md +0 -182
- package/docs/style-guide.md +0 -246
- package/scripts/audit-claude-hook-usage.mjs +0 -12
- package/scripts/audit-memory-duplicates.mjs +0 -44
- package/scripts/measure-claude-hook-overhead.mjs +0 -77
- 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
|
|
package/THIRD-PARTY-LICENSES.md
CHANGED
|
@@ -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
|
+
}
|
package/lib/claude-hooks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
+
}
|