How Linkumori works inside the browser: what runs where, how rules are loaded, and what happens to a request. It is written for people changing the code. The rule format itself is in filter-syntax.md; building and linting rules are in CONTRIBUTING.md.
File and function names are given so you can jump to the code. Line numbers are not, because they move.
1Overview
Linkumori is a Manifest V3 Firefox extension (desktop 140+, Android 142+). It has three kinds of runtime code:
| Part | Where it runs | What it does |
|---|---|---|
| Background page | one event page, 23 classic scripts (manifest.json background.scripts) | loads rules, holds settings, cleans requests with blocking webRequest, answers the pages |
| Extension pages | html/*.html with their core_js/*.js script | popup, settings, custom rules editor, log, tools; they talk to the background by messages |
| Content scripts | registered at runtime, only while search link fix is on | Google and Yandex result-link fixes |
There is no server. The only network requests Linkumori makes on its own are for remote rule lists, and only when the user configures them (§5.2).
Diagram source
flowchart LR
subgraph Browser
WR[webRequest events] --> BG
NAV[webNavigation events] --> BG
subgraph BG[Background page]
ST[storage.js<br/>settings + rule loading]
EN[clearurls.js<br/>engine]
OT[history, context menu,<br/>DNS, whitelist, ETag,<br/>badge, log, watchdog]
end
PG[Extension pages<br/>popup, settings, editor, log, …] <-- runtime messages --> BG
BG -- scripting.executeScript --> TAB[Web page]
CS[Content scripts<br/>Google / Yandex link fix] --> TAB
end
BG <-- browser.storage.local --> DISK[(Storage)]
BG -. optional .-> REMOTE[Remote rule list + hash]2The background page
All background scripts are classic scripts in one global scope. They call each other's functions directly (pureCleaning(), getData(), isWhitelisted(), …), so load order matters: a script may only call another script's functions at runtime, never while loading, unless that script is listed earlier.
| Order | Script | Role |
|---|---|---|
| 1 | external_js/linkumori-i18n.js | LinkumoriI18n: replacement for browser.i18n that loads _locales/<lang>/messages.json itself |
| 2 | core_js/message_handler.js | runtime.onMessage bridge for the pages (§10) |
| 3 | external_js/IP-Ranger.js | IP address parsing and classification |
| 4 | external_js/sha256.js | SHA-256 |
| 5 | core_js/tools.js | shared helpers: translate, URL helpers, counters, icon, log (pushToLog), keep-alive |
| 6–7 | external_js/light-punycode.js, external_js/publicsuffixlist.js | punycode; Public Suffix List parser (linkumoriPsl) |
| 8 | core_js/badgedHandler.js | per-tab badge counter |
| 9 | core_js/pureCleaning.js | cleaning outside webRequest, and the rule test lab (§7.1) |
| 10 | core_js/context_menu.js | Copy clean link context menu |
| 11 | core_js/historyListener.js | cleans URLs changed with the History API (§7.2) |
| 12 | external_js/regex_analyzer.js | regex analysis |
| 13 | core_js/linkumori_rule_ids.js | LinkumoriRuleIds: generated rule ids, shared with the editor and CLI (§6.2) |
| 14 | core_js/linkumori_rule_pins.js | LinkumoriRulePins: ids pinned for switched-off rules, shared with the editor and CLI (§6.2) |
| 14a | core_js/linkumori_rule_patches.js | LinkumoriRulePatches: checks and applies remote rule patches, shared with the CLI (§5.3) |
| 15 | clearurls.js | the engine: providers, request cleaning, start() (§6) |
| 16 | core_js/linkumori_dns.js | LinkumoriDNS: CNAME uncloaking (§6.5) |
| 17 | core_js/whitelist.js | whitelists and the temporary tab whitelist (§8) |
| 18 | external_js/linkumori_lz4_block.js | LinkumoriLZ4: decompresses the bundled rules |
| 19 | core_js/storage.js | settings, persistence, rule loading; calls genesis() last (§3–§5) |
| 20 | core_js/content_script_manager.js | registers the search link fix content scripts (§7.3) |
| 21 | core_js/watchdog.js | self-test every minute (§9) |
| 22 | core_js/eTagFilter.js | ETag header filtering (§7.4) |
| 23 | external_js/decode-uri-component.js | lenient URI decoding for redirect targets |
| 24 | core_js/consent_config.js | current policy and POSAR versions for the consent gate |
consent_config.js loads last, but genesis() reads its values only after an asynchronous storage read, so they are defined by then.
Main globals
| Global | Defined in | Holds |
|---|---|---|
storage | storage.js | in-memory copy of every setting and of the loaded rules (§4) |
providers, providersByToken, globalProviders | clearurls.js | the compiled providers and the lookup index (§6.3) |
requestContextManager | clearurls.js | URLs of each tab and frame, for context checks (§6.6) |
clearurlsProviderSnapshot | clearurls.js | every active and disabled rule with its ids, read by the editor's Disabled rules page |
badges | badgedHandler.js | per-tab badge counts |
temporaryTabWhitelist | whitelist.js | per-tab domains allowed for this tab only |
Keeping the page alive
Firefox can suspend an MV3 background event page when it is idle. tools.js calls browser.runtime.getPlatformInfo every 20 seconds (keepAlive) so the page, its in-memory storage and its compiled providers stay loaded.
3Startup
storage.js calls genesis() once all background scripts have loaded:
Diagram source
sequenceDiagram
participant G as genesis()
participant L as browser.storage.local
participant P as Public Suffix List
participant R as loadBundledRules()
participant E as start() (clearurls.js)
G->>L: get(null)
L-->>G: all saved keys
G->>G: initStorage(): defaults, then setData() for each saved key
G->>G: consumePostReloadOpenUrl()
G->>P: initLinkumoriPublicSuffixList()
G->>R: load and merge rules (§5)
R-->>G: storage.ClearURLsData
G->>E: startClearurlsIfConsentGranted()
G->>G: changeIcon(), contextMenuStart(), historyListenerStart()If rule loading fails, the same last four steps still run.
Consent gate
Cleaning only starts once the user has accepted the current privacy policy, the current POSAR version and the adult confirmation:
consent_config.jssetsLinkumoriversionandLinkumoriPOSARversion.hasPopupConsentForStartup()compares them with the accepted versions in storage (popupConsentPolicyVersionAccepted,popupConsentPOSARVersionAccepted,popupConsentAdultAccepted).- Until they match,
start()is not called, so there are no providers and nowebRequestlistener. The ETag filter and History API cleaning are registered at load time, so they checkisCleaningStarted()and do nothing before then. The context menu is also created, but with no providers it copies links unchanged. - Accepting in the popup or on
legal.htmlwrites those keys. Astorage.onChangedlistener in storage.js sees them and starts cleaning without a restart. - When the policy version is raised, earlier consent no longer counts: cleaning does not start, and
legal.htmlopens.
On first install, storage.js opens html/legal.html?source=first_install.
start()
start() in clearurls.js:
- starts Public Suffix List support (
initPslSupport) andrequestContextManager; - calls
tryInitialize(), which builds the providers fromstorage.ClearURLsDataand registers thewebRequestlistener. If no rules are loaded yet, it retries every 200 ms, up to 50 times, then registers the listener anyway; - calls
setBadgedStatus().
It also defines globalThis.updateProviderData(), which rebuilds the providers from storage.ClearURLsData without a restart (§5.5).
4Settings and persistence
storage is an in-memory object holding every setting. It is the source of truth while the extension runs; browser.storage.local is its saved copy.
- Defaults:
initSettings()sets every default. Remote rules, logging, ETag filtering, redirect bypass and the search link fix start off. Built-in rules, CNAME uncloaking, domain blocking, ping blocking, the History API listener and the context menu start on. - Loading and writing:
setData(key, value)normalises each key as it loads or changes: JSON strings are parsed, booleans coerced, remote URLs validated. It also migrates renamed keys (globalurlcounterbecomestotalCounter) and drops obsolete ones. - Saving:
saveOnDisk(keys)writes keys now.deferSaveOnDisk(key)queues a key and writes the queue one minute later (thedeferSaveOnDiskalarm); counters and the log use this, because they change on every request.saveOnExit()writes everything. - Changes made elsewhere: some modules also listen to
storage.onChanged, for keys that pages write straight tobrowser.storage.local:globalStatusand the theme update the toolbar icon;contextMenuEnabledrebuilds the context menu;searchLinkFixEnabledregisters or removes the content scripts;- the consent keys start cleaning (§3).
Keys that shape the runtime:
| Key | Meaning |
|---|---|
ClearURLsData | the merged rules the engine runs (§5) |
custom_rules | the user's custom providers, as saved by the editor |
customrules_import_exclusions | providers the user switched off (by provider signature) |
clearurls_disabled_rule_ids | rules the user switched off, as <scope>::<ruleId> (§6.2) |
userWhitelist, historyApiWhitelist | whitelists (§8) |
builtInRulesEnabled, remoteRulesEnabled, overloadModeEnabled, overloadModeStrategy | which rule sources to use and how overload mode handles patches (§5) |
ruleURL + hashURL, remoteRuleSets | remote rule sources |
remoteRulescache, remoteRulesHealth | last verified remote rules; fetch history |
hashStatus, hashValidationStatus, dataHash, mergeStats, rulesMetadata | what was loaded, from where, and how it merged; shown in settings |
globalStatus, temporaryPauseUntil | cleaning on/off; pause end time |
types, pingRequestTypes | request types the webRequest listener receives |
redirectionEnabled, domainBlocking, pingBlocking, referralMarketing, localHostsSkipping, eTagFiltering, linkumoriCNAMEUncloakEnabled, … | feature switches |
totalCounter, cleanedCounter, log, logLimit, loggingStatus | statistics and the log (§9) |
statisticsStatus | counters on/off (default on) |
badgedStatus, badged_color | the toolbar badge on/off (default on) and its colour (default #2563eb) (§9) |
historyListenerEnabled | History API cleaning on/off (default on) (§7.2) |
unboundedCyclesEnabled | lift the 10-cycle limit on cleaning a request (default off) (§6.4) |
ruleDefaultsMode, userRuleDefaults, sourceRuleDefaults | rule defaults: where the user's own defaults replace the files' (source, final or unified; default source), the user's own defaults block, and the user's edits of a file's own block, by source (defaults) |
remoteRuleCapabilities | what each remote file can do and what the user accepted, as { files: { [ruleURL]: { derived, accepted } } } (§5.2) |
disableGatekeeper | skip the confirmation for remote rule addresses (default off) (§11.2) |
The interface language is not in storage: the language selector in settings writes linkumori_language to browser.storage.sync, and external_js/linkumori-i18n.js reads it on every page. Left unset, the browser's language is used.
5Rule loading
loadBundledRules() in storage.js decides which rule sources to use, merges them with the custom rules, and stores the result in storage.ClearURLsData.
5.1Sources
| Source | Where it comes from |
|---|---|
| Bundled | data/linkumori-clearurls-min.json.lz4, built by node linkumori-cli-tool.js clearurls from data/linkumori-clearurls.json and decompressed with LinkumoriLZ4 |
| Remote | one or more ruleURL + hashURL pairs set by the user (§5.2) |
| Remote cache | remoteRulescache: the last remote rules that passed verification |
| Custom | custom_rules, from the custom rules editor |
| Fallback | a small built-in list in getEnhancedFallbackRules(), used when nothing else loads |
5.2Remote rules
Remote rules are off by default. Each source is a pair of URLs, and both must be valid (areValidRemoteURLsPresent(), isValidRuleURL()).
- Fetch the hash URL: 32–128 hex characters (
fetchRemoteHash). - Fetch the rule URL, and check that the SHA-256 of the raw text matches (
fetchRemoteRules,verifyRulesHash). A mismatch rejects the source. - Parse it as Linkumori JSON with a
providersobject.
Both requests use cache: 'no-store', so the rules and their hash always come from the same moment. Sources are fetched in parallel. The ones that pass are merged (mergeRemoteRulesSources): providers with the same pattern and settings become one provider (getProviderGroupKey, mergeRemoteProviderGroup). The result is saved as remoteRulescache.
Within a merged provider, two rules count as the same when everything that changes what they do matches, targetId, order and historyBypassProtection included (getStableRuleSignature). Only one is kept, and the other's id and aliases become aliases of it. An inactive provider adds nothing to its group, and a provider-level historyBypassProtection is copied onto its rules, since the merged provider can hold only one.
Each source is checked for rule collisions (LinkumoriRuleIds.findRuleCollisions), and so is each merge, for the collisions merging creates. A merge is also checked for the same text in rules from one file and referralMarketing from another (LinkumoriRuleIds.findReferralTextCollisions). They are logged and shown in settings, and the rules still load.
The outcome of every attempt is recorded in remoteRulesHealth, which settings shows.
Capabilities. When a remote file loads, resolveRemoteFileCapabilities records what its entries can do (LinkumoriRuleIds.deriveRuleFileCapabilities) in remoteRuleCapabilities. strip, rewrite and except are active at once; redirect and block stay off for that file until the user accepts them in Remote Rules Health, which calls acceptRemoteRuleCapabilities and reloads the remote rules. Entries that need a capability the file lacks are left out (restrictRuleFileCapabilities). Without redirect, every raw rule is kept but tagged _linkumoriNoRedirect: a raw rule can take the URL to another scheme, host or port by a rewrite or just by deleting text, which only shows when it runs, so rawRuleMayChangeOrigin() in clearurls.js then leaves such a URL unchanged. The verified cache can't be traced to one file, so when it loads, strip, rewrite and except are active and redirect and block only if every configured file has accepted them (getCapabilitiesAcceptedByAllRemoteFiles). See CLN 1.0 §Remote file capabilities.
5.3Choosing the sources
Diagram source
flowchart TD
A[loadBundledRules] --> B{built-in off<br/>and remote off?}
B -- yes --> C[custom rules only]
B -- no --> D{remote sources<br/>configured and valid?}
D -- no --> E{built-in on?}
E -- yes --> F[bundled LZ4]
E -- no --> C
D -- yes --> G[fetch + verify every source]
G -- at least one passed --> H{overload mode?}
H -- yes --> I{overload strategy}
I -- patch-only --> I1[bundled rules + remote patches]
I -- patch-and-merge --> I2[remote merged with bundled,<br/>then patched]
I -- merge-only --> I3[remote merged with bundled,<br/>patches skipped]
H -- no --> J[remote]
G -- all failed --> K{verified cache<br/>for these sources?}
K -- yes --> L[cached remote<br/>+ bundled in overload mode]
K -- no --> E2{built-in on?}
E2 -- yes --> F
E2 -- no --> C
F -- LZ4 fails --> M[remote cache, else last<br/>ClearURLsData, else fallback list]
I1 & I2 & I3 & J & L & F & M --> N[mergeCustomRules]
C --> O[ClearURLsData]
N --> ORule patches. A remote file's patches (see filter syntax §Rule patches) are applied by applyRemoteRulePatches() to the rules chosen above, just before mergeCustomRules, including when the remote cache is used. With overload mode on, overloadModeStrategy selects patch-only (use bundled rules as the base and omit remote providers), patch-and-merge (merge remote and bundled providers, then apply patches), or merge-only (merge providers and skip patches). With overload mode off, the strategy is ignored and existing remote rule behavior applies. When every loaded remote file holds only patches, the base is the bundled rules, unmerged so their provider names stay (withPatchOnlyBuiltInRules). Each patch's result (applied, unmatched or skipped) is stored in remoteRulesHealth.rulePatches.
hashStatus records which path was taken, for example bundled_rules_loaded, remote_verified, remote_rules_partially_merged, cache_remote_rules_after_remote_failure or fallback_rules_used. When custom rules are merged in, it gets a custom variant, such as custom_rules_merged.
5.4Merging custom rules
mergeCustomRules(baseRules) builds the final list:
- Providers the user switched off (
customrules_import_exclusions) are removed from both the base rules and the custom rules. - Providers without
urlPatternordomainPatternsare dropped. - A custom provider replaces every base provider with the same pattern (the same
urlPattern, or the samedomainPatternsin any order), whatever theirmethods,resourceTypes,completeProviderandforceRedirection(getProviderPatternOnlyGroupKey). Their rules are not combined. Overload mode still merges built-in and remote providers only when the pattern and all four settings match (§5.2). Switching a provider off (step 1) uses the pattern-only key too (isProviderSignatureDisabled), so it removes every provider with that pattern. - Other custom providers are added. A name already in use gets
_1,_2, …. - Every provider goes through
attachProviderActivationIds(): each rule gets its activation ids (_linkumoriActivationIds) and, if its id changed, its old ids (_linkumoriLegacyRuleIds); see §6.2. - The result is saved as
ClearURLsData, withdataHash(SHA-256 of a key-sorted serialisation) andmergeStats.
5.5When rules reload
Rules are loaded at startup, and again only when:
- the custom rules editor saves, or settings asks for a refresh (
reloadCustomRules()/refreshRemoteRulesNow()). These callupdateProviderData(), so the new rules apply without a restart; - the extension restarts (
reload(), used by some settings and by the watchdog).
Nothing refreshes remote rules on a timer.
6The engine (clearurls.js)
6.1Providers
createProviders() turns every provider in ClearURLsData into a Provider object:
- its match pattern (
domainPatterns, orurlPatternplusindexPattern); - its compiled rule lists: field rules, raw rules, referral-marketing rules,
$removeparamfilters and their@@exceptions, exceptions, redirections, field redirections; - its
methodsandresourceTypes.
Rules that fail to compile, or use an option the engine does not accept, are skipped. The custom rules editor and lint-rules report such rules before they are saved.
6.2Rule ids and switched-off rules
Every rule has an id: its own id, or one generated by LinkumoriRuleIds from its list and text (see filter-syntax.md: Rule objects). The engine, storage.js, the custom rules editor and the CLI all use core_js/linkumori_rule_ids.js, so they always produce the same ids.
A rule can be switched off in two ways; both are stored in clearurls_disabled_rule_ids:
| Scope | Stored as | Switches the rule off |
|---|---|---|
| Provider | <provider>::<ruleId> | everywhere in that provider |
| Pattern | domainPattern:<pattern>::<ruleId> or urlPattern:<pattern>::<ruleId> | only for URLs that match that one pattern |
While providers are built, attachCoreRuleIdentity() gives each rule its ids, and filterCoreRuleActivationIds() drops the switched-off ones.
A setting saved under an old id also counts, and is then moved to the current id (migrateCoreRuleAliasActivationIds). Old ids come from two places:
- the rule's
aliases(after a rename); - its legacy ids, which it gets after its generated id changed.
A generated id changes with the rule's text, so the first switch-off of a rule without an id fixes it (core_js/linkumori_rule_pins.js). The editor writes the id onto a custom rule; for a rule from a built-in or remote list it stores a pin in clearurls_rule_id_pins: { provider, section, generatedId, sourceListId, fingerprintAtToggle, disableKeys }. createProviders() matches each provider's pins to its current rules (same text, same generated id, or text at least 80% alike, resolveProviderPins) and gives a matched rule the pinned id, activation ids included (applyCoreRulePin). A switched-off rule that has no pin yet (switched off before pins existed) gets one, and a pin matched to drifted text remembers it as lastSeenText. Pins that match no rule are listed in clearurlsProviderSnapshot.rulePins.orphaned, which the editor shows as Orphaned toggles.
The result is published as clearurlsProviderSnapshot (via getData), which the editor's Disabled rules page lists.
6.3Finding the providers for a URL
Checking every provider against every URL would be slow, so the engine indexes providers by hostname token:
- Providers. Each provider registers in
providersByTokenunder the main label of each of itsdomainPatterns. AurlPatternprovider uses itsindexPatterninstead. The main label is the one just before the public suffix:||amazon.*^and||smile.amazon.co.uk^both register underamazon(getLookupTokens). - Requests.
getHostnameLookupTokens(host)returns every label of the request host, sowww.amazon.co.uklooks upwww,amazon,coanduk, and finds theamazonproviders. - Global providers. Some providers are in
globalProvidersand are checked against every URL:- a
urlPatternprovider withoutindexPattern(which is whylint-ruleswarns about it); - a provider with a
/regex/domain pattern.
- a
6.4Cleaning a request
The webRequest.onBeforeRequest listener (blocking) covers <all_urls> and the request types in types + pingRequestTypes. It records tab and frame URLs for requestContextManager, skips data: URLs, and calls clearUrl(request):
Diagram source
flowchart TD
A[request] --> B{paused?}
B -- yes --> Z[let it through]
B -- no --> C{whitelisted?<br/>request or its page}
C -- yes --> Z
C -- no --> D{cleaning on?}
D -- no --> Z
D -- yes --> E{ping or beacon,<br/>ping blocking on?}
E -- yes --> X[cancel]
E -- no --> F[collect @@ $removeparam exceptions<br/>from providers matching the page]
F --> G[candidates: providers indexed under<br/>the request host + global providers]
G --> H{next candidate matches<br/>method, type, URL?}
H -- no more --> Y{CNAME uncloaking?}
H -- yes --> I[removeFieldsFormURL]
I -- redirect --> R[redirect, or navigate the tab<br/>if forceRedirection]
I -- block --> K[main frame: siteBlockedAlert page<br/>other: redirect to javascript:void 0]
I -- changed --> M[redirect to the cleaned URL]
I -- unchanged --> H
Y -- decision for the CNAME --> R2[same decision,<br/>mapped back to the original host]
Y -- none --> ZThe first provider that redirects, blocks or changes the URL decides the result. A redirect is a new request, so the cleaned URL goes through clearUrl() again. The browser keeps the requestId across redirects, so clearUrl() counts the cycles of each request and stops after LINKUMORI_MAX_CLEANING_CYCLES (10), unless unbounded mode (unboundedCyclesEnabled) is on, and an entry with a replacePattern rewrites at most once per request (Termination). pureCleaning() (clipboard, context menu, rule tester) loops the same way, with the same cap (shouldRunAnotherCleaningCycle). In unbounded mode no browser stops a redirect loop there, so it also stops when the URL comes back to one it already produced.
A provider's exceptions are checked while matching the URL (matchRequestURL), so an excepted URL never reaches the next step. removeFieldsFormURL(provider, url, …) then applies the provider in the order described in filter-syntax.md: Processing order:
- It skips local hosts (
localHostsSkipping). redirectionsandfieldRedirectionsapply (only while redirection is on). A target that isn't an absolutehttporhttpsURL makes the entry not match (getLinkumoriRedirectTarget(), Redirect targets).completeProviderblocks the request (only while domain blocking is on).- The ordered cleaning steps run (
getOrderedCleaningSteps()): raw rules on the URL text, then field rules and referral-marketing rules on query and fragment parameters. Inside each stage, entries with anordercome first, lowest first; then by array (rules, flagged rules, referralMarketing) and array position (Run order). The provider keeps every definition by group and text and resolves them once, infinalizeRules(): the last definition wins and inherits the first one's place (Same text twice). Parameters already handled by a$removeparamfilter are left to that filter. $removeparamfilters run, minus their@@exceptions and the exceptions collected from the page's providers.@@entries are checked against the URL the provider received, before its raw rules ran.
It logs each change and updates the badge unless called in quiet mode.
6.5CNAME uncloaking
If no provider made a decision, and CNAME uncloaking is on, LinkumoriDNS.replayCNAMEIfNeeded() (linkumori_dns.js) checks whether the request host is an alias:
- It asks
browser.dns.resolve(host, ['canonical_name']). Answers are cached for 10 minutes, up to 512 hosts. - It skips page loads (
main_frame), non-HTTP(S) URLs, IP addresses, proxied requests, and (by default,cnameIgnoreRootDocument) requests to the page's own host. - A CNAME within the same registrable domain (
a.example.com→b.example.com) counts as no CNAME. - It runs
clearUrl()again on the URL with the canonical host. UnlesscnameReplayFullURLis on, that URL keeps only the host and drops the path and query. - If that produces a block or a redirect, the decision is returned with the original hostname put back.
6.6Request context
Some decisions depend on the page a request comes from:
@@exceptions, which also cover requests made by a page the provider matches;domain=,first-partyandthird-partyoptions;- whitelists, which also cover subresources of a whitelisted page.
requestContextManager.collectContextURLs(request) returns every URL that can stand for that page:
- the request's
documentUrl,originUrl,initiatorandreferrer; - the tab's URL, kept up to date from
tabs.onUpdatedandwebNavigation.onCommitted; - the URLs of the frame's ancestor frames.
6.7Public Suffix List
initPslSupport() loads data/public_suffix_list.dat into linkumoriPsl. The engine uses it for:
- hostname tokens (§6.3);
||example.*^patterns, which match any public suffix;first-partyandthird-party, which compare registrable domains.
If it fails to load, hostname tokens fall back to plain labels, .* patterns (in rules and in the whitelist) stop matching, and first-party / third-party compare full hostnames instead of registrable domains.
7Other cleaning paths
7.1pureCleaning(url)
pureCleaning() (pureCleaning.js) cleans a URL outside webRequest. It runs every provider over the URL until it stops changing. It does not use the token index, and there is no request, so options that depend on the request (party, method) do not apply. For request types the URL counts as main_frame (getLinkumoriRequestType()), the page a link opens: a rule or $removeparam filter limited to other types is skipped, and one limited to main_frame applies. It is used by:
- the Copy clean link context menu, which injects
clipboard-helper.jsand copies the result (context_menu.js); - History API cleaning (§7.2);
- the Cleaning tool page, over messaging;
- the watchdog (§9).
runRuleTestLab() and pureCleaningTrace() use the same loop, but record which provider and rule changed what. The custom rules editor's test lab shows this trace. traceClearURLWebRequestTest() does the same through the real clearUrl() for the regression page.
7.2History API
webNavigation.onHistoryStateUpdated (historyListener.js) fires when a page changes its own URL with pushState or replaceState. The listener:
- does nothing before cleaning has started (§3), and skips the URL if it is paused or whitelisted, including the History API whitelist and the temporary tab whitelist;
- cleans it with
pureCleaning(url, false, true), where rules withhistoryBypassProtection: falsedo not run; - if the URL changed, runs
history.replaceState(null, "", cleanUrl)in the frame withscripting.executeScript.
7.3Search link fix (content scripts)
content_script_manager.js registers two content scripts with scripting.registerContentScripts, and only while searchLinkFixEnabled is on (default off). They run at document_end in all frames:
google_link_fix.json Google search domains stops Google from rewriting result links into tracking redirects;yandex_link_fix.jsdoes the same on Yandex.
The registration is refreshed on install, on browser start, and when the setting changes.
7.4ETag filtering
eTagFilter.js registers a blocking webRequest.onHeadersReceived listener with responseHeaders. While eTagFiltering is on (default off), it replaces the value of each ETag response header with a random value of the same form, so the ETag cannot be used as a tracking id. It does nothing before cleaning has started (§3), and skips local hosts, whitelisted requests, and paused cleaning.
8Whitelists and pausing
| Mechanism | Stored in | Skips |
|---|---|---|
| Whitelist | userWhitelist | all cleaning for matching hosts, and for requests made by pages on them |
| History API whitelist | historyApiWhitelist | only History API cleaning (§7.2) |
| Temporary tab whitelist | memory only (temporaryTabWhitelist), per tab | cleaning for those hosts in that tab until it closes |
| Pause | temporaryPauseUntil (time), or memory only until restart | all cleaning, until the time passes or the browser restarts |
Whitelist entries are hostname patterns (example.com also covers its subdomains) or IP addresses (matchWhitelistHostnamePattern). The popup adds and removes entries over messaging.
9Counters, badge, log and watchdog
- Counters:
increaseTotalCountercounts parameters seen andincreaseCleanedCountercounts cleanings, while statistics are on. Both are saved withdeferSaveOnDisk. - Badge:
increaseBadged(request)(badgedHandler.js) counts cleanings per tab and shows the number on the toolbar button. The count resets when the tab's URL changes. Whitelisted requests never count. - Log: while logging is on,
pushToLog()(tools.js) appends an entry tostorage.log.log:- the entry holds the URL before and after, the rule, the provider and pattern, the request type and method, the tab, and any CNAME;
- the log keeps at most
logLimitentries (default 100, never more than 5000), dropping the oldest; - it is saved one minute later (
deferSaveOnDisk); - each entry also goes straight to every open log page, over a
runtime.connectport namedlinkumori-logger.
- Watchdog: every minute (alarm
watchdog), watchdog.js cleanshttps://linkumori.com?utm_source=addonand checks that the result has noutm_source. It only runs while cleaning is on, consent is given, nothing is paused and a rule source is enabled. On failure it increaseswatchDogErrorCountand reloads the extension, at most twice in a row. A success resets the count.
10Messaging between the pages and the background
Pages call the background with:
browser.runtime.sendMessage({ function: 'getData', params: ['globalStatus'] })handleMessage() (message_handler.js) always answers with a promise of { response, error? }.
function | What it does |
|---|---|
getData / setData | read or write one key of storage. For custom_rules, clearurls_disabled_rule_ids, userWhitelist, historyApiWhitelist and ClearURLsData, setData answers only after the key is saved to disk, so a restart that follows cannot lose it |
reloadCustomRules, mergeCustomRules, getCustomRulesStats, getBundledRulesOnly, getExistingLinkumoriDataForImport | rule loading and statistics for the settings page and the editor |
| any other name | called on the background's global scope, but only if it is in the allowlist in handleRegularFunction() |
The allowlist covers:
- whitelist changes (
addToWhitelist,addToTemporaryTabWhitelist,addToHistoryApiWhitelist, the matchingremoveFrom…,getHistoryApiWhitelist,getTemporaryTabWhitelistForTab); - pausing (
pauseCleaningTemporarily,pauseCleaningUntilRestart,resumeCleaningNow,getTemporaryPauseState); - rule information (
getRuleSourceInfo,getMergeStatistics,getRemoteRulesHealth,refreshRemoteRulesNow,getRuleDefaultsStatus, which returns each source'sdefaults, the mode and the user's own and edited blocks); - remote capabilities (
acceptRemoteRuleCapabilities(ruleURL, capabilities), §5.2); - the custom rules editor's run-order panel (
getProviderRunOrder(name, provider, defaults),simulateProviderRunOrder(name, provider, defaults, url, referralAllowed)andgenerateProviderTestUrl(name, provider, defaults), §11.1); - cleaning and tests (
pureCleaning,runRuleTestLab,traceClearURLWebRequestTest,applyRegressionRuleData, andsetPendingRegressionSuite/getPendingRegressionSuite, which hand a suite from settings toregression.htmlin memory); - maintenance (
storageAsJSON,saveOnExit,initSettings,reload,start,changeIcon,setBadgedStatus).
To add a background function a page can call: define it in a background script, then add its name to the allowlist.
Pages also read and write some keys directly with browser.storage.local: the popup's consent keys, and settings that background listeners watch (§4).
11Extension pages
| Page | Script | Purpose | Main background calls |
|---|---|---|---|
popup.html | popup.js | toolbar popup: on/off, counters, pause, whitelist this site, consent gate | getData, setData, temporary whitelist, pause functions |
settings.html (options page) | settings.js | all settings, rule sources, remote rules, export/import, statistics | getData, setData, getRuleSourceInfo, getMergeStatistics, getRemoteRulesHealth, refreshRemoteRulesNow, storageAsJSON, reload |
customrules.html | custom_rules_editor.js | custom providers, rule on/off, import, test lab, run-order panel | getData, setData, reloadCustomRules, getBundledRulesOnly, runRuleTestLab, getProviderRunOrder, simulateProviderRunOrder, generateProviderTestUrl |
log.html | log.js | the cleaning log, live | getData, setData, port linkumori-logger |
cleaningTool.html | cleaning_tool.js | clean pasted URLs | pureCleaning |
siteBlockedAlert.html | siteBlockedAlert.js | shown when a provider blocks a page (completeProvider); links to the blocked address so it can still be opened | — |
legal.html | about.js | privacy policy and consent | setData, saveOnExit, reload |
report.html | report.js | prepares a GitHub issue about a broken page | — |
audit.html | audit.js | shows the bundled, merged, custom and cached remote rules | getData |
regression.html | regression_page.js | runs tests/regression-suite.json inside the extension | applyRegressionRuleData, traceClearURLWebRequestTest, runRuleTestLab, storageAsJSON, start |
guide.html | guide.js | user guide | — |
Every page loads theme_bootstrap.js (themes), linkumori-i18n.js (text), accessibility_tools.js (accessibility options) and usually write_version.js (version footer).
11.1Run-order panel
Under the custom rules editor's JSON editor, the run-order panel lists the provider's rawRules, rules and referralMarketing entries in the order they run, with each step's sort key (stage, orderGroup, order, rank, arrayIndex), followed by its $removeparam filters. It is worked out by the engine, not by the page: on every edit (debounced) the page sends the unsaved provider and the custom rules' defaults to getProviderRunOrder. That function, in clearurls.js, builds the provider the way createProviders() does (fillProvider(), the custom rules' defaults, the user's rule pins), but as a dry run: its rules are not added to the snapshot the rule controls read, and no pin or alias migration is recorded. Provider.describeRunOrder() returns both variants, with referral-marketing rules and without, and marks the steps the user switched off. The panel starts on the variant the user's referral-marketing setting selects.
Steps can be rearranged in the variant that lists every entry, by dragging or with each step's arrow buttons, but only within their stage: raw rules always run before field rules, and the step a completeProvider adds stays where it is. A move gives every step of that stage an order of 10, 20, 30, … in the new sequence, written on the entry that defines the step (the last definition of its text, which describeRunOrder() reports as source). A plain string entry becomes { "matchPattern": …, "order": … }; its generated id comes from the text, so it doesn't change. Each movable step shows its order (or "no order"); while a step is dragged over a drop point, every step of its stage shows the number the drop would give it (order 5 → 20), and the labels go back if the drag is cancelled. Undo move restores the editor text from before the last move, until the user types. Restore order numbers puts every order back as it was when the provider was opened: an entry that had none loses it again (and a { "matchPattern", "order" } object goes back to a plain string), one that had one gets it back, and entries whose text changed since are left alone. It shows only while some order differs.
Simulate runs the provider on a test URL and steps through it: start, previous, play, next and end, with the URL after each step (removed text struck through, added text highlighted, compared by URL part) and the step's row lit in the list. simulateProviderRunOrder(name, provider, defaults, url, referralAllowed) builds the same dry-run provider with the user's switched-off rules, then calls removeFieldsFormURL() with a stepRecorder array that receives the URL before and after every ordered step and after the $removeparam filters. It runs like cleaning a copied link (no request, quiet), pass after pass while the URL changes, within the cycle limit (shouldRunAnotherCleaningCycle), and stops at a redirect or block. Only this provider runs. storage.referralMarketing is set to the panel's choice for the duration of the call and restored; the call is synchronous, so nothing else can read it meanwhile. The simulation runs again after each edit or move.
Generate test URL fills the test URL from the provider's own rules (generateProviderTestUrl(name, provider, defaults)): a host its domainPatterns or urlPattern match, text in the path for each raw rule, a parameter for each rules, referralMarketing and named $removeparam entry, and keep=1, which no rule should remove. Samples come from Regex.Analyzer (external_js/regex_analyzer.js), and each is checked against the rule it is for and against the provider's exceptions; what can't be sampled, or would make an exception match, is left out and listed. Redirects and blocks are left out too, since they would end the run before the other rules. On the 704 bundled providers it makes a matching URL for all but the two whose exceptions cover every URL they match.
11.2Settings page safeguards
settings.js asks before a few risky changes. These checks live in the page only; the background accepts the keys whatever their value.
- Gatekeeper for remote rule addresses. Each of the rule URL, hash URL and remote rule sets fields is checked on its own. A field that had a saved value can be edited without confirmation as long as it isn't emptied. Typing into an empty field, or emptying one, opens a disclaimer, even when the other fields hold saved addresses, and the change is kept only after the user types the confirmation phrase (I accept full responsibility in English; each language has its own). The confirmation lasts until the page is closed and covers every field. The import and export buttons for remote addresses appear only after confirming.
- Disable Gatekeeper (
disableGatekeeper) skips that confirmation and shows the import and export buttons. Turning it on opens a risk warning, then the same disclaimer with a phrase of its own. Turning it off needs no confirmation. - Redirect bypass (
redirectionEnabled): turning it on first shows a warning to review the redirect rules of every source; Cancel leaves it off.
12Code shared between the background, pages and CLI
Some logic has to agree between the background, the custom rules editor (which does not load the background scripts) and linkumori-cli-tool.js (Node):
| Logic | Where it lives |
|---|---|
| Generated rule ids | one shared module, core_js/linkumori_rule_ids.js, loaded by the background and the editor and imported by the CLI |
| Pinned rule ids | core_js/linkumori_rule_pins.js, loaded and imported in the same places; pins live in clearurls_rule_id_pins |
Rule validation (keys, options, rawrule=, targetId, …) | written twice: in the editor (assert… and rawRuleOptionProblem in custom_rules_editor.js) and in the CLI linter (lintClearURLsRules). Change both together |
| Pattern matching and option parsing | the engine (clearurls.js); the CLI smoke tests use a simplified copy |
13Build time and runtime
| Build time (CLI) | Runtime (extension) |
|---|---|
data/linkumori-clearurls.json is edited by hand | never read |
node linkumori-cli-tool.js clearurls builds data/linkumori-clearurls-min.json.lz4 with metadata | loaded at startup (§5.1) |
lint-rules, show-rule check and inspect rules | the editor runs the same checks on save |
data/public_suffix_list.dat is refreshed by the CLI | loaded by linkumoriPsl (§6.7) |
tests/regression-suite.json is maintained in the repo | run by regression.html |
14Things to know
Facts that matter when changing the code:
- Remote rules have no timer. They are fetched at startup and on a manual refresh (§5.5).
- Listeners registered at load time must check consent. Code that listens to browser events outside
start()runs whether or not consent is given. CheckisCleaningStarted()first, as the ETag filter and the History API listener do. - Any extension page can read or write any key.
getDataandsetDataover messaging accept every key; other function calls are limited to the allowlist (§10). Web pages cannot send these messages. - The first provider wins. For each request, the first provider that changes, redirects or blocks it decides the result. Provider order comes from the merged
ClearURLsData.
Generated from docs/runtime-architecture.md on the main branch. Found a problem? Open an issue.