-
Notifications
You must be signed in to change notification settings - Fork 3
Analyzer
The analyzer is viaduct-cli's compatibility engine. Before (or instead of) converting a Chrome extension, it scans the manifest and every JS/CSS/HTML file for things Safari does differently, and produces a list of typed issues: an error/warning/info verdict, a human report (CONVERSION_REPORT.md), and a machine-readable JSON payload. It runs automatically inside every conversion and standalone via --analyze.
It lives in two functions:
| Function | File | Scope |
|---|---|---|
analyzeManifest(m) |
src/manifest/manifest.ts |
The parsed manifest.json, permissions, CSP, version, commands, content-script config. Returns { issues, permissionsToRemove }. |
scanExtension(extPath, manifest, platforms) |
src/analyze/analyze.ts |
The files on disk, walks the package once and greps source for unsupported chrome.* calls, hardcoded URLs, icons, _locales, etc. Returns Issue[]. |
--analyze (in cli.ts → analyzeOnly) runs both and concatenates: [...mIssues, ...scanExtension(...)].
See Manifest Transform for the boundary: the analyzer only reports; transformManifest is what actually rewrites the manifest. --analyze previews those rewrites (via summarizeManifestChanges) without writing anything.
Every finding is an Issue (src/types.ts):
type Severity = "error" | "warning" | "info";
interface Issue {
severity: Severity;
category: string; // "api" | "manifest" | "permission" | "icons" | "i18n" | "ui" | "background" | ...
message: string; // what's wrong, in author-facing terms
file?: string; // "manifest.json" or a source path relative to the extension root
line?: number; // 1-based, for source hits
fix?: string; // the remediation shown after the message
autoFixed?: boolean; // the converter handles this itself → non-blocking
shimmed?: boolean; // Safari rejects it, but the runtime shim emulates it → still works
}The three levels are not just cosmetic, error blocks conversion. The gate is a single function used everywhere so the terminal verdict, the Markdown report, and the process exit code can never disagree (src/analyze/report.ts):
export function countBlocking(issues, strict = false): number {
return issues.filter(
(i) => !i.autoFixed && (i.severity === "error" || (strict && i.severity === "warning"))
).length;
}| Level | Meaning | Blocks conversion? |
|---|---|---|
error |
Safari (or the App Store, or the Xcode build) will reject the extension outright. |
Yes: conversion aborts unless --force. An autoFixed error does not block. |
warning |
A feature degrades or misbehaves, but the extension still installs and loads. | No (unless --strict, which promotes warnings to blocking for CI). |
info |
FYI, or something the converter already handled. | No. |
Because error aborts the whole run, mis-classifying a "degrades but still works" case as error doesn't just flag one feature, it kills the entire extension. This is the single most important design constraint in the analyzer, and it's why blocking webRequest is a warning, not an error:
Blocking
webRequestcan't block in Safari, but the listener still registers and fires as an observer, and every other feature of the extension keeps working. Flagging it aserroraborted conversion for essentially every ad-blocker, password manager, and VPN. Downgrading towarninglets the extension install; the network-blocking feature degrades and DNR migration is the real fix. (aa89a20)
Two booleans give the author reassurance instead of alarm, and surface as badges in the report:
-
[auto-fixed]: the manifest rewrite handles it. The finding exists so you know it happened (e.g. an MV2 persistent background set topersistent: false, a droppedkey/update_url, a version clamped to Apple's limits).autoFixedalso removes the issue from the blocking count. -
[shimmed]: Safari rejects the API/permission, but the Runtime Shim emulates it, so the call won't throw. Shown as_(shimmed, handled at runtime)_in Markdown. (30509f8)
The two counts are disjoint: a shimmed issue is never also counted as auto-fixed, and the [shimmed] badge wins when both could apply:
if (i.shimmed) shimmed++;
else if (i.autoFixed) autoFixed++;Both printIssues and the JSON payload carry separate autoFixed / shimmed totals.
The analyzer's checks fall into manifest-level (analyzeManifest) and source-level (scanExtension / scanJsContent). The source scan reads each file exactly once; several "one note is enough" checks (favicon, hardcoded chrome-extension://, chrome://settings) fire at most once per extension.
| Check (real function) | Sev | Safari reason | Remediation | Commit |
|---|---|---|---|---|
Unsupported chrome.* API (scanJsContent, from UNSUPPORTED_APIS) |
per-API (error/warning/info) |
API absent or behaves differently in Safari WebKit. | Per-API fix from compat-data; some are shimmed. |
24424b4, 744822b
|
Hardcoded chrome-extension://<id>/ URL (HARDCODED_EXT_URL_RE, JS/CSS/HTML) |
warning |
Safari assigns a random per-install origin; a baked-in 32-char id never resolves (resource 404s). | Build URLs with chrome.runtime.getURL(path). |
24424b4 |
Non-PNG declared icon (scanExtension icon loop) |
warning |
Safari's toolbar/store pipeline only renders PNG; .svg/.webp/.jpg shows a blank glyph and the App Store rejects it. |
Convert to PNG, update the manifest path. | 24424b4 |
| Missing declared icon (icon loop) | error |
File referenced in icons/default_icon isn't in the package → Safari fails to load the extension. |
Add the file or remove the reference. | 0d9e8e4 |
| Missing manifest-referenced file (content scripts, background, popup, options, devtools, sandbox) | error |
Safari silently drops the missing script/page. | Add the file or remove the reference. | 0d9e8e4 |
content_scripts[].world:"MAIN" (analyzeManifest) |
warning |
Supported only on Safari 18.4+; on older versions the script silently doesn't run and never has chrome.* access. |
Provide an ISOLATED-world fallback or feature-detect. | 0d9e8e4 |
Missing App Store description (analyzeManifest) |
info |
The App Store requires a description for submission; Safari shows it in Settings → Extensions. | Add a short description. |
24424b4 |
Keyboard shortcut: no primary modifier (analyzeCommands) |
warning |
A chord with no Ctrl/Command/Alt (Shift-only counts as none) is silently dropped, the command gets no shortcut. |
Use e.g. Command+Shift+Y. |
24424b4 |
Keyboard shortcut: ChromeOS-only modifier (Search) (analyzeCommands) |
warning |
Safari recognizes Ctrl/Command/Alt/Option/MacCtrl/Shift only; ChromeOS's Search has no equivalent. |
Use a Safari-supported modifier. | 24424b4 |
>4 commands with a shortcut (analyzeCommands) |
warning [auto-fixed]
|
Safari rejects the whole manifest on a 5th suggested_key; the transform strips the default chord from the extras. |
Extras still bindable in Safari → Settings → Extensions. | 0d9e8e4 |
_locales present, no default_locale (scanExtension) |
error |
Chrome and Safari reject the load. | Add default_locale. |
744822b |
default_locale has no / invalid messages.json (scanExtension) |
error |
Opaque load failure. | Create/fix the default locale's messages.json. |
744822b |
Unresolvable __MSG_*__ in name/description (scanExtension, strict resolver) |
error |
Chrome localizes manifest fields from default_locale only; an unresolved key renders the literal __MSG_appName__ as the name, and the App Store rejects a placeholder name. |
Add the key to the default locale, or use a plain string. | 744822b |
URL match pattern left in permissions (MV3) (analyzeManifest) |
warning |
Legal MV2, but under MV3 a host pattern in permissions is silently ignored, grants no host access. |
Move it to host_permissions. |
0d9e8e4, 1156251
|
Invalid content-script matches pattern (analyzeManifest) |
error |
Safari drops the entire content script for one bad pattern. | Correct or remove the pattern. | 24424b4 |
Invalid host_permissions pattern (analyzeManifest) |
warning [auto-fixed]
|
Chrome-only schemes (ws:///wss://) can't be granted; Safari gives no host access. Transform drops the bad entry; the rest still apply. |
(handled) | 0d9e8e4 |
Missing / non-numeric / out-of-range version (analyzeManifest) |
warning / info ([auto-fixed] when truncated/clamped) |
Apple's CFBundleShortVersionString needs ≤3 dot-separated integers each ≤65535; a suffix like -rc1 fails the Xcode build. Missing version fails the build. |
Use a numeric dotted version (1.2.3). |
24424b4 |
Native messaging, connectNative/sendNativeMessage (source, from UNSUPPORTED_APIS) |
info [shimmed]
|
No native-messaging-hosts manifest in Safari; the container app runs a loopback broker that launches the Chrome host and relays stdio. | Keep the container app running; install the companion host app. | a9c2e80 |
nativeMessaging permission (analyzeManifest) |
warning |
Surfaced from the permission too, native calls hide in minified bundles the source scan attributes imprecisely. | Route to the app's SafariWebExtensionHandler. |
a9c2e80 |
navigator.userAgent Chrome-version sniff (UA_CHROME_SNIFF_RE) |
warning |
Safari's UA has no Chrome/ token; the version capture returns null and the dependent feature dies. The shim spoofs a token only in content scripts on http(s) pages, popup/options/background sniffs stay truthful and still fail. |
Remove the UA dependency. | a534d69 |
Blocking webRequest (per-file) (BLOCKING_WEBREQUEST_RE + "blocking" literal) |
warning [shimmed]
|
Safari ignores the blocking return; listeners still fire as observers. The extension loads; the network-modifying feature degrades. | Migrate to declarativeNetRequest. |
aa89a20 |
Blocking-webRequest content blocker (class-level) (scanExtension) |
error |
For an ad/tracker blocker, blocking is the product, an extension that installs but blocks nothing is a misleading failure. Gated narrowly: needs webRequestBlocking + a broad host (<all_urls> / scheme wildcard) and no DNR, so password managers don't trip it. |
Convert the extension's DNR build instead (e.g. uBO Lite). | dbe3442 |
Hardcoded chrome://extensions/shortcuts or chrome://settings link (CHROME_SETTINGS_URL_RE) |
warning |
No such page in Safari; the navigation errors. The shim swallows it, but the author still needs their own "edit in Safari → Settings → Extensions" affordance. | Show the settings hint instead of linking the chrome:// page. |
f74597a |
chrome://favicon / _favicon access (FAVICON_RE) |
warning |
No Safari equivalent. | Fetch favicons directly, or drop the favicon UI. | 744822b |
CSP 'unsafe-eval' (analyzeManifest) |
warning |
Safari rejects eval in extension contexts regardless ('wasm-unsafe-eval' is fine and not flagged). |
Remove eval()/new Function. |
24424b4 |
CSP remote script-src origin (analyzeManifest) |
warning |
Safari forbids remote script in extension pages; App Store review rejects it. | Bundle the script locally. |
24424b4, 1156251
|
background.type:"module" (MV3) (analyzeManifest) |
warning [auto-fixed]
|
Causes silent popup failures on Safari/TestFlight. Stripped unless --keep-module. |
(handled) | 24424b4 |
web_accessible_resources use_dynamic_url (analyzeManifest) |
warning [auto-fixed]
|
Chrome-only; getURL() returns an unservable URL and the resource silently fails to load. |
(handled, flag cleared) | 0d9e8e4 |
externally_connectable.ids (analyzeManifest) |
warning |
ID-based connections don't resolve in Safari. | Use matches for web-page messaging. |
24424b4 |
chrome_url_overrides (newtab / other) (analyzeManifest) |
warning |
Inconsistent/unsupported per platform. | Test on macOS/iOS or drop it. | 24424b4 |
Background setTimeout/setInterval (scanJsContent, background files) |
warning |
Unreliable in suspended Safari background contexts. | Use chrome.alarms; persist to storage.local. |
744822b |
importScripts() in the MV3 service worker (scanJsContent) |
info (static) / warning (dynamic) |
The SW becomes a module background page where importScripts is undefined; static literals are hoisted into background.html, dynamic args can't be. (Webpack chunk loaders are recognized and stay info.) |
Replace dynamic importScripts(expr) with static ES imports. |
744822b |
tabs.connect (Safari 18 port bug) (CONNECT_RE) |
warning |
Safari 18 breaks iframe ↔ content-script ports. | Use contentWindow.postMessage + runtime.sendMessage. |
, |
storage permission (analyzeManifest) |
info |
storage.sync doesn't sync across iCloud devices (maps to local). |
Implement custom cloud sync if needed. | , |
iOS/iPadOS target caveats (scanExtension, when platforms includes iOS) |
info |
Responsive UI required; some APIs (contextMenus, notifications, downloads, cookies) are gated; App Store distribution only. |
Feature-detect per platform; test on iOS. | 0d9e8e4 |
Over-flagging is as harmful as under-flagging, a spurious error aborts a perfectly convertible extension. The scanner is deliberately conservative about what counts as a real hit (2cc6c34):
-
Blocking
webRequestrequires the literal quoted"blocking"token in theextraInfoSpec, not the bare word (which appears in comments, CSS classes, and unrelated identifiers). -
Hardcoded extension URLs match only the exact 32-char
[a-p]{32}id form, sochrome-extension://joined with a variable isn't flagged. -
Icon format is sniffed off the basename, so a dot in a directory name (
assets.v2/icon48) isn't read as a bogus extension. -
Native
importScriptschecks run only against the manifest-declared service worker;importScriptsin a genuine web worker keeps working in Safari and is left alone. Args are extracted with the same balanced-paren scan the converter uses, so webpack'simportScripts(o.p+o.u(t))isn't truncated at the first inner). -
A more specific API hit (
chrome.identity.launchWebAuthFlow) subsumes the generic namespace hit (chrome.identity) so you don't see the duplicate. - The scan skips its own output dir (
staged_extension),node_modules,.git, and__MACOSX.
The scan walks the raw manifest and source, before the converter injects its own shim/polyfill/bridge scripts, so those injected files never produce false "unsupported API" hits.
--analyze runs the analyzer standalone (no conversion, no Xcode project) and returns exit code 1 when there are blocking issues, 0 otherwise, the same gate a real conversion uses. --strict promotes warnings to blocking. See CLI Reference for all flags.
printIssues groups findings error → warning → info, prints each with its [category], location, [shimmed]/[auto-fixed] badge, message, and fix, then a tally line and the verdict:
Convertible: no blocking issues.
3 blocking issue(s) (--strict: warnings count as blocking).
Below the issues it also previews the manifest rewrites the converter would apply (from summarizeManifestChanges).
A single object per extension (batch + --json is rejected, one extension at a time for parseable output). The convertible field matches the real conversion gate exactly: blocking === 0, where blocking already excludes autoFixed issues, so a CI consumer can trust it. Built in cli.ts → analyzeOnly. Only these fields exist:
{
"name": "My Extension",
"appName": "My Extension",
"bundleId": "com.viaduct.my-extension",
"version": "1.2.3",
"manifestVersion": 3,
"platforms": "all",
"counts": { "error": 0, "warning": 2, "info": 3 },
"autoFixed": 2,
"shimmed": 1,
"blocking": 0,
"convertible": true,
"removedPermissions": ["idle", "contextMenus"],
"manifestChanges": [
"Version `1.2` → `1.2.0` (Apple format).",
"Background made non-persistent (MV2 → Safari)."
],
"issues": [
{
"severity": "warning",
"category": "api",
"message": "Blocking webRequest detected; Safari ignores the blocking return...",
"file": "background.js",
"line": 42,
"fix": "Migrate the blocking rules to declarativeNetRequest rulesets...",
"shimmed": true
}
]
}On a corrupt archive or missing manifest, JSON mode still emits parseable output rather than a stack trace: { "error": "<message>", "convertible": false }.
Writes the report to disk. With --json it writes the JSON payload; otherwise it writes the Markdown report (buildReportMarkdown), the same document writeReportFile drops as CONVERSION_REPORT.md next to a real conversion's output, so the issue list survives past terminal scrollback (useful for CI logs / handoff). The Markdown report leads with a Status: line, per-severity counts, a Removed permissions section, a Manifest changes section, then the grouped issues with _(shimmed …)_ / _(auto-fixed)_ tags.
- Manifest Transform: the counterpart that actually applies the rewrites the analyzer previews.
-
Runtime Shim: what backs every
[shimmed]finding (UA spoof, native-messaging broker, storage sync→local, dead-navigation swallowing). -
Safari Quirks: the underlying WebKit/Safari behaviors these checks defend against (per-install origin,
world:"MAIN"on 18.4+, Safari 18 port bug, PNG-only icons). -
CLI Reference:
--analyze,--json,--report,--strict,--force. -
Limitations and FAQ: what can't be shimmed at all (blocking
webRequestnetwork blocking, favicon access).
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.