├── .gitignore ├── background.js ├── README.md ├── manifest.json ├── LICENSE ├── options.js ├── options.html ├── rules.js └── icon.svg /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.zip -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | import { RuleSet, getRules } from "./rules.js"; 2 | 3 | async function applyRulesFromStorage() { 4 | const rules = new RuleSet(await getRules()); 5 | rules.registerAll(); 6 | } 7 | 8 | browser.runtime.onInstalled.addListener(async (details) => { 9 | if (details.reason === 'update') { 10 | console.info('Addon updated; reload rules.'); 11 | await applyRulesFromStorage(); 12 | } 13 | }); 14 | 15 | browser.storage.local.onChanged.addListener(async () => { 16 | console.debug("Ruleset changed"); 17 | await applyRulesFromStorage();; 18 | }); 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Accept-Language per site 2 | 3 | Firefox extension to change `Accept-Language` header for different websites. 4 | 5 | ## Requirement 6 | 7 | Current version: Firefox 113 or above 8 | 9 | v0.2.x (`mv2` branch): Firefox 48 or above 10 | 11 | 12 | ## Usage 13 | 14 | 1. [Install it](https://addons.mozilla.org/firefox/addon/accept-language-per-site/). 15 | 2. Go to `about:addons` page, open options, add domain name 16 | (e.g. `*.example.com`) and languages (e.g. `en`). 17 | 3. Click save button. 18 | 19 | ## Acknowledgement 20 | The icon is derived from [Material icons](https://material.io/icons/). -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Accept-Language per site", 4 | "version": "0.4.0", 5 | "homepage_url": "https://github.com/sorz/accept-language-per-site", 6 | "author": "Shell Chen", 7 | "description": "Change Accept-Language for different sites", 8 | "browser_specific_settings": { 9 | "gecko": { 10 | "id": "addon-accept-lang@sorz.org", 11 | "strict_min_version": "128.0" 12 | } 13 | }, 14 | "icons": { 15 | "48": "icon.svg", 16 | "96": "icon.svg" 17 | }, 18 | 19 | "permissions": [ 20 | "declarativeNetRequestWithHostAccess", 21 | "storage" 22 | ], 23 | 24 | "optional_host_permissions": [ 25 | "*://*/*" 26 | ], 27 | 28 | "background": { 29 | "scripts": ["background.js"], 30 | "type": "module" 31 | }, 32 | 33 | "options_ui": { 34 | "page": "options.html" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shell Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | import { Rule, getRules } from "./rules.js"; 2 | 3 | const $ = selector => document.querySelector(selector); 4 | const sleep = ms => new Promise(cb => setTimeout(cb, ms)); 5 | 6 | async function saveOptions(ev) { 7 | const button = $("button[type='submit']"); 8 | ev.preventDefault(); 9 | button.disabled = true; 10 | try { 11 | // Get rules from form 12 | const list = document.querySelectorAll("#list li"); 13 | const rules = Array.from(list) 14 | .map(li => ({ 15 | host: li.querySelector(".host").value, 16 | language: li.querySelector(".language").value 17 | })) 18 | .filter(rule => rule.host && rule.language) 19 | .map(({host, language}) => new Rule(host, language)); 20 | 21 | // Check permission 22 | const permissions = { 23 | origins: rules.map((rule) => rule.permissionOrigins), 24 | }; 25 | if (!await browser.permissions.request(permissions)) { 26 | throw new Error("permssions rejected"); 27 | } 28 | 29 | // Save rules 30 | await browser.storage.local.set({ rules: rules }); 31 | $("#saved").classList.add("show"); 32 | await sleep(800); 33 | $("#saved").classList.remove("show"); 34 | } catch (err) { 35 | alert(`Failed to save rules: ${err}`) 36 | } finally { 37 | button.disabled = false; 38 | } 39 | } 40 | 41 | async function restoreOptions() { 42 | let rules = await getRules(); 43 | let list = $("#list"); 44 | list.innerHTML = ''; 45 | rules.forEach(rule => list.appendChild(rule.formHTML)); 46 | list.appendChild(new Rule().formHTML); 47 | } 48 | 49 | function addMoreRule() { 50 | $("#list").appendChild(new Rule().formHTML); 51 | } 52 | 53 | document.addEventListener("DOMContentLoaded", restoreOptions); 54 | $("form").addEventListener("submit", saveOptions); 55 | $("#more").addEventListener("click", addMoreRule); 56 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 22 | 23 |
24 |

