Skip to content

Repository files navigation

linkward

Asks where a link should open — before it opens.

When another application hands a link to your browser — Slack, Outlook, Teams, a terminal — it lands wherever the browser felt like putting it. Usually the wrong identity. linkward stops it first and asks:

  • open it in this container,
  • open it without one,
  • or copy the link and open nothing at all.

On Firefox the request is stopped before it is sent, so the page is never fetched, no cookie is set, and no session is started in the wrong container.

Answer once for a host and tick remember, and it stops asking about that one. Those live in the settings page, where you can move a host to another container or drop it to be asked again — and they follow your browser account, because they are stored by the container's name, not by the id the browser minted for it on one machine. A rule for a container this browser does not have is never guessed at: it asks.

What it does with a link

flowchart TD
    A["A link arrives<br/>from outside the browser"] --> B{"Could this be<br/>an external link?"}
    B -->|"a page opened it,<br/>or you were already<br/>browsing in that tab"| L["Leave it alone"]
    B -->|"nothing in the browser<br/>can account for it"| C{"On the<br/>never-ask list?"}
    C -->|yes| L
    C -->|no| D{"Answered for<br/>this host before?"}
    D -->|"yes, and that container<br/>still exists here"| E["Open it there.<br/>No question."]
    D -->|"yes, but it said<br/>no container"| L
    D -->|"no — or the container<br/>is gone or renamed"| F["Ask"]
    F --> G["Open in a container"]
    F --> H["Open without one"]
    F --> I["Copy it, open nothing"]
    F --> J["Close the tab"]

    style F fill:#2f6feb,color:#fff
    style E fill:#eaf1ff,color:#16181d
    style L fill:#8080801f,color:#8a8f98
Loading

Every branch that is not the blue one errs towards not interrupting you. The one that matters most is "the container is gone or renamed": a remembered rule that cannot be resolved on this machine asks rather than guessing, because opening the wrong identity is the failure this exists to prevent.

The keyboard

The picker interrupts something, and it is seen many times a day, so it does not insist on the mouse:

Key
19 open in the nth container
Enter the one offered first — the container used last, or plainly if there are none
c copy the link, open nothing
Esc close the tab

Anything with ⌘, Ctrl or Alt held is left alone: those belong to the browser, and taking ⌘C from somebody copying the address off the page would be its own small betrayal.

Why it asked

The page says how long ago the tab appeared and what it could not account for:

Asked because this tab was opened 2.4s ago and nothing in the browser accounts for it — no page opened it, and you had not been browsing in it.

This is not decoration. The detection is a process of exclusion, so when it gets one wrong there has to be something to point at — for whoever was interrupted, and for whoever they report it to.

The honest part, up front

Firefox does not tell an extension that a link came from another application. It knows — isExternal, in BrowserDOMWindow.sys.mjs — and it does not expose it. What an extension sees for a link handed over by Slack is transitionType: "link": byte for byte the same as a click on a web page. Mozilla's own bug for this has been open since 2022.

So linkward does not detect external links. It excludes everything it can positively identify as something else — a tab with an opener, a navigation a document started, a tab you have already been browsing in, and a tab that started on one of the browser's own pages rather than on a link — and asks about what is left. Every rule errs towards not asking, because interrupting a link you clicked yourself is the failure that gets an add-on uninstalled.

The rules are one small, pure file — src/lib/candidates.js — and it is the most heavily tested thing in the repo.

Bookmarks and typed addresses

Those exclusions leave four things that look identical to an extension — a new tab, an http(s) address, no opener, navigating within a few seconds:

What actually happened
a link handed over by Slack, Outlook, a terminal the one linkward wants
a bookmark must be left alone
an address typed or pasted into a new tab must be left alone
a search from the address bar must be left alone

The field that names them is transitionTypeauto_bookmark, typed, link — and it exists only on webNavigation.onCommitted, which fires after the request has already gone. The two builds therefore answer this differently, each with the best evidence its browser actually offers:

flowchart TD
    S["New tab, http(s) address,<br/>no opener, first navigation"] --> B{"Which build?"}
    B -->|Chromium| T{"What does<br/>transitionType say?"}
    T -->|"typed, generated, auto_bookmark,<br/>keyword, reload, start_page …<br/>or the from_address_bar qualifier"| IN["Started in here"]
    T -->|"link, or anything unknown"| OUT["Handed over by<br/>another application"]
    B -->|Firefox| Q{"How long had the<br/>browser been in front<br/>when the tab appeared?"}
    Q -->|"longer than 1.5s"| IN
    Q -->|"it had just come to the front"| OUT
    Q -->|"still behind something else,<br/>or nothing recorded"| OUT
    IN --> L["Left alone"]
    OUT --> P["Ask where it should open"]
    L -.->|"setting: ask about<br/>these too"| P

    style P fill:#2f6feb,color:#fff
    style L fill:#eaf1ff,color:#16181d
