diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 0d593468..5279c1ea 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -42,6 +42,9 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { # Opus 4.7: SDK currently strips plaintext thinking deltas (encrypted only) so the live "Thought for Ns" pill loses mid-turn text. Final answer + tokens fine. {"value": "opus-4-7", "label": "Claude Opus 4.7", "context_window": 1_000_000, "model_id": "claude-opus-4-7", "router_model_id": "cc/claude-opus-4-7", "api": "anthropic", "reasoning": True}, + # Sonnet 5 (2026-06-30): cheaper near-Opus-4.8 agentic model. cc/ route assumed to pass through like opus-4-8 did; needs a live sub-route check. + {"value": "sonnet-5", "label": "Claude Sonnet 5", "context_window": 1_000_000, + "model_id": "claude-sonnet-5", "router_model_id": "cc/claude-sonnet-5", "api": "anthropic", "reasoning": True}, {"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True}, {"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000, @@ -53,6 +56,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { "model_id": "claude-opus-4-8", "router_model_id": "cc/claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "cc"}, {"value": "opus-4-7-cc", "label": "Claude Opus 4.7", "context_window": 1_000_000, "model_id": "claude-opus-4-7", "router_model_id": "cc/claude-opus-4-7", "api": "anthropic", "reasoning": True, "route": "cc"}, + {"value": "sonnet-5-cc", "label": "Claude Sonnet 5", "context_window": 1_000_000, + "model_id": "claude-sonnet-5", "router_model_id": "cc/claude-sonnet-5", "api": "anthropic", "reasoning": True, "route": "cc"}, {"value": "sonnet-cc", "label": "Claude Sonnet 4.6", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "cc"}, {"value": "opus-cc", "label": "Claude Opus 4.6", "context_window": 1_000_000, @@ -65,6 +70,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { "model_id": "claude-opus-4-8", "router_model_id": "claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "api"}, {"value": "opus-4-7-api", "label": "Claude Opus 4.7 (API key)", "context_window": 1_000_000, "model_id": "claude-opus-4-7", "router_model_id": "claude-opus-4-7", "api": "anthropic", "reasoning": True, "route": "api"}, + {"value": "sonnet-5-api", "label": "Claude Sonnet 5 (API key)", "context_window": 1_000_000, + "model_id": "claude-sonnet-5", "router_model_id": "claude-sonnet-5", "api": "anthropic", "reasoning": True, "route": "api"}, {"value": "sonnet-api", "label": "Claude Sonnet 4.6 (API key)", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "router_model_id": "claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "api"}, {"value": "opus-api", "label": "Claude Opus 4.6 (API key)", "context_window": 1_000_000, @@ -360,6 +367,7 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None = COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = { # (provider, model): (input_cost_per_1M, output_cost_per_1M) NOTE: real cost numbers come from 9Router's usage stats. These entries are kept so the table matches BUILTIN_MODELS and can be used by any future native-loop path. Subscription-routed models are zero-cost to the user, but API rates are recorded here for reference where they exist. Anthropic (direct API rates). ("Anthropic", "sonnet"): (3.0, 15.0), + ("Anthropic", "sonnet-5"): (3.0, 15.0), ("Anthropic", "opus"): (5.0, 25.0), ("Anthropic", "opus-4-7"): (5.0, 25.0), ("Anthropic", "opus-4-8"): (5.0, 25.0), diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py index d1731dae..5822b64b 100644 --- a/backend/apps/skill_registry/skill_registry.py +++ b/backend/apps/skill_registry/skill_registry.py @@ -165,21 +165,42 @@ class p_CuratedInstallRequest(BaseModel): folder: str +def p_cached_curated_fallback(folder: str) -> Optional[dict]: + """Offline/rate-limited curated-install fallback: rebuild a single-SKILL.md install + payload from the warmed catalog (it already holds the SKILL.md body) so a curated + install still works when GitHub is unreachable, minus the folder's extra files. + Empty version means it's skipped by update checks until re-installed online.""" + cached = next((s for s in p_cache.values() if s.get("folder") == folder), None) + if cached is None: + return None + name, description, body = cached.get("name", ""), cached.get("description", ""), cached.get("content", "") + return { + "skill_id": folder.rsplit("/", 1)[-1], "name": name, "description": description, + "files": {"SKILL.md": f"---\nname: {name}\ndescription: {description}\n---\n\n{body}"}, + "scripts": [], "source": sources.REPO, "folder": folder, "version": "", + } + + @skill_registry.router.post("/install-curated") async def registry_install_curated(req: p_CuratedInstallRequest): """Install a curated (anthropics/skills) skill with its FULL folder, not just SKILL.md, so scripts/assets land too (the old path wrote only SKILL.md, which left multi-file skills like pdf/docx with dead script references). Curated is the vetted source, so this is one-click; files are still written inert, never - executed. Needs network at install time (the catalog only caches SKILL.md).""" + executed. When GitHub is unreachable (offline / rate-limited) it falls back to the + catalog's cached SKILL.md, so the install still works (single file, no folder + extras), restoring the old offline behavior.""" try: resolved = await sources.resolve_curated_skill(req.folder) - except RegistryRateLimited: - raise HTTPException(status_code=429, detail="GitHub rate limit hit fetching this skill; try again in a few minutes.") except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: - raise HTTPException(status_code=502, detail=f"could not fetch skill: {e}") + resolved = p_cached_curated_fallback(req.folder) + if resolved is None: + if isinstance(e, RegistryRateLimited): + raise HTTPException(status_code=429, detail="GitHub rate limit hit and no cached copy of this skill; try again in a few minutes.") + raise HTTPException(status_code=502, detail=f"GitHub unreachable and no cached copy: {e}") + logger.info(f"curated install: GitHub unreachable ({type(e).__name__}); installing '{req.folder}' from cached SKILL.md (single file, no folder extras)") from backend.apps.skills.skills import write_folder_skill, unique_skill_slug slug = unique_skill_slug(resolved["skill_id"]) diff --git a/backend/config/json_store.py b/backend/config/json_store.py index a5025bd5..e8c18163 100644 --- a/backend/config/json_store.py +++ b/backend/config/json_store.py @@ -32,10 +32,19 @@ def atomic_write_json(path: str, payload, *, indent: int = 2) -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(payload, f, indent=indent) + # os.replace is atomic for the filename, but the file's data + # may still be sitting in the page cache when the rename + # commits. A power loss between rename and the kernel's next + # writeback can leave a zero-length or torn file even though + # the rename "succeeded". fsync before the rename is what + # actually makes this crash-safe. + f.flush() + os.fsync(f.fileno()) # Windows: Defender can briefly hold the destination open; a couple of retries covers every real case. for attempt in range(3): try: os.replace(tmp, path) + p_fsync_dir(directory) return except PermissionError: if attempt == 2: @@ -49,6 +58,24 @@ def atomic_write_json(path: str, payload, *, indent: int = 2) -> None: raise +def p_fsync_dir(directory: str) -> None: + # Best-effort fsync of the parent dir so the rename itself sticks + # across a crash. POSIX needs this (ext4/xfs/btrfs); Windows doesn't + # let you open a directory for fsync, so we just skip there. Failing + # here is non-fatal: the file content is already fsync'd above and + # the rename has happened in memory. + try: + dir_fd = os.open(directory, os.O_RDONLY) + except OSError: + return + try: + os.fsync(dir_fd) + except OSError: + pass + finally: + os.close(dir_fd) + + def read_json_or_none(path: str) -> dict | None: """Parse `path`; return None (and log) on a missing/garbled file rather than raising. Schema validation is the caller's job, kept separate so a real diff --git a/backend/tests/test_disk_resilience.py b/backend/tests/test_disk_resilience.py index 4d7956af..e94c2b02 100644 --- a/backend/tests/test_disk_resilience.py +++ b/backend/tests/test_disk_resilience.py @@ -49,6 +49,62 @@ def test_atomic_write_preserves_existing_when_new_write_fails(tmp_path): assert read_json_or_none(p) == {"good": 1} +def test_atomic_write_fsyncs_file_before_rename(tmp_path, monkeypatch): + # Without fsync before os.replace, we only get filename-atomicity, not + # data durability. Catch the regression by counting fsync calls before + # the rename happens. + from backend.config import json_store + + fsync_count_at_replace = [] + fsync_calls = [] + real_fsync = os.fsync + real_replace = os.replace + + def tracking_fsync(fd): + fsync_calls.append(fd) + return real_fsync(fd) + + def tracking_replace(src, dst): + fsync_count_at_replace.append(len(fsync_calls)) + return real_replace(src, dst) + + monkeypatch.setattr(json_store.os, "fsync", tracking_fsync) + monkeypatch.setattr(json_store.os, "replace", tracking_replace) + atomic_write_json(str(tmp_path / "x.json"), {"k": "v"}) + + assert fsync_count_at_replace == [1], ( + f"expected fsync on the tempfile before os.replace, saw " + f"{fsync_count_at_replace[0] if fsync_count_at_replace else 0} fsync calls" + ) + + +def test_atomic_write_fsyncs_directory_after_rename(tmp_path, monkeypatch): + # POSIX only: the rename is only durable once the parent dir is fsync'd. + if not hasattr(os, "O_RDONLY"): + pytest.skip("directory fsync not applicable on this platform") + from backend.config import json_store + + fsync_targets = [] + real_fsync = os.fsync + + def tracking_fsync(fd): + try: + st = os.fstat(fd) + fsync_targets.append("dir" if (st.st_mode & 0o170000) == 0o040000 else "file") + except OSError: + fsync_targets.append("unknown") + return real_fsync(fd) + + monkeypatch.setattr(json_store.os, "fsync", tracking_fsync) + atomic_write_json(str(tmp_path / "x.json"), {"k": "v"}) + + assert "file" in fsync_targets, "expected fsync on the data file" + assert "dir" in fsync_targets, "expected fsync on the parent directory" + + +# ---------------- read_json_or_none ---------------- + + # ---------------- read_json_or_none ---------------- def test_read_missing_returns_none(tmp_path): diff --git a/backend/tests/test_settings_migration.py b/backend/tests/test_settings_migration.py index 80499af1..53d2e36b 100644 --- a/backend/tests/test_settings_migration.py +++ b/backend/tests/test_settings_migration.py @@ -55,7 +55,7 @@ def test_no_file_returns_defaults(settings_file): s = store.load_settings() assert isinstance(s, AppSettings) assert s.default_system_prompt == DEFAULT_SYSTEM_PROMPT - assert s.theme == "dark" + assert s.theme == "light" def test_minimal_old_file_fills_missing_with_defaults(settings_file): @@ -130,7 +130,7 @@ def test_corrupt_json_returns_defaults_and_preserves_file(settings_file): with open(settings_file, "w", encoding="utf-8") as fh: fh.write("{ this is : not json ,,, ") s = store.load_settings() - assert s.theme == "dark" # defaults + assert s.theme == "light" # defaults # Original is moved aside (recoverable), not silently destroyed. assert os.path.exists(settings_file + ".corrupt") assert not os.path.exists(settings_file) @@ -139,7 +139,7 @@ def test_corrupt_json_returns_defaults_and_preserves_file(settings_file): def test_non_dict_top_level_returns_defaults(settings_file): p_write(settings_file, ["not", "an", "object"]) s = store.load_settings() - assert s.theme == "dark" + assert s.theme == "light" assert os.path.exists(settings_file + ".corrupt") diff --git a/backend/tests/test_skill_registry_community.py b/backend/tests/test_skill_registry_community.py index 118ace42..b52b56be 100644 --- a/backend/tests/test_skill_registry_community.py +++ b/backend/tests/test_skill_registry_community.py @@ -350,6 +350,55 @@ def test_curated_install_writes_full_folder(skills_dir, monkeypatch): assert slug in listed and listed[slug]["has_supporting_files"] is True +def test_curated_install_falls_back_to_cached_skill_md_when_offline(skills_dir, monkeypatch): + """Regression: when GitHub is unreachable (rate-limited/offline), /install-curated + falls back to the catalog's cached SKILL.md so the install still works (single file, + no folder extras) instead of erroring, restoring the old offline behavior.""" + import secrets as p_secrets + from fastapi.testclient import TestClient + from backend.main import app + import backend.auth as auth_mod + import backend.apps.skill_registry.skill_registry as sr_routes + from backend.apps.skill_registry.skill_registry_github import RegistryRateLimited + if not auth_mod.TOKEN: + auth_mod.TOKEN = p_secrets.token_urlsafe(32) + client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"}) + + async def boom(folder): + raise RegistryRateLimited() + monkeypatch.setattr("backend.apps.skill_registry.skill_registry_sources.resolve_curated_skill", boom) + monkeypatch.setattr(sr_routes, "p_cache", {"PDF": { + "name": "PDF", "description": "work with pdfs", "content": "Do PDF things.", "folder": "skills/pdf", + }}) + + r = client.post("/api/skill-registry/install-curated", json={"folder": "skills/pdf"}) + assert r.status_code == 200 and r.json()["installed"] is True + assert r.json()["files"] == ["SKILL.md"] and r.json()["scripts"] == [] + slug = r.json()["skill"]["id"] + md = (skills_dir / slug / "SKILL.md").read_text() + assert "Do PDF things." in md and "name: PDF" in md + + +def test_curated_install_no_cache_no_network_errors_honestly(skills_dir, monkeypatch): + """If GitHub is unreachable AND nothing's cached, surface an honest error, not a + silent half-install.""" + import secrets as p_secrets + from fastapi.testclient import TestClient + from backend.main import app + import backend.auth as auth_mod + import backend.apps.skill_registry.skill_registry as sr_routes + if not auth_mod.TOKEN: + auth_mod.TOKEN = p_secrets.token_urlsafe(32) + client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"}) + + async def boom(folder): + raise RuntimeError("connection refused") + monkeypatch.setattr("backend.apps.skill_registry.skill_registry_sources.resolve_curated_skill", boom) + monkeypatch.setattr(sr_routes, "p_cache", {}) + r = client.post("/api/skill-registry/install-curated", json={"folder": "skills/pdf"}) + assert r.status_code == 502 + + def test_manual_rm_leaves_no_ghost_blocking_slug(skills_dir): """A folder deleted out-of-band (manual rm) must not keep squatting its slug via a leftover index entry: existence is by files on disk, and a prune cleans the index.""" diff --git a/electron/main.js b/electron/main.js index 30c138e0..7b462187 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2444,16 +2444,50 @@ app.on('web-contents-created', (_event, contents) => { })(); `).catch(() => {}); - // Agent bridge (window.OPENSWARM_APP). Injected into EVERY app's main world - // from the shell so it exists regardless of frontend/src; the lightweight - // App Builder mode deletes frontend/src (and with it the template's own - // agentBridge.ts), so this is the only entry point a trimmed app can't lose. - // Idempotent + guarded: a workspace app that imports its own bridge installs - // first and this no-ops, so we never clobber a registered bridge. An app - // becomes agent-operable by calling OPENSWARM_APP.register({rules, controls, - // getState, invoke}); until it does, describe()/getState() report __ready:false - // (the agent then falls back to native keyboard/mouse). Keep this in sync with - // backend/apps/outputs/webapp_template/frontend/src/agentBridge.ts. + const url = contents.getURL(); + if (url.includes('spotify')) { + contents.executeJavaScript(` + (function() { + const origFetch = window.fetch; + window.fetch = async function(...args) { + const resp = await origFetch.apply(this, args); + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; + if (url.includes('widevine-license') && !resp.ok) { + const clone = resp.clone(); + try { + const text = await clone.text(); + console.log('[drm-diag] License response ' + resp.status + ': ' + text.substring(0, 500)); + } catch(e) {} + } + return resp; + }; + + // Check EME availability + if (navigator.requestMediaKeySystemAccess) { + navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{ + initDataTypes: ['cenc'], + audioCapabilities: [{contentType: 'audio/mp4; codecs="mp4a.40.2"'}], + }]).then(function(access) { + console.log('[drm-diag] Widevine EME access: ' + access.keySystem); + }).catch(function(err) { + console.log('[drm-diag] Widevine EME FAILED: ' + err.message); + }); + } else { + console.log('[drm-diag] EME API not available'); + } + })(); + `).catch(() => {}); + } + }); + + // Agent bridge (window.OPENSWARM_APP): app webviews ONLY, all platforms. Gated + // off the browser partition so a normal browser card / agent web automation + // never carries this global, since a unique window.OPENSWARM_APP is a one-line + // bot tell on the open web. Shell-injected so a trimmed App-Builder app (its + // frontend/src + the template's agentBridge.ts deleted) still gets a bridge; + // idempotent, so a full app that self-installs its own bridge no-ops here. Keep + // in sync with backend/apps/outputs/webapp_template/frontend/src/agentBridge.ts. + if (contents.session !== session.fromPartition(BROWSER_PARTITION)) contents.on('dom-ready', () => { contents.executeJavaScript(` (function() { if (window.OPENSWARM_APP) return; @@ -2521,44 +2555,8 @@ app.on('web-contents-created', (_event, contents) => { }, }; window.OPENSWARM_APP = bridge; - try { console.warn('[openswarm:bridge] shell-injected window.OPENSWARM_APP at', location.href); } catch (_) {} })(); `).catch(() => {}); - - const url = contents.getURL(); - if (url.includes('spotify')) { - contents.executeJavaScript(` - (function() { - const origFetch = window.fetch; - window.fetch = async function(...args) { - const resp = await origFetch.apply(this, args); - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; - if (url.includes('widevine-license') && !resp.ok) { - const clone = resp.clone(); - try { - const text = await clone.text(); - console.log('[drm-diag] License response ' + resp.status + ': ' + text.substring(0, 500)); - } catch(e) {} - } - return resp; - }; - - // Check EME availability - if (navigator.requestMediaKeySystemAccess) { - navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{ - initDataTypes: ['cenc'], - audioCapabilities: [{contentType: 'audio/mp4; codecs="mp4a.40.2"'}], - }]).then(function(access) { - console.log('[drm-diag] Widevine EME access: ' + access.keySystem); - }).catch(function(err) { - console.log('[drm-diag] Widevine EME FAILED: ' + err.message); - }); - } else { - console.log('[drm-diag] EME API not available'); - } - })(); - `).catch(() => {}); - } }); } }); diff --git a/electron/package-lock.json b/electron/package-lock.json index 493bfc3b..bef06b63 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.5.3", + "version": "1.5.4", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", diff --git a/electron/package.json b/electron/package.json index d40da8f1..3056c855 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.5.3", + "version": "1.5.4", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", @@ -33,6 +33,7 @@ "productName": "OpenSwarm", "afterPack": "./build/after-pack.js", "electronLanguages": [ + "en", "en-US" ], "electronDownload": { diff --git a/scripts/ci/verify-locale-paks.js b/scripts/ci/verify-locale-paks.js index 83bfbb49..ae7865d7 100644 --- a/scripts/ci/verify-locale-paks.js +++ b/scripts/ci/verify-locale-paks.js @@ -1,12 +1,17 @@ #!/usr/bin/env node -// Guards the empty-locale renderer crash. A packaged build MUST ship Chromium's -// locale .pak files (locales/en-US.pak + the full ~50). If they are missing, -// Electron launches the renderer with an EMPTY --lang, and Blink's -// LCIDFromLocaleInternal (third_party/blink/.../text/locale_win.cc) null-derefs -// (STATUS_ACCESS_VIOLATION 0xC0000005, read of 0x8) the instant a text/agent/ -// webview surface mounts - a hard crash on the first real interaction, with no -// JS error to localize it. electron-builder has been observed to drop these on a -// --dir repack, so we assert them explicitly rather than trust the packager. +// Guards the empty-locale crashes on BOTH platforms. A packaged build MUST ship +// Chromium's locale .pak files. +// Windows: missing locales/en-US.pak launches the renderer with an EMPTY --lang +// and Blink's LCIDFromLocaleInternal null-derefs (STATUS_ACCESS_VIOLATION +// 0xC0000005) the instant a text/agent/webview surface mounts. +// macOS: missing en.lproj/locale.pak makes EVERY l10n_util string come back +// empty; the WebAuthn Touch ID path then passes that empty string as +// localizedReason to -[LAContext evaluateAccessControl:...], which raises an +// uncaught NSException and kills the whole app on any passkey prompt (the +// 1.5.3 "OS suicide"). electronLanguages naming is per-platform ("en" for mac +// .lproj dirs, "en-US" for win .pak files) and electron-builder silently +// deletes EVERYTHING when the name doesn't match, so we assert the artifact +// explicitly rather than trust the packager. 'use strict'; const fs = require('fs'); @@ -41,22 +46,25 @@ function checkWinLinux(exe) { } function checkMac(exe) { - // mac stores locale paks inside the Electron Framework; layout varies by version, - // and this crash is Windows-specific, so be informational rather than blocking. + // mac stores locale paks as .lproj/locale.pak inside the Electron + // Framework; chrome_100_percent/resources.pak do NOT count (they survive the + // language strip that causes the crash), so require a real locale.pak. const appRoot = exe.slice(0, exe.indexOf('.app') + 4); const found = []; (function walk(d, depth) { - if (depth > 6) return; + if (depth > 8) return; let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of ents) { const full = path.join(d, e.name); if (e.isDirectory()) walk(full, depth + 1); - else if (e.isFile() && e.name.toLowerCase().endsWith('.pak')) found.push(full); + else if (e.isFile() && e.name.toLowerCase() === 'locale.pak') found.push(full); } })(appRoot, 0); - process.stdout.write(` mac: found ${found.length} .pak file(s) under the app bundle\n`); - return { ok: found.length > 0, msg: `${found.length} paks (mac is informational)` , soft: true }; + const hasEn = found.some((p) => path.basename(path.dirname(p)).toLowerCase() === 'en.lproj'); + process.stdout.write(` mac: ${found.length} locale.pak file(s), en.lproj=${hasEn}\n`); + if (!hasEn) return { ok: false, msg: `en.lproj/locale.pak missing (${found.length} locale paks under the bundle)` }; + return { ok: true, msg: `${found.length} locale paks incl en.lproj` }; } function main() { @@ -64,13 +72,14 @@ function main() { const exe = h.packagedAppPath(args.app); const res = process.platform === 'darwin' ? checkMac(exe) : checkWinLinux(exe); if (res.ok) { process.stdout.write(`PASS locale paks present (${res.msg})\n`); process.exit(0); } - if (res.soft) { process.stdout.write(`WARN ${res.msg} - could not confirm mac paks; not blocking\n`); process.exit(0); } process.stderr.write( `FAIL packaged build is MISSING Chromium locale paks: ${res.msg}\n` + - ` The renderer would launch with an empty --lang and crash in Blink's\n` + + ` Windows: renderer launches with an empty --lang and crashes in Blink's\n` + ` LCIDFromLocaleInternal (0xC0000005) on the first text/agent/webview mount.\n` + - ` Fix: ensure electron-builder copies node_modules/electron/dist/locales/*.pak\n` + - ` into the packaged output (this regressed on --dir repacks of the v42 build).\n`); + ` macOS: every l10n string is empty and the WebAuthn Touch ID prompt kills\n` + + ` the whole app with an uncaught NSException (empty localizedReason).\n` + + ` Fix: electronLanguages must list BOTH "en" (mac .lproj) and "en-US"\n` + + ` (win locales/*.pak); a name miss makes electron-builder delete them all.\n`); process.exit(1); }