Rules

25 |
26 | 27 | 32 |
33 |
34 |
35 |

Examples

36 | 43 |
44 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /rules.js: -------------------------------------------------------------------------------- 1 | export class Rule { 2 | constructor(host, language) { 3 | // "host" is legacy name, better be "expr" 4 | // The true host name is `this.canonicalDomain` 5 | this.host = host || ''; 6 | this.language = language || ''; 7 | } 8 | 9 | get isUniversalWidlcard() { 10 | return this.host === '*'; 11 | } 12 | 13 | get isSubdomainWildcard() { 14 | return this.host.startsWith('*.'); 15 | } 16 | 17 | get canonicalDomain() { 18 | if (this.isUniversalWidlcard) { 19 | return null; 20 | } else if (this.isSubdomainWildcard) { 21 | return new URL(`http://${this.host.slice(2)}`).host; 22 | } else { 23 | return new URL(`http://${this.host}`).host; 24 | } 25 | } 26 | 27 | get permissionOrigins() { 28 | if (this.isUniversalWidlcard) { 29 | return '*://*/*'; 30 | } else { 31 | return `*://*.${this.canonicalDomain}/*`; 32 | } 33 | } 34 | 35 | get regexFilter() { 36 | if (this.isUniversalWidlcard) { 37 | return "https?:\\/\\/.*"; 38 | } 39 | const escapedHost = this.canonicalDomain.replace('.', '\\.'); 40 | if (this.isSubdomainWildcard) { 41 | return `https?:\\/\\/([^\\/]+\\.)?${escapedHost}(:\\d+)?\\/.*`; 42 | } else { 43 | return `https?:\\/\\/${escapedHost}(:\\d+)?\\/.*`; 44 | } 45 | } 46 | 47 | get rule() { 48 | let priority = this.host.split(".").length; 49 | if (!this.isUniversalWidlcard && !this.isSubdomainWildcard) { 50 | // Let `example.com` precedent `*.example.com` 51 | priority += 1; 52 | } 53 | console.debug(`Rule P${priority} ${this.language} [${this.host}] /${this.regexFilter}/`) 54 | 55 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest#rules 56 | return { 57 | id: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER), 58 | action: { 59 | type: "modifyHeaders", 60 | requestHeaders: [ 61 | { 62 | header: "Accept-Language", 63 | operation: "set", 64 | value: this.language, 65 | } 66 | ] 67 | }, 68 | condition: { 69 | excludedResourceTypes: [], 70 | regexFilter: this.regexFilter 71 | }, 72 | priority 73 | }; 74 | } 75 | 76 | get formHTML() { 77 | let template = document.querySelector("#rule"); 78 | let content = document.importNode(template, true).content; 79 | content.querySelector(".host").value = this.host; 80 | content.querySelector(".language").value = this.language; 81 | return content; 82 | } 83 | } 84 | 85 | export class RuleSet { 86 | constructor(rules) { 87 | this.rules = rules; 88 | } 89 | 90 | async registerAll() { 91 | const oldRules = await browser.declarativeNetRequest.getDynamicRules(); 92 | await browser.declarativeNetRequest.updateDynamicRules({ 93 | removeRuleIds: oldRules.map((r) => r.id), 94 | addRules: this.rules.map((r) => r.rule), 95 | }); 96 | console.debug(`Remove ${oldRules.length}; add ${this.rules.length} rules`); 97 | } 98 | } 99 | 100 | export async function getRules() { 101 | let opts = await browser.storage.local.get("rules"); 102 | if (opts.rules) 103 | return opts.rules 104 | .filter(v => v.host && v.language) 105 | .map(v => new Rule(v.host, v.language)); 106 | else 107 | return []; 108 | } 109 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 18 | 22 | 26 | 27 | 35 | 36 | 38 | 39 | 41 | image/svg+xml 42 | 44 | 45 | 46 | 47 | 48 | 51 | 55 | 59 | 62 | 63 | 68 | 69 | 70 | --------------------------------------------------------------------------------