Loading

Chromium waits for onCommitted and believes it. Nothing on that side could hold the request anyway — MV3 removed blocking webRequest, so the earlier onBeforeNavigate only ever raced the navigation it was reacting to. Waiting costs a page flash and buys an exact answer, which is a good trade when the alternative is guessing. The list in candidates.js names what to exclude, so a Chromium that invents a new transition leaves linkward asking rather than going quiet on a string nobody has looked at.

Firefox holds the request before it is sent — the reason to use that build — and at that moment the browser has said nothing about how the navigation started. So it falls back to the one question that is answerable beforehand and that differs between the four: was the browser already in front? A bookmark, the address bar, a history entry are all somebody already in the browser, using it. A link from another application is by definition somebody who was not: they were in Slack, and the browser is being brought to the front to receive it. windows.onFocusChanged says which just happened, and needs no permission.

That fallback is a proxy, not the fact, and it is wrong in two places. Both are named here rather than left to be discovered. Neither applies to the Chromium build, which does not ask the question at all:

  • A link that arrives without raising the browseropen -g, a cron job, a script — looks like something you did in here, and is not asked about.
  • Come to the browser and open a bookmark inside a second and a half and you are asked, as before — including when you only moved between two browser windows. That last one is a choice: not counting a window switch would mean keeping a timestamp across a focus change, and the loss of focus is the half neither browser reports reliably — Firefox does not fire it when you leave a non-browser window for another application, open since 2017, and Chrome has been reported not to fire it on minimise. One missed report and every later hand-off would look like something you did in here and never be asked about again. Bounded and annoying beats unbounded and silent.

On Firefox for Android there are no windows to focus, so the API is absent and none of this applies: linkward asks there exactly as it did before.

Both err towards asking, which is the direction every other rule in that file errs in too. And if the browser will not say — no windows API, no storage.session to keep the answer in across an event page the browser tears down when it likes — the answer is "we do not know", which behaves exactly like linkward did before any of this: it asks.

The proxy shipped first, on both builds, and the report that followed is why Chromium no longer uses it: copy a link somewhere, switch to the browser, paste it into the address bar — a tab created a second after the browser came to the front, which is exactly what a hand-off looks like. No grace period separates those two, which is what "proxy" means.

Settings → Ask about bookmarks and addresses I type myself turns the whole rule off, for people who would rather be interrupted than miss one. It is off.

Chrome

sequenceDiagram
    participant App as Slack, Outlook, …
    participant B as Browser
    participant L as linkward
    participant S as The server

    rect rgba(47, 111, 235, 0.12)
    Note over App,S: Firefox — before the request is sent
    App->>B: open this link
    B->>L: onBeforeRequest (blocking)
    L-->>B: hold it
    L->>L: ask, or apply a remembered rule
    L->>B: open in the chosen container
    B->>S: first and only request
    end

    rect rgba(128, 128, 128, 0.12)
    Note over App,S: Chrome — it cannot be held, so it waits to be sure
    App->>B: open this link
    B->>S: request goes — MV3 has no blocking form,<br/>so nothing here could stop it
    B->>L: onCommitted, with transitionType
    Note right of L: waits for this on purpose:<br/>it is the only event that says<br/>HOW the navigation started
    L->>B: turn the tab around
    Note right of L: the page may flash —<br/>and there are no<br/>containers to offer
    end
Loading

Two things are different, and the picker says both on the page rather than hiding them:

  • Chrome MV3 removed blocking webRequest, so linkward can only turn the tab around once the navigation has begun. The page may flash. Since nothing there can hold a request either way, the Chrome build waits for onCommitted and takes the browser's own transitionType rather than guessing — see bookmarks and typed addresses.
  • No extension can open a tab in another Chrome profile. tabs.create takes a window to aim at and no profile, because an extension in one profile cannot see that the others exist. A profile can only be chosen before the browser is handed the link — what actually works.

Containers are a Firefox feature. The Chrome build ships without the container permissions at all.

Permissions

Nothing is granted at install. Everything below is requested from the options page, in one call, when you switch the feature on — and handed back when you switch it off.

