mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] merge origin/eric/dev into eric/mcp (1.5.4 line + Sonnet 5 under the social MCPs)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+44
-46
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 <lang>.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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user