-
Notifications
You must be signed in to change notification settings - Fork 3
Release History
viaduct is versioned with npm semver and shipped from git tags. Every published GitHub Release triggers a GitHub Action that publishes the package to npm as @magicelk235/viaduct over Trusted Publishing (OIDC): no long-lived NPM_TOKEN. The current released version is 1.11.0. The project spans roughly 185 commits from 2026-05-29 (scaffold) to 2026-07-22 (working branch), and this page maps each tag to what actually shipped in it, grounded in the commit record.
Every bullet cites the (shorthash) it came from. Newest release is on top.
Cross-references: Runtime Shim · Manifest Transform · Analyzer · Build and Install · OAuth Bridge · Safari Quirks
Theme: the page-to-background bridge, which had four separate ways to fail in silence, all of them found by driving Claude for Chrome's login.
Every fix here is in the bridge templates or the manifest, and every one applies to any extension with an externally_connectable flow rather than to Claude specifically.
- Give the page world its bridge even when Safari skips the
world:"MAIN"entry. Safari honors that key from 18.4 and ignores it in silence below, so the page had nochrome.runtime, messaged nobody, and left its login button spinning while the relay, the background and the polyfill all reported healthy.applyOAuthBridgehas madepage-bridge.jsweb-accessible for a script-tag fallback since the first bridge commit and the fallback was never written; the relay now probes the page world and injects the file itself when nothing answers. A page CSP can still refuse it, which is whatworld:"MAIN"was for, and that case says so. - Raise
strict_min_versionto 18.4 forworld:"MAIN"entries the conversion injected and that cannot recover on their own, instead of shipping a manifest that claims 15.4 and quietly does less. Entries the extension declared itself keep the author's floor, and the page bridge is exempt because it re-injects itself. - Probe both transports into the background. A
sendMessageinto a suspended background is not reliably delivered or rejected, so a single relayed send could vanish with no error anywhere. The relay pings three times oversendMessage, then three times over the shim'schrome.storage.localmailbox, and sends the payload once over whichever answered. The ping is answered by the polyfill rather than forwarded to the extension, so it works for a bundle that has never heard of a ping and stays safe to repeat. - Take the bridged sender's origin from the page URL rather than Safari's
sender.origin. The relay hands over a content-script sender whoseoriginis Safari's choice and not reliably the page's, and an allow-list handed the wrong origin refuses the message while holding the channel open: nothing throws, nothing logs, the page waits out its timeout. - Give the bridged sender a tab. Chrome always supplies one for an external page message and handlers act on it, which is how the consent window dismisses itself. Neither transport can produce a tab, so the polyfill resolves it from the page URL, refuses a candidate that is visibly another site, and gives up after 1.5s rather than becoming a new way to hang.
- Treat Safari's "Invalid call to runtime.sendMessage(). Tab not found." as teardown. It fires routinely while a page unloads, including right after a successful OAuth exchange, and forwarding it made the page bridge reject a promise nobody was left to catch. The relay now stops quietly and suppresses every later diagnostic.
- Read
__C2S_DEBUGat call time in all three templates, so logging can be turned on from a console that is already open instead of only by editing the build. Two new unconditional lines name the failures that used to be invisible: listeners that accept a message and never answer it, and a background that answers no ping on either transport.
Claude in Chrome 1.0.85 moves to a verified login in the table. Its earlier row covered the popup and side panel only, which is why the bridge looked fine for so long. See Tested Extensions, OAuth Bridge, Safari Quirks E1/E9/E10.
Theme: the team detection widened in 1.10.2, checked against the Xcode versions other people actually run rather than the one on this machine.
- Read
ApplicationIdentifierPrefixout of a provisioning profile. Profiles only grew aTeamIdentifierkey around Xcode 6; older ones carry the same id under the earlier key, and the profile source skipped them entirely (e0fba1a). - Accept
Developer ID Applicationand3rd Party Mac Developer Applicationidentities. The keychain lookup took development and distribution certificates only, so a paid account that has never created a development certificate, which is every account that only ships notarized releases, read as "no Apple account" for the same reason as #14. The team id lives in the same subjectOUon all of them. Development identities still win when both are present, since that is what the build asks for (e0fba1a). - The detection tests were rewritten to run against stub
defaults/security/opensslbinaries and a tempHOME, so they assert real behavior on any machine instead of skipping when the keychain has no identity. Twelve shapes are covered: both preference keys, both preference domains, quoted ids, both profile directories, three profile key layouts, and five certificate names (e0fba1a).
Theme: a --team auto run that did everything right, told the user it had failed, and pointed them at a fix they had already applied (#14).
- Stop failing the run for the ad-hoc fallback it announced itself.
--team autoand plain--installmean "team-sign if a team is available", and when none was found the CLI said so up front and fell back. The signature check added in 1.10.0 then read the resulting ad-hoc bundle, decided team signing had been requested and not delivered, and exited1with "sign in to Xcode", which is what the reporter had already done. The expectation is now what actually reachedxcodebuild, not what was typed on the command line: a concrete team that came out ad-hoc still fails, since the build ignored the signing it was told to use, but an announced fallback is a warning and exits0. An unsigned bundle fails either way, because the fallback promises ad-hoc signing rather than no signature (a87574a). - Detect the team from more than Xcode's preference cache.
detectXcodeTeamreadIDEProvisioningTeamByIdentifierfromcom.apple.dt.Xcode, then a codesigning identity from the keychain. A freshly signed-in account has neither: Xcode writes that cache only once it has provisioned something, and it does not mint anApple Developmentcertificate until the first signed build, so signing in changes nothing the tool can see and the advice to sign in is a loop. It now also readsIDEProvisioningTeamsand thecom.apple.dt.xcodebuilddomain (which key and which domain get written varies by Xcode version), and any provisioning profile under~/Library/Developer/Xcode/UserData/Provisioning Profilesor~/Library/MobileDevice/Provisioning Profiles, newest first. Profiles are CMS-signed but the payload plist sits in the blob as plain XML, soTeamIdentifieris scanned out of the bytes instead of shelling out tosecurity cms -Dper file. The id is the whole job, since the build already runsxcodebuild -allowProvisioningUpdatesand Xcode mints the certificate itself (47d1ce1). - The fallback warning now names the two things that actually resolve it: Xcode → Settings → Accounts → Manage Certificates → + → Apple Development, which writes both the certificate and the cached team, or
--team <TEAMID>from the membership page to skip detection entirely. Help text, README, and the wiki were updated to match (a87574a).
Theme: the two Safari behaviors between a converted Kondo and a web app that can talk to its extension, both found by driving an extension a user had reported dead.
- Emulate
navigator.serviceWorkerin an extension page. Chrome registers the MV3 background service worker against the extension origin, which is what makes the documented web-page-to-background channel work: the page hands an extension-origin iframe aMessagePortand the iframe forwards it with(await navigator.serviceWorker.ready).active.postMessage(msg, [port]). The conversion turns the worker into a background page, so nothing is registered for the origin at all — measured in a converted page,getRegistrations()comes back empty,controlleris null, andreadystays pending indefinitely. Theawaitnever returns, nothing throws anywhere, and an extension whose only channel is that port sits there looking installed and doing nothing. Kondo is exactly that shape:app.trykondo.comsigned in and the extension never answered a single request.readynow resolves with a registration stub andpostMessageis tunneled to the background over aruntime.connectport, which is the only transport available here — an extension page embedded in a web page runs in the web content process, whereextension.getBackgroundPage()is null (still null after 11 s with a port held open), so the port cannot be handed across realms the way the offscreen client is. AMessagePortcannot cross a runtime port either, so each transferred port is bridged by id and the background re-materializes a realMessagePortfrom its ownMessageChannel, then dispatches amessageevent carrying it plus thesourceandwaitUntila worker handler expects. Payloads cross as runtime-message JSON, so Blobs, ArrayBuffers and Dates do not survive (0f37df7), see Safari Quirks C7 and Runtime Shim. - Return the cookie the request will actually send from
cookies.get. When two cookies share a name on nested domains, Safari answersget({name, url})with the parent-domain one while the network stack sends the host one first; Chrome breaks the tie by longest path then earliest creation, which returns the site's own cookie. So an extension reads a session value and then makes a request carrying a different value for that same cookie. LinkedIn's realJSESSIONIDlives on.www.linkedin.com, Kondo mints a placeholder on.linkedin.comwhenever it finds no session (which is what happens the first time it runs before LinkedIn has been opened in Safari), and from then on every voyager call went out with aCsrf-Tokencontradicting its ownCookieheader and came back403 CSRF check failed— permanently, since the junk cookie outlives the session that produced it. Kondo's own error payload carried both cookies and the token it sent, which is how the precedence was pinned down.getnow re-resolves throughgetAll({url, name})and, only when more than one candidate comes back, returns the one the server sees first (RFC 6265: longest path, then the most specific domain); a single candidate, an incomplete query, or a callback-onlygetAllfalls through to the nativeget(3deb446), see Safari Quirks E8.
Kondo 1.12.1 joins the table as Working: the web app signs in and reaches Kondo's own plan-selection screen, linkedin.com/messaging auto-opens the inbox, and the toolbar button's contexts:["action"] menu carries its own toggles with the right one ticked. What sits behind Kondo's paid plan is unverified rather than known broken. Its empty toolbar popover is not a conversion fault either: the popup only does window.open(app); window.close(), the tab opens, and Safari refuses to let script close the popover it drew. See Tested Extensions.
Theme: two Safari behaviors that silently swallow an extension's requests for its own files, and a signing path that stopped taking its own output on trust.
- Lowercase the host on the empty
getURL("")only. Safari servesgetURL("")with an uppercase UUID host and reports every origin (sender.origin,new URL().origin,location.origin) lowercase, and bundles gate on both idioms. v1.9.1 alignedsender.originup togetURL's case for uBlock'ssender.origin === getURL("").slice(0,-1), which broke the mirror-image check: LastPass's background accepts its popup's initialize message only whennew URL(sender.url).origin === sender.origin, always lowercase, so it never matched and the popup sat on its loading splash. Resource paths keep Safari's real case, since the resource server is case-sensitive on the host and a lowercasedfetch(getURL("manifest.json"))404s, and Honey and Dark Reader matchlocation.hrefagainstgetURL("/…"). The sender-origin rewrite becomes a no-op (929c2ca), see Safari Quirks. - Rewrite the scheme on
chrome-extension://__MSG_@@extension_id__/…. That is how a bundle reaches its own files from CSS, and Safari substitutes the per-install UUID for the placeholder while leaving the scheme alone, so the request leaves an https page aschrome-extension://<uuid>/…and WebKit blocks it: "requested insecure content … must be served over HTTPS". Cloaked'scontent.csslost 18 webfonts on every injection into my.cloaked.com, which is also what buried its console.CHROME_SCHEME_REskips these and has to, because its host guard cannot separate a placeholder from a real id and a concrete-host URL may be an OAuthredirect_uriregistered with a provider; a placeholder host never is, since nothing outside the browser resolves it.walkScriptstakes an extension list now, so this pass reaches CSS and HTML that the script-only rewrites never opened (053e525), see Conversion Pipeline and Safari Quirks. - Verify the signature instead of trusting team detection. Detection only predicts how a build will be signed; when it came up empty the build fell back to ad-hoc behind a single warning line, so the extension installed, worked, and disappeared on the next Safari quit. The signature is now read off the built
.appexwithcodesign -dvvand held to what the run asked for, so no certificate, a stale copy of the tool, or xcodebuild dropping the identity all surface the same way. Behavior change: a run that asked for a team (--team <id>,--team auto, or plain--install) and produced an ad-hoc bundle exits non-zero, and not behind--verify— a build that ignored the signing it was told to use did not do what was asked, the same as an install that does not land. Builds with no--teamare untouched (f24972c, tests in118c327), see Build and Install. - Corrected the help text and README, which still described Xcode's preference cache as the only source of a team id after the keychain fallback landed in v1.9.0 (
f24972c).
Theme: four shim bugs between a converted Honey and a popup that renders, two of which viaduct had caused itself.
- Only give a real side panel the injected
tabIdparam. Safari opens a side panel as a popover without the?tabId=<n>a panel page expects, so the shim resolves the active tab and writes it in withreplaceState. That ran for plain action popups too, where Chrome puts no query on the URL either, so the page was handed a URL its own code never expects. Honey picks which message service to send on by comparinglocation.hreftogetURL("/popover/popover.html"); once the query landed the compare failed, every popup RPC went out asmessages:cs, and the background'smessages:cslistener bails on any sender without asender.tab, which is every message a popup sends. No reply, no error, empty popup. This is also where Dark Reader's popup was getting the?tabIdthat 1.8.0 and 1.9.0 strip back off, so those fixes were treating our own symptom (064812b), see Safari Quirks F3 and Runtime Shim. - Give
getURLone host case, and fixsender.origininstead. Safari reports the extension UUID uppercase ingetURL,location.hrefandsender.url, and lowercase insender.origin, and bundles comparegetURL's output against both. The root args were lowercased so uBlock'ssender.origin === getURL("").slice(0,-1)would match, which broke Honey'slocation.href.includes(getExtensionURL("/")): its popup uses that to decide whether it is the popover, and with it false it treated its own href as the current page URL and sent every message without atabId. Resource paths could not be lowercased at all, since Safari's resource server is case-sensitive on the host and a lowercasedfetch(getURL("manifest.json"))404s.getURLnow returns Safari's case for every argument andsender.originis aligned to it on the clone the sender fix already hands listeners, which is where mutation actually sticks. Thesender.urlhalf of the old lowercasing had already gone in43047af(bf014e0), see Safari Quirks B4 and Runtime Shim. - Emulate
tabs.onActivated, because Safari never fires it in the background. Probed live with logging wrapped around the registrations: bothtabs.onActivatedandwindows.onFocusChangedare registered 28 ms into boot, the user clicks between three tabs, and nothing arrives, while that same background is demonstrably alive and answering popup messages 8.7 s later. An extension that keeps the selected tab in a variable fed only by those events never learns one; Honey'sgetSelectedTab()istabs.get(selectedTabId), so its popup's opening request made the background calltabs.get(undefined)and Safari answered "Invalid call to tabs.get(). The 'tabID' value is invalid, because a number is expected." The shim polls the active tab in the background while something is listening. The first poll always dispatches, since Safari restarts the background constantly and a woken one has missed every activation before it, and one realonActivatedstops the polling permanently so a future Safari cannot deliver everything twice (7b1b69a, hardened in61cf026), see Safari Quirks C6 and Runtime Shim. - Strip the query off a relayed
sender.urltoo. Native delivery already did. The storage relay builds its sender out oflocation.href, so an extension page whose URL carries a query reported asender.urlthat no longer equalledgetURL(path), and bundles allow-listing their own pages by exact match dropped those messages without ever callingsendResponse. Dark Reader's popup sat on "Loading, please wait" with dead buttons (11a00b4), see Safari Quirks B2.
Honey 19.4.0 moves to Working on this build: popup renders and a session signed in on joinhoney.com is recognized. Coupon application at checkout is unverified. See Tested Extensions.
Theme: the messaging paths a content script depends on, driven by the Cloaked and Tampermonkey conversions, plus chrome.userScripts reaching the page at all.
-
Fall back to the signing certificate when Xcode's team cache is empty.
detectXcodeTeamread onlyIDEProvisioningTeamByIdentifier, a preference Xcode writes asynchronously and leaves missing on machines whose Apple account signs perfectly well, so viaduct reported no account and quietly dropped to ad-hoc signing, which Safari disables again on every quit. The team id now comes off the codesigning identity in the keychain when the preference is empty, read out of the certificate subject's OU (168d505), see Build and Install. -
Correct the background-context check in converted background scripts. An MV3 bundle decides "am I the background?" by the absence of
window, which holds in Chrome because the background is a service worker and stops holding here because the conversion gives it a background page. The bundle then concludes it is a content script and everything keyed off that identity misroutes without a word. Cloaked's crx-kit dispatcher opens withif (msg.to !== this.myEndpoint) return false, so{to:"BACKGROUND", name:"openAuthUrl"}arrived at a listener that dropped it and Log in spun forever. Rewritten only in the files the background itself loads, and only where the check directly produces a background-ish value, so a bundled library using the same idiom to pick a Node path is left alone, as is the identical detector inside the popup bundle, which has to keep answering "foreground" (cd58700), see Safari Quirks A9 and Runtime Shim. -
Emulate
webNavigation.onHistoryStateUpdatedfrom the content script. Safari has no implementation and the shim backfilled an inert stub, which looks exactly like a real event that has not fired yet. Cloaked installs its page-to-extension bridge from this event on my.cloaked.com, a Vue app, so after login the dashboard'spostMessagehad nobody listening. Safari re-injects content scripts on same-document navigations with a fresh isolated world each time, so the sender cannot remember its previous URL; it announces the current one on every injection and the background, which does persist, holds the per-tab baseline and decides what changed. Between injections the URL is sampled in short bursts after user input rather than on a standing interval, since a permanent timer in every frame of every page is a real cost for a rare event (0949657), see Safari Quirks C5 and Runtime Shim. -
Populate
tabson the Window returned bywindows.create. Chrome guarantees that array on the created window; Safari leaves it undefined, and an extension that reads the new tab straight off the result treats that as a hard failure. Cloaked's auth flow throws "Created window has no tabs available" after its popup has already closed itself, so the user watches a spinner disappear and no login window arrive. The wrapper backfills from atabs.queryon the new window and synthesizes a tab when the query comes back empty, because callers indextabs[0]unconditionally and an empty array is as fatal as a missing one. It also retries withouttypewhen Safari refuses a window type it does not render (7fd15e8), see Safari Quirks C4 and Runtime Shim. -
Stop swapping the extension page's global
apiobject in the storage relay. The relay replacedchrome/browseron extension pages with a Proxy to interceptsendMessage/onMessage, and Safari answers that by not delivering content-script messages to the page at all, including to a listener registered on the pristine native event before the swap. So it is not a registration problem: Safari resolves delivery through the page's current global at dispatch time. Every converted extension whose content scripts message the background was silently dead, Cloaked included, since its login tokens are read by a content script and sent to the background. Replacingruntime.onMessagein place fails the same way; wrapping itsaddListenerdoes not, so the relay records listeners that way and leaves both identities alone. Bisected across six converted builds (8acef29), see Safari Quirks E7 and Runtime Shim. -
Keep content scripts and MV2 backgrounds on native messaging. Safari only delivers a content script's native
runtime.sendMessageto the background, and only lets itssendResponseround-trip, when both sides sit on the real unwrapped runtime, so the relay's Proxy was dropping the delivery in two contexts that never needed the relay: content scripts, and the MV2 background the shim was being prepended into. TWP was the visible casualty, sending{action:"translateHTML"}and waiting on a reply that never came. The relay still runs where Safari genuinely drops native messaging, which is extension pages: popover, panel, options (4010230), see Runtime Shim and Safari Quirks E6. -
Make
chrome.userScriptsactually reach the page. The registry was coherent and inert, which is fine for an extension that only manages scripts and useless for one whose whole injection strategy is this API: Tampermonkey declares nocontent_scriptsat all, so its userscripts saved, listed as enabled, and never ran. The background now publishes its registry tostorage.localon every register/update/unregister, and viaduct injects a content script (only into extensions that declared the permission) that reads it atdocument_startand evaluates what matches the page. Storage rather than messaging, becauseruntime.sendMessagebroadcasts to every listener and the firstsendResponsewins: Tampermonkey's own background handler consumed the request and answered with nothing on all twelve retries, and storage additionally survives the background being torn down. Tested end to end in both halves (62ec130), see Safari Quirks C3. -
Append a sacrificial message to every
_locales/<locale>/messages.jsonduring staging, because Safari doesn't load the final entry of a locale catalog.getMessagereturns""for that key in every form, and the extension then renders its raw message key. Tampermonkey's last message isv0version0("v$version$"), so its dashboard header showed a literalv0version0instead of the version whiletop_level_awaitimmediately before it resolved fine. Ruled out the two obvious explanations live: entries past the same byte offset resolve, and other placeholder messages resolve, so the entry is dropped purely for being last. The insert is textual so shipped formatting and escapes survive byte for byte (937da18), see Safari Quirks A7 and Conversion Pipeline. -
Keep the DNR rules Safari accepts when one rule in a dynamic batch is invalid. Safari rejects the whole
update{Session,Dynamic}Rulescall if any single rule fails validation, so one uncompilableregexFiltercosts the extension every other rule it tried to register. Tampermonkey registers all of its*.user.jsinterception rules in one call, so userscript-URL detection was dead with only a console error to show for it. The wrapper now re-applies the removals and adds rules one at a time, keeping what lands; a batch where nothing lands still surfaces the original error (479d518), see Safari Quirks D2. -
Deliver
client.postMessageto the emulated offscreen document. The shim fabricatesself.clientsfor extensions that probe for their offscreen document the Chrome way, but the fabricated client'spostMessagewas a no-op. That satisfies an existence probe and breaks the other half of the contract: the SW-to-offscreen binary handshake posts a message with a transferredMessagePortand awaits the reply on its twin, so the await never settled. Nothing threw, so it read as a hang with a clean console. Tampermonkey hit it on every editor save (it wraps the script source in an object URL created offscreen), which is why saving a userscript spun on "Please wait..." and then reported a bogus "Unable to parse this!" (3dd05f2), see Safari Quirks C2 and Runtime Shim. -
Stop reporting a dynamic-
declarativeNetRequestextension as a blocking-webRequestcontent blocker. The class-level error exempted only extensions shipping staticdeclarative_net_request.rule_resources, so an extension that declares the DNR permission and builds its rules at runtime (updateDynamicRules/updateSessionRules) still tripped it and conversion aborted. Tampermonkey is exactly that shape (webRequestBlocking+<all_urls>+declarativeNetRequestWithHostAccess, no static ruleset), so a userscript manager was blocked as an ad blocker. Since the transform already maps the Safari-rejectedWithHostAccesstoken back onto plaindeclarativeNetRequest, the DNR path survives conversion, and the exemption now follows the permission rather than the ruleset (3403ff7), see Analyzer and Manifest Transform. -
Don't hijack a
setPopup-driven toolbar button with the action-click bridge. The bridge treated any background that mentionedonClickednext toaction/browserActionas an onClicked-driven button and injected a transparent stubdefault_popup. But an extension can registeronClickedand still wire its real popup at runtime viasetPopup, which Safari honors, so the stub overrode it and the extension's own popup never opened. TWP (Translate Web Pages) is exactly this shape: it setspopup/popup.htmlviasetPopupat startup and itsonClickedfires only under a non-default config, so clicking the toolbar button showed an empty gray stub and there was no way to start a translation.backgroundRegistersActionOnClickednow scans all background files collectively and opts out when any of them sets a non-empty popup viasetPopup; an emptysetPopup({popup:""})clears the popup and doesn't opt out. The collective scan matters because viaduct prepends its own shim (which referencesonClicked+actionbut nosetPopup) tobackground.scripts, so a per-file check would short-circuit on the shim before reaching the bundle's realsetPopup. Fixed in both detector copies (6f63db2), see Runtime Shim and Safari Quirks A5.
Theme: an adversarially-verified sweep over the whole pipeline — eight correctness/robustness fixes, none behavior-visible on the happy path. All land in 92fa2ee.
- Recover the CRX-embedded
keywith the lenientparseJsonc, not strictJSON.parse. A Chrome manifest carrying a BOM,//comment, or trailing comma madeJSON.parsethrow, so the key was silently dropped and the derived Chrome id (OAuth bridge,chrome-extension://origin spoof) was lost for exactly the bundles most likely to be hand-edited (92fa2ee), see Input Handling and OAuth Bridge. -
defaultBundleIdnow hashes whenever the slug isn't a lossless rendering of the name, not only when it's empty. Two distinct names reducing to the same non-empty slug (Foo/1Foo,Cafe/Café,MyExt/My-Ext) previously collapsed to one bundle id and shadowed each other in LaunchServices (92fa2ee), see Build and Install. -
--uninstallreads the installed app's realCFBundleIdentifierto key the native-messaging broker LaunchAgent, instead of recomputing the default id. An extension installed with a custom--bundle-idused to leave an orphanedKeepAliveagent runningopenon the deleted app forever (92fa2ee), see Build and Install and OAuth Bridge. - The whole-download 5-minute wall-clock deadline is threaded through the redirect recursion as one shared deadline, instead of re-armed per hop — a 5-hop redirect chain could otherwise stay open ~30 min (
92fa2ee), see Input Handling. - The
ancestorOriginsguard regex no longer allows)/]in the receiver walk-back, so it can't start a match mid-expression (foo().ancestorOrigins…) and emit unbalanced parentheses. Latent (real bundles never call through toancestorOrigins), but a token rewrite must never be able to produce invalid JS (92fa2ee), see Safari Quirks E3. - The action hotkey maps a named command key to its DOM
KeyboardEvent.keyform (Up→ArrowUp,Space→" ", …) instead of the bare WebExtensions token, and falls back to theCtrl+Shift+Ydefault when a token is unmappable. A command likeCtrl+Shift+Upused to wire a shortcut that could never fire (92fa2ee), see Runtime Shim and Safari Quirks A5. - Drop the dead
try/catcharoundBuffer.from(key, "base64")inderiveChromeId— Node's base64 decoder never throws; theder.length === 0check is the real malformed-key guard (92fa2ee), see OAuth Bridge. - Strip comments before the
importScriptsdynamic-argument residue test, soimportScripts("a.js" /*x*/, "b.js")is recognized as fully static instead of being flipped to dynamic — which needlessly triggered webpack-chunk collection and module-mode background loading (92fa2ee), see Runtime Shim and Conversion Pipeline.
Regression tests added for the bundle-id collision, the hotkey key mapping, and the ancestorOrigins call-receiver balance.
- Capture content-script
runtime.onMessagelisteners unconditionally so the in-page action hotkey works. The hotkey replays the extension's toggle message to the listeners the shim captures in the content world, but that capture was gated on a__vHotkeyWiredflag derived fromchrome.runtime.getManifest().content_scripts. Safari stripscontent_scriptsfromgetManifest()inside a content script, so the flag read false there, the capture never installed, and the hotkey fired into an empty list. SuperDev Pro'sCmd+Shift+Ssidebar toggle did nothing as a result (itsonClickedandcommands.onCommandare both dead in Safari, so the hotkey is the only path). Dropped thegetManifest-derived gate; the capture now always runs in a content-script context, verified live (190133d), see Runtime Shim and Safari Quirks A5. - Stop overwriting an extension's own
browser-polyfill.min.js. uBlock Origin ships its own polyfill in its content-script list;writePolyfillcopied viaduct's webextension-polyfill build over that exact file, so uBlock'svapi.js(compiled against its own copy) threw at load and every content script after it, includingcontentscript.js(the cosmetic/element-hiding engine), never ran. The extension installed, enabled, and badged from the background but did nothing on the page. viaduct now writes its copy underviaduct-browser-polyfill.min.jswhen the extension already ships one, leaving the original intact and loading both (the polyfill is idempotent) (622001c), see Runtime Shim and Safari Quirks E5. Closes #10. - Strip a
chrome://-scheme URL (and any other unparseable-scheme host pattern, e.g.ws://) frompermissions/optional_permissionsduring transform. Tampermonkey shipschrome://favicon/inpermissions; left in place, Safari treats the whole manifest as invalid and never loads the extension, even though it installs and registers with pluginkit. The analyzer already warned but the converter left it alone (warn-don't-move), which is right for a merely-ignoredhttps://pattern but fatal for a Chrome-only scheme Safari can't grant anywhere. The transform now drops these the same wayhost_permissionsalready filters unparseable schemes, and the analyzer reports the case as auto-fixed (31d13b9), see Manifest Transform and Safari Quirks A6. Closes #11.
Theme: Safari runtime-compat fixes, driven by the TWP – Translate Web Pages conversion (#4) and a review-surfaced XHR bug (#6). Squash-merged as #5 (7247e8b) and #8 (47d2454); per-fix shorthashes below are the working-branch commits.
- Route content-script messages and XHR through the correct Safari paths (
26605b7), see Runtime Shim. - Stop hijacking
onClickedactions and preserveversion_namein the transformed manifest (5b1bd03), see Manifest Transform. - Guard
ancestorOriginsreads and rewrite self-pagechrome-extension://URLs during staging (02c4007), see Safari Quirks E3 / B1. - Prepend the compat shim to MV2 background scripts so the background survives load (
0240e1d), see Safari Quirks A1. - Strip
ftp://fromcontextMenusURL patterns (4a5cae9), see Safari Quirks F4. - Cap
commandsat Safari's 4-shortcut limit (d3fcb9a), see Manifest Transform / Safari Quirks A4. - Make top-level content-script globals re-injection-safe (
const/let→var) so Safari's double-evaluation of adocument_end/all_framesgroup into a shared world can't throw "Can't create duplicate variable" (1f22a10), then normalize./and backslash content-scriptjspaths so every referenced file is reached (2dc30ca), see Safari Quirks E4. Closes #4. - Proxied XHR fires
timeout(+loadend), noterror, on a timeout (2501a8b), see Safari Quirks D6. Closes #6.
Theme: debugger/CDP emulation, native-messaging broker, real Chrome-id derivation, plus licensing.
- Emulate
chrome.debugger/ the Chrome DevTools Protocol for converted extensions (a2a0f55), see Runtime Shim. - Native-messaging broker so
runtime.connectNativeworks on Safari (05cc244), see OAuth Bridge. - Derive the Chrome extension id from the CRX public key instead of guessing (
34d4724), see Build and Install. - Source-comment tidy (
f7e3c75).
Licensing / meta milestones folded into this release window:
- Added an MIT license and privacy policy (
8579c84), then a security policy (3c94a79). - Relicensed under PolyForm Shield 1.0.0 (
a8bd84d), the current license.
Theme: main-world content scripts.
- Re-declare content-script page-world injections as
world: MAINso scripts that expect the page's own JS context behave on Safari (d35e3ad), see Manifest Transform and Safari Quirks.
Theme: popup-less action buttons.
- Popover-free toggle for popup-less
onClickedactions, so a toolbar button with no popup fires its click handler on Safari instead of opening an empty popover (e9ba1e3), see Safari Quirks. - Analyzer now flags blocking-
webRequestcontent blockers that can't block in Safari (dbe3442), with README guidance that full uBO can't block and users should reach for uBO Lite (644e97e), see Analyzer. -
deriveAppNamewhitelists characters to stop XML/scheme injection into generated project files (ba89cc7).
Theme: URL-rewrite correctness fix.
- Keep concrete-host
chrome-extension://URLs intact rather than rewriting them (1b7b6f5), see Safari Quirks.
Theme: docs.
- Spell out the Safari website-access grant step users must perform after install (
2ea6657), see Build and Install.
Theme: Safari blob downloads, dark mode, webpack SWs, popup tightening.
- Safari blob downloads, dark-mode pages, webpack service workers, and WebSocket hosts (
7acf2ca), with follow-ups to preserve the filename via the share sheet (bfa7903) and to make direct one-click blob downloads keep the correct extension (6c2396d). - Route offscreen-document replies and rewrite the
chrome-extension:scheme (5a12186); strip the?tabIdquery from popupsender.url(49c41cb), see Runtime Shim. - Keep a
declarativeContent-gated action button clickable and further tighten popup sizing (db06848), see Safari Quirks.
Theme: docs / release chore.
- Smaller viaduct ASCII icon in the README (
39820ba); release bump (886284c). No code changes.
Theme: docs / release chore.
- New viaduct ASCII icon and README banner update (
357a49e); release bump (a62f069). No code changes.
Theme: bug-hunt hardening at scale, plus config/batch/--verify.
This is a large release built mostly from multi-agent audit passes, hundreds of small correctness fixes across the shim, manifest transform, OAuth templates, and analyzer accuracy:
- Config file, batch convert, and
--verifymode (33e9bce), the batch/verify feature landed here, see Build and Install. - Wire 4 previously-inert shim stubs to real Safari/web implementations (
94ad310); strip two Safari-unrecognized permission tokens (774d0a8), see Runtime Shim and Manifest Transform. - Shim runtime hardening, frozen-root throw guards,
storage.sessioncorrectness, missing runtime APIs (f344d7a); external-messaging events and proxy-config substitution guard (54172b4). - Resolve
__MSG_*__i18n placeholders in the generatedbackground.htmltitle (f02a0cc), see Manifest Transform. - Rolled-up audit fixes: 6 bugs (
e9150d8), 9 bugs (4f42d8f), 5 issues (23aef5e), 13 issues (b016620), 8 more shim gaps from corpus cross-reference (8f8aef6), 22 issues across runtime/CLI/analyze (9a221ce), 13 bugs incl. iOS build/crashes/OAuth/codegen (eb71af6), and 28 bugs incl. shim semantics/manifest/OAuth/analyze (6e0d73b). - Single source of truth for the default Safari min-version (
45f4d29); removed the scratchscripts/anddocs/dirs (7f971ad); new README ASCII icon (dd17de2).
Theme: sidePanel + action badge fixes.
- Safari
sidePaneltoggle and action-badge init-abort fixes, with 5 extension test reports (4d1109a), see Safari Quirks. - 14 correctness bugs from a deep multi-agent codebase audit (
e5d343b).
Theme: many audit fixes, the Grammarly port-routing rewrite, and the src/ reorg.
The headline work is getting real extensions (Grammarly, uBlock) to actually run on Safari, driven by the port-routing rewrite:
- Rewrite
chrome.runtime.idto the UUID host and strip theruntime.idprefix from port-routing matchers so Safari routes popup↔background ports correctly (362feb9,cc611a3), then remove the now-dead port-clone machinery the matcher rewrite superseded (43047af), see Runtime Shim. Root-cause chain documented inb05abd8. - Supporting shim fixes for the same goal: route popup port by lowercasing
sender.urlhost (e90a45f); forward cloned-port methods bound to the real port for Safari's brand check (7c39c87);defineProperty-wrap nativeonConnect/onMessage/cookiesevents (16eccc6); backfillstorage.session.setAccessLeveland clearruntime.lastErrorso background init can't hang or reject (e0a70ea,f3eb880); carry the httpOnly session cookie through the native-host auth proxy (6071347); guardcookies.onChangedagainst Safari null events (8952cd5); restore resource loading +connect-src 'self'for Grammarly's background init (7debbf8). - Make converted extensions survive Safari's frozen/exotic namespaces and no-
chromeglobals; backfill MV3 enums/storage/userScriptsevents on the resolved namespace (73c1926,ca523e8,b81dda0,1880790); blockingwebRequestdegrades instead of aborting conversion (aa89a20), see Runtime Shim and Analyzer. - Input: descend into the lone manifest-bearing subdir of a repo-style layout (
e64f335); balanced-parenimportScriptsneutralization + DNR path-escape guard (e8830e0). - Large refactor splitting
src/into concern foldersinput/manifest/analyze/runtime/build(a07abde), extracting the shim runtime JS tosrc/runtime/safari-compat-shim.js(2927cdf) and the Safari compat-data tables out ofmanifest.ts(3f919d0); addedCLAUDE.mdarchitecture docs (41779ff). - Final rollup of audit correctness bugs across shim/analyze/manifest/build (
d885422); the release also carries per-extension Safari test reports and a live-testing debug protocol (7bd822d).
Theme: UA-sniffing detection + a bug hunt.
- Detect
navigator.userAgentChrome-version sniffing so the analyzer can warn about it (a534d69), see Analyzer. - Surface shim-emulated APIs in analysis output and fix
pluginkitparsing (30509f8), see Analyzer. - Bug-hunt fixes: 3 conversion bugs (
cef6aee); bundle-id collisions + a symlink leak in staging (b0c81a1); native-proxyResponsecrashes on out-of-range/null-body status (3a16e42); shimfill()no longer clobbers legitimately-falsy native members (273272b); sanitize hosts/origin in generated native-proxy Swift literals (dab04f1); fix report handling of the MV2page_actionfold (72158b8).
Theme: emulate complex Chrome APIs behind a catch-all shim safety net; keyboard-shortcut management.
- Emulate complex Chrome APIs and add a catch-all shim safety net so unknown
chrome.*access degrades gracefully (75d6cd2), see Runtime Shim. - Make keyboard-shortcut management work in Safari, which has no
chrome://extensions/shortcutspage (503e14f), and warn at build time on hardcodedchrome://extensions/shortcuts(orsettings) links (f74597a); documented in the shim feature list (e7d1653), see Safari Quirks. - First real test suite lands and hardens the release:
applyDnr(7e8477d),analyzeManifestCSP/host-misplacement (1156251),scanExtensiondetections (2cc6c34),extract.tsCRX3/zip-slip (748a9eb), plus a DNR keyless-ruleset fix and sidepanel-open test (cd9b73c). - GitHub issue templates for conversion failures, broken extensions, and feature requests (
ed8be98, later trimmed in2d7b4ac).
(This tag range also sweeps up the tail of the README ASCII-banner iteration 700c321→85e408c and the final publishing-pipeline commits 1d728d2→47b3dea, see the v1.2.x note below.)
Theme: the rename to viaduct, @magicelk235 scoping, and the Trusted-Publishing burst.
This is where chrome2safari became viaduct and npm publishing was rebuilt:
- Rename
chrome2safari→viaductacross README, package, CLI, and source (6624163,c497683); scope the package as@magicelk235/viaductand add a release publish workflow (aaa1437); switch to npm Trusted Publishing (OIDC) instead ofNPM_TOKEN(df4346f), see Build and Install. -
Publishing-pipeline hardening (one note for the whole burst). Standing up OIDC Trusted Publishing took an iterative sequence of CI/npmrc fixes,
1197fd9(bump past the burned 1.2.6 publish),a983d3a,2a8ce3f(run onmacos-latest, the package isos:darwin),cca58cd(shell-expanded test glob),1d728d2(pin node, verify npm version),01cc7a7/8073d4e/0dadd91(registry-url and.npmrctoken-auth juggling so OIDC isn't shadowed),6038846/47b3dea(diagnostics added then removed once proven). The 1.2.6/1.2.7 versions were consumed pre-tag; v1.2.8 is the first tag published via Trusted Publishing. - Shim/analyzer work carried in this window: shim
chrome.windows/devtools/appplus a CORS proxy and build-config hardening (8255ebc); first test suite + CI gate with corpus-run bug fixes (0dd9519);oauth-bridgeidempotency, CSP sandbox scoping, optional-perm host warning, CDP MAIN-world eval (0fe0447), see OAuth Bridge; derive extension id generically instead of hardcoding Claude's (208eaa6); two converter-hardening sweeps (0ed107a,7395bec); flag native-messaging usage for Safari (a9c2e80). - Manifest/CLI/analyzer additions earlier in the window: manifest compat transforms + validation + input hardening (
b20954b); CLI lifecycle flags and new compat checks (a40114e,24424b4); completechrome.i18nand harden the runtime shim (1f3af6f); harden staging/extraction/reporting (781b01c). - Popup sizing on Safari iterated toward "floor only, never overrides the app's own size" (
0601904,b33698a,fb05734,bf77318), see Safari Quirks. Service-workerimportScriptshoisted into the background page for a CSP-safe fix (fc49074).
Theme: DNR sanitize, broaden the shim, unified scanner + i18n, packaging robustness.
- Sanitize static DNR rulesets for Safari (
55d8b0e), see Manifest Transform. - Broaden
chrome.*polyfill coverage and fix runtime gaps (a69e926), see Runtime Shim. - Unified scanner with i18n name resolution and richer checks (
744822b), see Analyzer. - Packaging robustness and lockfile sync (
f415d0c); publish-workflow token fix (7dc63b0), and a post-tag URL fix for provenance validation after the repo rename (e1d9317).
Theme: expanded compat analysis, stub no-equivalent namespaces, bundle the polyfill.
- Expand Safari compatibility-analysis coverage (
0d9e8e4), see Analyzer. - Stub no-equivalent
chrome.*namespaces in the compat shim (1232fff), see Runtime Shim. - Bundle
webextension-polyfillforbrowser.*parity (67e2039). - CI to auto-publish to npm with provenance on version tags (
45e6366), the first publishing automation, later replaced by OIDC in the v1.2.x burst.
Theme: the initial converter, made npm-publishable.
The 1.0 line predates tags, it runs from the scaffold at 2fd033c through the v1.0.1 tag. This is the whole first-cut pipeline:
- TypeScript CLI scaffold and workspace hygiene (
2fd033c); shared types and process/IO utilities (ac8f154). - zip/crx/dir extraction with xattr cleanup (
3846882); Safari manifest analysis/transform and a JS API scanner (5103497), see Manifest Transform and Analyzer. - The runtime compatibility shim,
storage.sync,identity,sidePanel,notifications(530a7ed), see Runtime Shim. - Clean asset staging into a persistent output dir (
7f0e825); packager wrapper with built-.appexbundle-id verification (e3e3007); pipeline orchestrator, CLI, reporting, and Safari 18 temp-load (c212d81), see Build and Install. - Safari OAuth bridge, identity polyfill + page bridges (
53b8e3c), see OAuth Bridge; DNR ruleset to pinOriginforapi.anthropic.com(5d1d542); a second runtime shim pass with achrome.debuggerpolyfill and SW→background-page conversion (2b09dd6). - Safari fixes that made the output actually load: extension discovery + popup compatibility (
a3a7ef5), persistent install via team signing + crash fixes (1a6ecef), MV3 non-persistent background + move-not-copy of the built app (bd3bbc7), and the analyze exit code / empty-action injection /debugger.onDetachfixes (146b6ca). - Made the package npm/npx-publishable (
3013af2) and cut the release (c996214).
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.