Permission Why
<all_urls> The only one you actually see: "Access your data for all websites". It is what lets linkward stop a page before it loads. Without it there is nothing to intercept.
webRequest, webRequestBlocking Firefox only. Silently granted. Stopping the request rather than reacting after it.
webNavigation Chrome only. There is no blocking form there.
contextualIdentities, cookies Firefox only, required by the manifest: reading your containers' names and colours, and honouring a cookieStoreId when opening.

linkward reads no page content, stores nothing about where you go, and sends nothing anywhere. See PRIVACY.md.

Install

In review at both stores.

npm install
npm run build:firefox   # dist-firefox/
npm run build           # dist/

Firefox: about:debugging#/runtime/this-firefoxLoad Temporary Add-ondist-firefox/manifest.json. Chrome: chrome://extensionsDeveloper modeLoad unpackeddist/.

Then switch it on. linkward opens its settings page by itself the first time, and it does nothing at all until the switch there is on: the access it needs is asked for at that moment, not at install, and a browser only grants it on a click. Before that, links open exactly as they always did — which looks identical to a broken install, so the page says so in as many words.

The settings file

Settings → Settings file → Export writes the settings that travel, and the remembered hosts, as JSON — two fields are left out on purpose, see below. Import replaces both. The shape is stable and small enough to edit by hand:

{
  "format": "linkward-settings",
  "version": 1,
  "settings": {
    "neverAsk": ["intranet.example", "mail.example"],
    "rememberPrompt": "ticked",
    "askInternal": false
  },
  "rules": {
    "docs.example.com": {
      "container": "Work",
      "cookieStoreId": "firefox-container-2"
    },
    "shop.example": {
      "container": null,
      "cookieStoreId": "",
      "plain": true
    }
  }
}
Field Meaning
format Always linkward-settings. A file without it is refused, rather than half-read.
version 1. A higher number is refused: a newer linkward may mean something else by these fields. A lower one is read.
settings.neverAsk Hosts to leave alone entirely. Subdomains included. Trimmed, de-duplicated and sorted on import.
settings.rememberPrompt What the picker does with its "remember this host" box: hidden, unticked or ticked.
settings.askInternal true to ask about bookmarks and addresses you type yourself as well — see above. Anything but true reads as off.
rules One entry per remembered host, keyed by the host in lower case.
rules[host].container The container's name. This is what a rule is matched on.
rules[host].cookieStoreId The id that name had on the machine that wrote the file. Only ever used for a rule that carries no name — see below.
rules[host].plain true means "always open this host with no container". Absent otherwise.

Two fields are deliberately not in the file:

  • enabled stands for a permission a browser only grants on a click, and a file cannot click. Importing it would produce a page claiming the feature is on while nothing is listening.
  • lastContainer is a cookieStoreId, which means a different container on the machine the file arrives at.

Why a rule stores a name and an id

flowchart LR
    R["rule:<br/>container 'Work'<br/>id firefox-container-2"] --> N{"A container<br/>called 'Work'<br/>on this machine?"}
    N -->|yes| U["Open there"]
    N -->|no| I{"Does the rule<br/>carry a name<br/>at all?"}
    I -->|"yes — so the name<br/>was tried and missed"| A["Ask"]
    I -->|"no: written before<br/>names were stored"| C{"Does that id<br/>still exist here?"}
    C -->|yes| U
    C -->|no| A

    style A fill:#2f6feb,color:#fff
    style U fill:#eaf1ff,color:#16181d
Loading

A cookieStoreId is minted per profile and reused. Syncing one alone would mean the same rule opening a different container on another machine — a rule for Admin whose old id now belongs to Work would open Work, silently. So the id is a fallback for legacy rules only, and a named rule that cannot be matched by name asks.

Seeing it work

A link clicked on a page is deliberately never intercepted, so demonstrating this from inside the browser demonstrates nothing. The script hands links to the operating system instead, the way a mail client does:

node scripts/demo.mjs                    # one link
node scripts/demo.mjs --all              # the tour, one at a time
node scripts/demo.mjs --browser=Firefox  # ignore the default browser
node scripts/demo.mjs https://your.intranet/page

Chromium profiles

No extension can open a tab in another Chromium profile. Not linkward, not anything — this is worth stating plainly, because "not supported yet" would imply it is coming.

chrome.tabs.create takes a window to aim at and nothing else; there is no profile parameter, and chrome.windows.create has none either. The reason is below the API: an extension installed in one profile cannot see that the others exist. Profiles are the isolation boundary, and extensions live inside one.

The same holds in Vivaldi, and for its Workspaces — Vivaldi's own forum staff put per-workspace extension settings down to "Vivaldi/Chromium core restrictions", and routing a URL to a profile by rule is an open feature request, not an API.

So a profile can only be chosen before the browser is handed the link, by whatever hands it over:

Route What it costs
Command line — vivaldi --profile-directory="Profile 2" <url> nothing; works today
An OS-level router — Choosy, BrowserOSaurus, Velja a second app, set as the default browser
A native-messaging host a binary and a manifest per machine, installed outside the browser, and the kind of thing store reviewers read closely

"Can the browser not just call itself?"

It can — and that is exactly what the first two routes do. The catch is that an extension is not a process. It is JavaScript in a sandbox with no way to start one, so every route to vivaldi --profile-directory=… runs through something registered with the operating system.

The obvious shortcut is a custom URL scheme: register linkward:// with the OS, have the picker navigate to it, let the OS launch the handler. It works, and it saves the native-messaging manifest — but not the installer. In both models the native application is installed by the OS's own machinery rather than by the browser, so a user still has to install something.

And it would be worse to use. Chromium's list of schemes that may launch without asking is compiled in, not built from what is on the machine, so a custom scheme prompts every single time: linkward asks where the link should go, then Chromium asks whether it may open linkward. Two dialogs for one link. Only an enterprise policy (AutoLaunchProtocolsFromOrigins) removes the second, and that is not something an extension can ship.

So linkward ships none of these on purpose. It is an extension that needs one switch and no installer — a native host would trade that for a feature only Chromium users could ever get, while Firefox already does the whole job inside the browser, with containers, and no helper at all.

scripts/demo.mjs takes the first route:

node scripts/demo.mjs --browser=Vivaldi --profile="Profile 1" https://example.com/
# which is:  open -a Vivaldi -n --args --profile-directory="Profile 1" <url>

Profile directories are Default, Profile 1, Profile 2, … — the folder names under the browser's user-data directory, not the names shown in its UI.

Two more things follow from profiles being sealed off, and they surprise people:

  • Extensions are per profile. linkward installed in one is not installed in the other; the second profile opens links exactly as it did before.
  • Installing it in both does not let it move a link between them. It only means the question gets asked in whichever profile the link lands in.

The container extensions for Chromium solve a different problem

There are extensions that bring Firefox-style containers to Chromium — SessionBox, Cookie Profile Switcher, and several free imitations of Mozilla's Multi-Account Containers. They are real and some of them are good, but they are not an answer to the question on this page.

What they give you is isolated sessions inside one profile: a per-tab cookie jar, swapped through the cookies API, so you can be signed in twice to the same site. What they cannot give you is another profile, because that is the boundary described above and no extension crosses it.

The isolation is also thinner than Firefox's. Cookies and site storage are separated; the fingerprint, the IP and the browser build are not, so every session still looks like the same machine. Firefox containers have the same ceiling — neither changes your fingerprint — but they are implemented by the browser rather than reconstructed on top of it, and users report the extension kind losing sessions after a while.

If you use one, the two get along by staying out of each other's way: put the hosts it manages on the never-ask list, and linkward never reaches them. There is nothing to wire up and no setting connecting them — those hosts simply never become a question.

That is the whole of the interoperability, and deliberately so. SessionBox has no externally_connectable surface and no deep-link scheme; its own support pages say the extension has no API. The one supported route is an npm toolkit driven by an API key from a local Node process — the helper-process cost again, with a third party and a key on top.

linkward does not do this and will not. Holding somebody's cookie jars is a different product with a different failure mode: when it goes wrong, you are silently signed in as the wrong person, which is precisely what this exists to prevent.

Firefox has none of this trouble: containers live inside one profile, so the add-on really can open the link in the right one.

If Firefox has no containers yet

Containers are built into Firefox — linkward reads them through contextualIdentities and needs nothing else. What Firefox does not give you is an obvious way to make one, which is why almost everybody has Multi-Account Containers, Mozilla's own add-on, installed to create, name and colour them.

linkward does not depend on it and does not talk to it. It lists whatever containers exist, whoever made them, and there is no setting connecting the two. Install it, make the containers you want, and they appear in the picker.

The picker says as much when it finds none, rather than reporting an empty list and leaving you to work out why.

Development

npm test          # vitest
npm run ci        # lint + format + coverage + package, the same gate CI runs

No bundler and no runtime dependencies: the source under src/ is the artifact. An extension that intercepts navigations should be readable end to end by whoever reviews it.

Publishing

Two stores, one release workflow, and a setup script that creates the Chrome Web Store item and writes every secret so nobody has to click through two dashboards. What is left is the part no API can do — the store listing and the permission justifications.

docs/publishing.md

Author & License

Sebastian Winterberger · MIT

About

linkward — asks which Firefox container to open a link in, before it opens. Or just copies the URL.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages