diff options
33 files changed, 1799 insertions, 675 deletions
diff --git a/background/cookie_filter.js b/background/cookie_filter.js new file mode 100644 index 0000000..fea2d23 --- /dev/null +++ b/background/cookie_filter.js @@ -0,0 +1,45 @@ +/** + * part of Hachette + * Filtering request headers to remove hachette cookies that might have slipped + * through. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * IMPORTS_START + * IMPORT extract_signed + * IMPORTS_END + */ + +function is_valid_hachette_cookie(cookie) +{ + const match = /^hachette-(\w*)=(.*)$/.exec(cookie); + if (!match) + return false; + + return !extract_signed(match.slice(1, 3)).fail; +} + +function remove_hachette_cookies(header) +{ + if (header.name !== "Cookie") + return header; + + const cookies = header.value.split("; "); + const value = cookies.filter(c => !is_valid_hachette_cookie(c)).join("; "); + + return value ? {name: "Cookie", value} : null; +} + +function filter_cookie_headers(headers) +{ + return headers.map(remove_hachette_cookies).filter(h => h); +} + +/* + * EXPORTS_START + * EXPORT filter_cookie_headers + * EXPORTS_END + */ diff --git a/background/main.js b/background/main.js index 2e9fa50..03cd5d7 100644 --- a/background/main.js +++ b/background/main.js @@ -9,6 +9,7 @@ * IMPORTS_START * IMPORT TYPE_PREFIX * IMPORT get_storage + * IMPORT light_storage * IMPORT start_storage_server * IMPORT start_page_actions_server * IMPORT browser @@ -17,6 +18,7 @@ * IMPORT gen_nonce * IMPORT inject_csp_headers * IMPORT apply_stream_filter + * IMPORT filter_cookie_headers * IMPORT is_chrome * IMPORTS_END */ @@ -49,6 +51,7 @@ browser.runtime.onInstalled.addListener(init_ext); let storage; +let policy_observable = {}; function on_headers_received(details) { @@ -58,7 +61,8 @@ function on_headers_received(details) const [pattern, settings] = query_best(storage, details.url); const has_payload = !!(settings && settings.components); - const allow = !has_payload && !!(settings && settings.allow); + const allow = !has_payload && + !!(settings ? settings.allow : policy_observable.value); const nonce = gen_nonce(); const policy = {allow, url, nonce, has_payload}; @@ -70,7 +74,7 @@ function on_headers_received(details) skip = true; } - headers = inject_csp_headers(details, headers, policy); + headers = inject_csp_headers(headers, policy); skip = skip || (details.statusCode >= 300 && details.statusCode < 400); if (!skip) { @@ -82,19 +86,40 @@ function on_headers_received(details) return {responseHeaders: headers}; } +function on_before_send_headers(details) +{ + let headers = details.requestHeaders; + headers = filter_cookie_headers(headers); + return {requestHeaders: headers}; +} + +const all_types = [ + "main_frame", "sub_frame", "stylesheet", "script", "image", "font", + "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket", + "other", "main_frame", "sub_frame" +]; + async function start_webRequest_operations() { storage = await get_storage(); - const extra_opts = ["blocking", "responseHeaders"]; + const extra_opts = ["blocking"]; if (is_chrome) extra_opts.push("extraHeaders"); browser.webRequest.onHeadersReceived.addListener( on_headers_received, {urls: ["<all_urls>"], types: ["main_frame", "sub_frame"]}, - extra_opts + extra_opts.concat("responseHeaders") ); + + browser.webRequest.onBeforeSendHeaders.addListener( + on_before_send_headers, + {urls: ["<all_urls>"], types: all_types}, + extra_opts.concat("requestHeaders") + ); + + policy_observable = await light_storage.observe_var("default_allow"); } start_webRequest_operations(); diff --git a/background/page_actions_server.js b/background/page_actions_server.js index 58a0073..b0db5f5 100644 --- a/background/page_actions_server.js +++ b/background/page_actions_server.js @@ -8,6 +8,7 @@ /* * IMPORTS_START * IMPORT get_storage + * IMPORT light_storage * IMPORT TYPE_PREFIX * IMPORT CONNECTION_TYPE * IMPORT browser @@ -20,17 +21,17 @@ var storage; var handler; +let policy_observable; function send_actions(url, port) { - const [pattern, settings] = query_best(storage, url); + let [pattern, settings] = query_best(storage, url); + if (!settings) + settings = {allow: policy_observable && policy_observable.value}; const repos = storage.get_all(TYPE_PREFIX.REPO); port.postMessage(["settings", [pattern, settings, repos]]); - if (settings === undefined) - return; - let components = settings.components; let processed_bags = new Set(); @@ -127,6 +128,8 @@ async function start_page_actions_server() storage = await get_storage(); listen_for_connection(CONNECTION_TYPE.PAGE_ACTIONS, new_connection); + + policy_observable = await light_storage.observe_var("default_allow"); } /* diff --git a/background/policy_injector.js b/background/policy_injector.js index 1d4db6f..72318d4 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -16,25 +16,27 @@ * IMPORTS_END */ -function inject_csp_headers(details, headers, policy) +function inject_csp_headers(headers, policy) { - const url = details.url; - - let orig_csp_headers; + let csp_headers; let old_signature; let hachette_header; for (const header of headers.filter(h => h.name === "x-hachette")) { - const match = /^([^%])(%.*)$/.exec(header.value); + /* x-hachette header has format: <signature>_0_<data> */ + const match = /^([^_]+)_(0_.*)$/.exec(header.value); if (!match) continue; - const old_data = extract_signed(...match.splice(1, 2), [[0]]); - if (!old_data || old_data.url !== url) + const result = extract_signed(...match.slice(1, 3)); + if (result.fail) continue; + /* This should succeed - it's our self-produced valid JSON. */ + const old_data = JSON.parse(decodeURIComponent(result.data)); + /* Confirmed- it's the originals, smuggled in! */ - orig_csp_headers = old_data.csp_headers; + csp_headers = old_data.csp_headers; old_signature = old_data.policy_sig; hachette_header = header; @@ -46,24 +48,23 @@ function inject_csp_headers(details, headers, policy) headers.push(hachette_header); } - orig_csp_headers = orig_csp_headers || + csp_headers = csp_headers || headers.filter(h => is_csp_header_name(h.name)); /* When blocking remove report-only CSP headers that snitch on us. */ headers = headers.filter(h => !is_csp_header_name(h.name, !policy.allow)); if (old_signature) - headers = headers.filter(h => h.name.search(old_signature) === -1); + headers = headers.filter(h => h.value.search(old_signature) === -1); - const sanitizer = h => sanitize_csp_header(h, policy); - headers.push(...orig_csp_headers.map(sanitizer)); + headers.push(...csp_headers.map(h => sanitize_csp_header(h, policy))); const policy_str = encodeURIComponent(JSON.stringify(policy)); - const policy_sig = sign_data(policy_str, new Date()); + const signed_policy = sign_data(policy_str, new Date().getTime()); const later_30sec = new Date(new Date().getTime() + 30000).toGMTString(); headers.push({ name: "Set-Cookie", - value: `hachette-${policy_sig}=${policy_str}; Expires=${later_30sec};` + value: `hachette-${signed_policy.join("=")}; Expires=${later_30sec};` }); /* @@ -71,9 +72,9 @@ function inject_csp_headers(details, headers, policy) * These are signed with a time of 0, as it's not clear there is a limit on * how long Firefox might retain headers in the cache. */ - let hachette_data = {csp_headers: orig_csp_headers, policy_sig, url}; + let hachette_data = {csp_headers, policy_sig: signed_policy[0]}; hachette_data = encodeURIComponent(JSON.stringify(hachette_data)); - hachette_header.value = sign_data(hachette_data, 0) + hachette_data; + hachette_header.value = sign_data(hachette_data, 0).join("_"); /* To ensure there is a CSP header if required */ if (!policy.allow) diff --git a/background/storage.js b/background/storage.js index c2160b0..12c0c61 100644 --- a/background/storage.js +++ b/background/storage.js @@ -7,7 +7,7 @@ /* * IMPORTS_START - * IMPORT TYPE_PREFIX + * IMPORT raw_storage * IMPORT TYPE_NAME * IMPORT list_prefixes * IMPORT make_lock @@ -15,76 +15,17 @@ * IMPORT unlock * IMPORT make_once * IMPORT browser - * IMPORT is_chrome * IMPORT observables * IMPORTS_END */ var exports = {}; -/* We're yet to decide how to handle errors... */ - -/* Here are some basic wrappers for storage API functions */ - -async function get(key) -{ - try { - /* Fix for fact that Chrome does not use promises here */ - let promise = is_chrome ? - new Promise((resolve, reject) => - chrome.storage.local.get(key, - val => resolve(val))) : - browser.storage.local.get(key); - - return (await promise)[key]; - } catch (e) { - console.log(e); - } -} - -async function set(key, value) -{ - try { - return browser.storage.local.set({[key]: value}); - } catch (e) { - console.log(e); - } -} - -async function setn(keys_and_values) -{ - let obj = Object(); - while (keys_and_values.length > 1) { - let value = keys_and_values.pop(); - let key = keys_and_values.pop(); - obj[key] = value; - } - - try { - return browser.storage.local.set(obj); - } catch (e) { - console.log(e); - } -} - -async function set_var(name, value) -{ - return set(TYPE_PREFIX.VAR + name, value); -} - -async function get_var(name) -{ - return get(TYPE_PREFIX.VAR + name); -} - -/* - * A special case of persisted variable is one that contains list - * of items. - */ +/* A special case of persisted variable is one that contains list of items. */ async function get_list_var(name) { - let list = await get_var(name); + let list = await raw_storage.get_var(name); return list === undefined ? [] : list; } @@ -97,7 +38,7 @@ async function list(prefix) let map = new Map(); for (let item of await get_list_var(name)) - map.set(item, await get(prefix + item)); + map.set(item, await raw_storage.get(prefix + item)); return {map, prefix, name, observable: observables.make(), lock: make_lock()}; @@ -175,19 +116,19 @@ async function set_item(item, value, list) } async function _set_item(item, value, list) { - let key = list.prefix + item; - let old_val = list.map.get(item); + const key = list.prefix + item; + const old_val = list.map.get(item); + const set_obj = {[key]: value}; if (old_val === undefined) { - let items = list_items(list); + const items = list_items(list); items.push(item); - await setn([key, value, "_" + list.name, items]); - } else { - await set(key, value); + set_obj["_" + list.name] = items; } - list.map.set(item, value) + await raw_storage.set(set_obj); + list.map.set(item, value); - let change = { + const change = { prefix : list.prefix, item, old_val, @@ -212,20 +153,21 @@ async function remove_item(item, list) } async function _remove_item(item, list) { - let old_val = list.map.get(item); + const old_val = list.map.get(item); if (old_val === undefined) return; - let key = list.prefix + item; - let items = list_items(list); - let index = items.indexOf(item); + const items = list_items(list); + const index = items.indexOf(item); items.splice(index, 1); - await setn([key, undefined, "_" + list.name, items]); - + await raw_storage.set({ + [list.prefix + item]: undefined, + ["_" + list.name]: items + }); list.map.delete(item); - let change = { + const change = { prefix : list.prefix, item, old_val, @@ -247,11 +189,11 @@ async function replace_item(old_item, new_item, list, new_val=undefined) } async function _replace_item(old_item, new_item, list, new_val=undefined) { - let old_val = list.map.get(old_item); + const old_val = list.map.get(old_item); if (new_val === undefined) { if (old_val === undefined) return; - new_val = old_val + new_val = old_val; } else if (new_val === old_val && new_item === old_item) { return old_val; } @@ -261,17 +203,18 @@ async function _replace_item(old_item, new_item, list, new_val=undefined) return old_val; } - let new_key = list.prefix + new_item; - let old_key = list.prefix + old_item; - let items = list_items(list); - let index = items.indexOf(old_item); + const items = list_items(list); + const index = items.indexOf(old_item); items[index] = new_item; - await setn([old_key, undefined, new_key, new_val, - "_" + list.name, items]); + await raw_storage.set({ + [list.prefix + old_item]: undefined, + [list.prefix + new_item]: new_val, + ["_" + list.name]: items + }); list.map.delete(old_item); - let change = { + const change = { prefix : list.prefix, item : old_item, old_val, @@ -291,6 +291,17 @@ EOF cp html/*.css $BUILDDIR/html mkdir $BUILDDIR/icons cp icons/*.png $BUILDDIR/icons + + if [ "$BROWSER" = "chromium" ]; then + for MOZILLA_FILE in $(find $BUILDDIR -name "MOZILLA_*"); do + echo > "$MOZILLA_FILE" + done + fi + if [ "$BROWSER" = "mozilla" ]; then + for CHROMIUM_FILE in $(find $BUILDDIR -name "CHROMIUM_*"); do + echo > "$CHROMIUM_FILE" + done + fi } main "$@" diff --git a/common/misc.js b/common/misc.js index 30a9e77..91d60d2 100644 --- a/common/misc.js +++ b/common/misc.js @@ -8,9 +8,7 @@ /* * IMPORTS_START - * IMPORT sha256 * IMPORT browser - * IMPORT is_chrome * IMPORT TYPE_NAME * IMPORT TYPE_PREFIX * IMPORTS_END @@ -45,32 +43,6 @@ function gen_nonce(length) // Default 16 return Uint8toHex(randomData); } -function get_secure_salt() -{ - if (is_chrome) - return browser.runtime.getManifest().key.substring(0, 50); - else - return browser.runtime.getURL("dummy"); -} - -function extract_signed(signature, data, times) -{ - const now = new Date(); - times = times || [[now], [now, -1]]; - - const reductor = - (ok, time) => ok || signature === sign_data(data, ...time); - if (!times.reduce(reductor, false)) - return undefined; - - try { - return JSON.parse(decodeURIComponent(data)); - } catch (e) { - /* This should not be reached - it's our self-produced valid JSON. */ - console.log("Unexpected internal error - invalid JSON smuggled!", e); - } -} - /* csp rule that blocks all scripts except for those injected by us */ function csp_rule(nonce) { @@ -89,7 +61,7 @@ const report_only_header_name = "content-security-policy-report-only"; function is_csp_header_name(string, include_report_only) { - string = string && string.toLowerCase() || ""; + string = string && string.toLowerCase().trim() || ""; return (include_report_only && string === report_only_header_name) || csp_header_names.has(string); @@ -112,17 +84,13 @@ function open_in_settings(prefix, name) window.open(url, "_blank"); } -/* Check if url corresponds to a browser's special page */ -function is_privileged_url(url) -{ - return !!/^(chrome(-extension)?|moz-extension):\/\/|^about:/i.exec(url); -} - -/* Sign a given string for a given time */ -function sign_data(data, now, hours_offset) { - let time = Math.floor(now / 3600000) + (hours_offset || 0); - return sha256(get_secure_salt() + time + data); -} +/* + * Check if url corresponds to a browser's special page (or a directory index in + * case of `file://' protocol). + */ +const privileged_reg = + /^(chrome(-extension)?|moz-extension):\/\/|^about:|^file:\/\/.*\/$/; +const is_privileged_url = url => privileged_reg.test(url); /* Parse a CSP header */ function parse_csp(csp) { @@ -193,8 +161,6 @@ const matchers = { /* * EXPORTS_START * EXPORT gen_nonce - * EXPORT extract_signed - * EXPORT sign_data * EXPORT csp_rule * EXPORT is_csp_header_name * EXPORT nice_name diff --git a/common/observable.js b/common/observable.js index 1fb0b0a..02f1c1b 100644 --- a/common/observable.js +++ b/common/observable.js @@ -6,28 +6,22 @@ * Redistribution terms are gathered in the `copyright' file. */ -function make() -{ - return new Set(); -} +const make = (value=undefined) => ({value, listeners: new Set()}); +const subscribe = (observable, cb) => observable.listeners.add(cb); +const unsubscribe = (observable, cb) => observable.listeners.delete(cb); -function subscribe(observable, cb) -{ - observable.add(cb); -} - -function unsubscribe(observable, cb) -{ - observable.delete(cb); -} +const silent_set = (observable, value) => observable.value = value; +const broadcast = (observable, ...values) => + observable.listeners.forEach(cb => cb(...values)); -function broadcast(observable, event) +function set(observable, value) { - for (const callback of observable) - callback(event); + const old_value = observable.value; + silent_set(observable, value); + broadcast(observable, value, old_value); } -const observables = {make, subscribe, unsubscribe, broadcast}; +const observables = {make, subscribe, unsubscribe, broadcast, silent_set, set}; /* * EXPORTS_START diff --git a/common/patterns.js b/common/patterns.js index be7c650..ebb55ab 100644 --- a/common/patterns.js +++ b/common/patterns.js @@ -5,35 +5,48 @@ * Redistribution terms are gathered in the `copyright' file. */ -const proto_re = "[a-zA-Z]*:\/\/"; +const proto_regex = /^(\w+):\/\/(.*)$/; + +const user_re = "[^/?#@]+@" const domain_re = "[^/?#]+"; -const segments_re = "/[^?#]*"; -const query_re = "\\?[^#]*"; - -const url_regex = new RegExp(`\ -^\ -(${proto_re})\ -(${domain_re})\ -(${segments_re})?\ -(${query_re})?\ -#?.*\$\ -`); +const path_re = "[^?#]*"; +const query_re = "\\??[^#]*"; + +const http_regex = new RegExp(`^(${domain_re})(${path_re})(${query_re}).*`); + +const file_regex = new RegExp(`^(${path_re}).*`); + +const ftp_regex = new RegExp(`^(${user_re})?(${domain_re})(${path_re}).*`); function deconstruct_url(url) { - const regex_match = url_regex.exec(url); - if (regex_match === null) + const proto_match = proto_regex.exec(url); + if (proto_match === null) return undefined; - let [_, proto, domain, path, query] = regex_match; + const deco = {proto: proto_match[1]}; + + if (deco.proto === "file") { + deco.path = file_regex.exec(proto_match[2])[1]; + } else if (deco.proto === "ftp") { + [deco.domain, deco.path] = ftp_regex.exec(proto_match[2]).slice(2, 4); + } else { + const http_match = http_regex.exec(proto_match[2]); + if (!http_match) + return undefined; + [deco.domain, deco.path, deco.query] = http_match.slice(1, 4); + } + + if (deco.domain) + deco.domain = deco.domain.split("."); - domain = domain.split("."); - let path_trailing_dash = - path && path[path.length - 1] === "/"; - path = (path || "").split("/").filter(s => s !== ""); - path.unshift(""); + const leading_dash = deco.path[0] === "/"; + deco.trailing_dash = deco.path[deco.path.length - 1] === "/"; + deco.path = deco.path.split("/").filter(s => s !== ""); + if (leading_dash || deco.path.length === 0) + deco.path.unshift(""); - return {proto, domain, path, query, path_trailing_dash}; + return deco; } /* Be sane: both arguments should be arrays of length >= 2 */ @@ -104,84 +117,70 @@ function url_matches(url, pattern) return false } - if (pattern_deco.proto !== url_deco.proto) - return false; - - return domain_matches(url_deco.domain, pattern_deco.domain) && - path_matches(url_deco.path, url_deco.path_trailing_dash, - pattern_deco.path, pattern_deco.path_trailing_dash); + return pattern_deco.proto === url_deco.proto && + !(pattern_deco.proto === "file" && pattern_deco.trailing_dash) && + !!url_deco.domain === !!pattern_deco.domain && + (!url_deco.domain || + domain_matches(url_deco.domain, pattern_deco.domain)) && + path_matches(url_deco.path, url_deco.trailing_dash, + pattern_deco.path, pattern_deco.trailing_dash); } -/* - * Call callback for every possible pattern that matches url. Return when there - * are no more patterns or callback returns false. - */ -function for_each_possible_pattern(url, callback) +function* each_domain_pattern(domain_segments) { - const deco = deconstruct_url(url); - - if (deco === undefined) { - console.log("bad url format", url); - return; + for (let slice = 0; slice < domain_segments.length; slice++) { + const domain_part = domain_segments.slice(slice).join("."); + const domain_wildcards = []; + if (slice === 0) + yield domain_part; + if (slice === 1) + yield "*." + domain_part; + if (slice > 1) + yield "**." + domain_part; + yield "***." + domain_part; } +} - for (let d_slice = 0; d_slice < deco.domain.length; d_slice++) { - const domain_part = deco.domain.slice(d_slice).join("."); - const domain_wildcards = []; - if (d_slice === 0) - domain_wildcards.push(""); - if (d_slice === 1) - domain_wildcards.push("*."); - if (d_slice > 0) - domain_wildcards.push("**."); - domain_wildcards.push("***."); - - for (const domain_wildcard of domain_wildcards) { - const domain_pattern = domain_wildcard + domain_part; - - for (let s_slice = deco.path.length; s_slice > 0; s_slice--) { - const path_part = deco.path.slice(0, s_slice).join("/"); - const path_wildcards = []; - if (s_slice === deco.path.length) { - if (deco.path_trailing_dash) - path_wildcards.push("/"); - path_wildcards.push(""); - } - if (s_slice === deco.path.length - 1 && - deco.path[s_slice] !== "*") - path_wildcards.push("/*"); - if (s_slice < deco.path.length && - (deco.path[s_slice] !== "**" || - s_slice < deco.path.length - 1)) - path_wildcards.push("/**"); - if (deco.path[s_slice] !== "***" || s_slice < deco.path.length) - path_wildcards.push("/***"); - - for (const path_wildcard of path_wildcards) { - const path_pattern = path_part + path_wildcard; - - const pattern = deco.proto + domain_pattern + path_pattern; - - if (callback(pattern) === false) - return; - } - } +function* each_path_pattern(path_segments, trailing_dash) +{ + for (let slice = path_segments.length; slice > 0; slice--) { + const path_part = path_segments.slice(0, slice).join("/"); + const path_wildcards = []; + if (slice === path_segments.length) { + if (trailing_dash) + yield path_part + "/"; + yield path_part; } + if (slice === path_segments.length - 1 && path_segments[slice] !== "*") + yield path_part + "/*"; + if (slice < path_segments.length - 1) + yield path_part + "/**"; + if (slice < path_segments.length - 1 || + path_segments[path_segments.length - 1] !== "***") + yield path_part + "/***"; } } -function possible_patterns(url) +/* Generate every possible pattern that matches url. */ +function* each_url_pattern(url) { - const patterns = []; - for_each_possible_pattern(url, patterns.push); + const deco = deconstruct_url(url); - return patterns; + if (deco === undefined) { + console.log("bad url format", url); + return false; + } + + const all_domains = deco.domain ? each_domain_pattern(deco.domain) : [""]; + for (const domain of all_domains) { + for (const path of each_path_pattern(deco.path, deco.trailing_dash)) + yield `${deco.proto}://${domain}${path}`; + } } /* * EXPORTS_START * EXPORT url_matches - * EXPORT for_each_possible_pattern - * EXPORT possible_patterns + * EXPORT each_url_pattern * EXPORTS_END */ diff --git a/common/settings_query.js b/common/settings_query.js index e85ae63..b54e580 100644 --- a/common/settings_query.js +++ b/common/settings_query.js @@ -8,30 +8,25 @@ /* * IMPORTS_START * IMPORT TYPE_PREFIX - * IMPORT for_each_possible_pattern + * IMPORT each_url_pattern * IMPORTS_END */ -function check_pattern(storage, pattern, multiple, matched) -{ - const settings = storage.get(TYPE_PREFIX.PAGE, pattern); - - if (settings === undefined) - return; - - matched.push([pattern, settings]); - - if (!multiple) - return false; -} - function query(storage, url, multiple) { const matched = []; const cb = p => check_pattern(storage, p, multiple, matched); - for_each_possible_pattern(url, cb); + for (const pattern of each_url_pattern(url)) { + const result = [pattern, storage.get(TYPE_PREFIX.PAGE, pattern)]; + if (result[1] === undefined) + continue; + + if (!multiple) + return result; + matched.push(result); + } - return multiple ? matched : (matched[0] || [undefined, undefined]); + return multiple ? matched : [undefined, undefined]; } function query_best(storage, url) diff --git a/common/signing.js b/common/signing.js new file mode 100644 index 0000000..2171714 --- /dev/null +++ b/common/signing.js @@ -0,0 +1,73 @@ +/** + * part of Hachette + * Functions related to "signing" of data, refactored to a separate file. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * IMPORTS_START + * IMPORT sha256 + * IMPORT browser + * IMPORT is_chrome + * IMPORTS_END + */ + +/* + * In order to make certain data synchronously accessible in certain contexts, + * hachette smuggles it in string form in places like cookies, URLs and headers. + * When using the smuggled data, we first need to make sure it isn't spoofed. + * For that, we use this pseudo-signing mechanism. + * + * Despite what name suggests, no assymetric cryptography is involved, as it + * would bring no additional benefits and would incur bigger performance + * overhead. Instead, we hash the string data together with some secret value + * that is supposed to be known only by this browser instance. Resulting hash + * sum plays the role of the signature. In the hash we also include current + * time. This way, even if signed data leaks (which shouldn't happen in the + * first place), an attacker won't be able to re-use it indefinitely. + * + * The secret shared between execution contexts has to be available + * synchronously. Under Mozilla, this is the extension's per-session id. Under + * Chromium, this is the key that resides in the manifest. + * + * An idea to (under Chromium) instead store the secret in a file fetched + * synchronously using XMLHttpRequest is being considered. + */ + +function get_secret() +{ + if (is_chrome) + return browser.runtime.getManifest().key.substring(0, 50); + else + return browser.runtime.getURL("dummy"); +} + +function extract_signed(signature, signed_data) +{ + const match = /^([1-9][0-9]{12}|0)_(.*)$/.exec(signed_data); + if (!match) + return {fail: "bad format"}; + + const result = {time: parseInt(match[1]), data: match[2]}; + if (sign_data(result.data, result.time)[0] !== signature) + result.fail = "bad signature"; + + return result; +} + +/* + * Sign a given string for a given time. Time should be either 0 or in the range + * 10^12 <= time < 10^13. + */ +function sign_data(data, time) { + return [sha256(get_secret() + time + data), `${time}_${data}`]; +} + +/* + * EXPORTS_START + * EXPORT extract_signed + * EXPORT sign_data + * EXPORTS_END + */ diff --git a/common/storage_light.js b/common/storage_light.js new file mode 100644 index 0000000..067bf0c --- /dev/null +++ b/common/storage_light.js @@ -0,0 +1,129 @@ +/** + * part of Hachette + * Storage manager, lighter than the previous one. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * IMPORTS_START + * IMPORT TYPE_PREFIX + * IMPORT raw_storage + * IMPORT is_mozilla + * IMPORT observables + */ + +const reg_spec = new Set(["\\", "[", "]", "(", ")", "{", "}", ".", "*", "+"]); +const escape_reg_special = c => reg_spec.has(c) ? "\\" + c : c; + +function make_regex(name) +{ + return new RegExp(`^${name.split("").map(escape_reg_special).join("")}\$`); +} + +const listeners_by_callback = new Map(); + +function listen(callback, prefix, name) +{ + let by_prefix = listeners_by_callback.get(callback); + if (!by_prefix) { + by_prefix = new Map(); + listeners_by_callback.set(callback, by_prefix); + } + + let by_name = by_prefix.get(prefix); + if (!by_name) { + by_name = new Map(); + by_prefix.set(prefix, by_name); + } + + let name_reg = by_name.get(name); + if (!name_reg) { + name_reg = name.test ? name : make_regex(name); + by_name.set(name, name_reg); + } +} + +function no_listen(callback, prefix, name) +{ + const by_prefix = listeners_by_callback.get(callback); + if (!by_prefix) + return; + + const by_name = by_prefix.get(prefix); + if (!by_name) + return; + + const name_reg = by_name.get(name); + if (!name_reg) + return; + + by_name.delete(name); + + if (by_name.size === 0) + by_prefix.delete(prefix); + + if (by_prefix.size === 0) + listeners_by_callback.delete(callback); +} + +function storage_change_callback(changes, area) +{ + if (is_mozilla && area !== "local") + {console.log("area", area);return;} + + for (const item of Object.keys(changes)) { + for (const [callback, by_prefix] of listeners_by_callback.entries()) { + const by_name = by_prefix.get(item[0]); + if (!by_name) + continue; + + for (const reg of by_name.values()) { + if (!reg.test(item.substring(1))) + continue; + + try { + callback(item, changes[item]); + } catch(e) { + console.error(e); + } + } + } + } +} + +raw_storage.listen(storage_change_callback); + + +const created_observables = new Map(); + +async function observe(prefix, name) +{ + const observable = observables.make(); + const callback = (it, ch) => observables.set(observable, ch.newValue); + listen(callback, prefix, name); + created_observables.set(observable, [callback, prefix, name]); + observables.silent_set(observable, await raw_storage.get(prefix + name)); + + return observable; +} + +const observe_var = name => observe(TYPE_PREFIX.VAR, name); + +function no_observe(observable) +{ + no_listen(...created_observables.get(observable) || []); + created_observables.delete(observable); +} + +const light_storage = {}; +Object.assign(light_storage, raw_storage); +Object.assign(light_storage, + {listen, no_listen, observe, observe_var, no_observe}); + +/* + * EXPORTS_START + * EXPORT light_storage + * EXPORTS_END + */ diff --git a/common/storage_raw.js b/common/storage_raw.js new file mode 100644 index 0000000..4c02ee4 --- /dev/null +++ b/common/storage_raw.js @@ -0,0 +1,54 @@ +/** + * part of Hachette + * Basic wrappers for storage API functions. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * IMPORTS_START + * IMPORT TYPE_PREFIX + * IMPORT browser + * IMPORT is_chrome + * IMPORTS_END + */ + +async function get(key) +{ + /* Fix for fact that Chrome does not use promises here */ + const promise = is_chrome ? + new Promise(resolve => chrome.storage.local.get(key, resolve)) : + browser.storage.local.get(key); + + return (await promise)[key]; +} + +async function set(key_or_object, value) +{ + const arg = typeof key_or_object === "object" ? + key_or_object : {[key_or_object]: value}; + return browser.storage.local.set(arg); +} + +async function set_var(name, value) +{ + return set(TYPE_PREFIX.VAR + name, value); +} + +async function get_var(name) +{ + return get(TYPE_PREFIX.VAR + name); +} + +const on_changed = browser.storage.onChanged || browser.storage.local.onChanged; +const listen = cb => on_changed.addListener(cb); +const no_listen = cb => on_changed.removeListener(cb); + +const raw_storage = {get, set, get_var, set_var, listen, no_listen}; + +/* + * EXPORTS_START + * EXPORT raw_storage + * EXPORTS_END + */ diff --git a/content/activity_info_server.js b/content/activity_info_server.js index 81a25fb..beecb1a 100644 --- a/content/activity_info_server.js +++ b/content/activity_info_server.js @@ -44,6 +44,11 @@ function report_settings(settings) report_activity("settings", settings); } +function report_content_type(content_type) +{ + report_activity("content_type", content_type); +} + function report_repo_query_action(update, port) { report_activity_oneshot("repo_query_action", update, port); @@ -91,5 +96,6 @@ function start_activity_info_server() * EXPORT start_activity_info_server * EXPORT report_script * EXPORT report_settings + * EXPORT report_content_type * EXPORTS_END */ diff --git a/content/freezer.js b/content/freezer.js index 9dbc95e..0ea362e 100644 --- a/content/freezer.js +++ b/content/freezer.js @@ -49,6 +49,7 @@ function mozilla_suppress_scripts(e) { console.log('Script suppressor has detached.'); return; } + console.log("script event", e); if (e.isTrusted && !e.target._hachette_payload) { e.preventDefault(); console.log('Suppressed script', e.target); diff --git a/content/main.js b/content/main.js index 8440eb5..4fe6d43 100644 --- a/content/main.js +++ b/content/main.js @@ -10,6 +10,7 @@ * IMPORTS_START * IMPORT handle_page_actions * IMPORT extract_signed + * IMPORT sign_data * IMPORT gen_nonce * IMPORT is_privileged_url * IMPORT mozilla_suppress_scripts @@ -22,15 +23,67 @@ * IMPORTS_END */ -function accept_node(node, parent) +function extract_cookie_policy(cookie, min_time) { - const clone = document.importNode(node, false); - node.hachette_corresponding = clone; - /* - * TODO: Stop page's own issues like "Error parsing a meta element's - * content:" from appearing as extension's errors. - */ - parent.hachette_corresponding.appendChild(clone); + let best_result = {time: -1}; + let policy = null; + const extracted_signatures = []; + + for (const match of cookie.matchAll(/hachette-(\w*)=([^;]*)/g)) { + const new_result = extract_signed(...match.slice(1, 3)); + if (new_result.fail) + continue; + + extracted_signatures.push(match[1]); + + if (new_result.time < Math.max(min_time, best_result.time)) + continue; + + /* This should succeed - it's our self-produced valid JSON. */ + const new_policy = JSON.parse(decodeURIComponent(new_result.data)); + if (new_policy.url !== document.URL) + continue; + + best_result = new_result; + policy = new_policy; + } + + return [policy, extracted_signatures]; +} + +function extract_url_policy(url, min_time) +{ + const [base_url, payload, anchor] = + /^([^#]*)#?([^#]*)(#?.*)$/.exec(url).splice(1, 4); + + const match = /^hachette_([^_]+)_(.*)$/.exec(payload); + if (!match) + return [null, url]; + + const result = extract_signed(...match.slice(1, 3)); + if (result.fail) + return [null, url]; + + const original_url = base_url + anchor; + const policy = result.time < min_time ? null : + JSON.parse(decodeURIComponent(result.data)); + + return [policy.url === original_url ? policy : null, original_url]; +} + +function employ_nonhttp_policy(policy) +{ + if (!policy.allow) + return; + + policy.nonce = gen_nonce(); + const [base_url, target] = /^([^#]*)(#?.*)$/.exec(policy.url).slice(1, 3); + const encoded_policy = encodeURIComponent(JSON.stringify(policy)); + const payload = "hachette_" + + sign_data(encoded_policy, new Date().getTime()).join("_"); + const resulting_url = `${base_url}#${payload}${target}`; + location.href = resulting_url; + location.reload(); } /* @@ -171,23 +224,44 @@ async function sanitize_document(doc, policy) } if (!is_privileged_url(document.URL)) { - const reductor = - (ac, [_, sig, pol]) => ac[0] && ac || [extract_signed(sig, pol), sig]; - const matches = [...document.cookie.matchAll(/hachette-(\w*)=([^;]*)/g)]; - let [policy, signature] = matches.reduce(reductor, []); + let policy_received_callback = () => undefined; + let policy; + + /* Signature valid for half an hour. */ + const min_time = new Date().getTime() - 1800 * 1000; + + if (/^https?:/.test(document.URL)) { + let signatures; + [policy, signatures] = extract_cookie_policy(document.cookie, min_time); + for (const signature of signatures) + document.cookie = `hachette-${signature}=; Max-Age=-1;`; + } else { + const scheme = /^([^:]*)/.exec(document.URL)[1]; + const known_scheme = ["file", "ftp"].includes(scheme); + + if (!known_scheme) + console.warn(`Unknown url scheme: \`${scheme}'!`); + + let original_url; + [policy, original_url] = extract_url_policy(document.URL, min_time); + history.replaceState(null, "", original_url); + + if (known_scheme && !policy) + policy_received_callback = employ_nonhttp_policy; + } - if (!policy || policy.url !== document.URL) { - console.log("WARNING! Using default policy!!!"); + if (!policy) { + console.warn("Using fallback policy!"); policy = {allow: false, nonce: gen_nonce()}; } - if (signature) - document.cookie = `hachette-${signature}=; Max-Age=-1;`; - - if (!policy.allow) - sanitize_document(document, policy); + const doc_ready = Promise.all([ + policy.allow ? Promise.resolve : sanitize_document(document, policy), + new Promise(cb => document.addEventListener("DOMContentLoaded", + cb, {once: true})) + ]); - handle_page_actions(policy.nonce); + handle_page_actions(policy.nonce, policy_received_callback, doc_ready); start_activity_info_server(); } diff --git a/content/page_actions.js b/content/page_actions.js index aff56b8..8057541 100644 --- a/content/page_actions.js +++ b/content/page_actions.js @@ -11,13 +11,18 @@ * IMPORT browser * IMPORT report_script * IMPORT report_settings + * IMPORT report_content_type * IMPORTS_END */ -var port; -var loaded = false; -var scripts_awaiting = []; -var nonce; +let policy_received_callback; +/* Snapshot url and content type early; these can be changed by other code. */ +let url; +let is_html; +let port; +let loaded = false; +let scripts_awaiting = []; +let nonce; function handle_message(message) { @@ -31,11 +36,13 @@ function handle_message(message) scripts_awaiting.push(script_text); } } - if (action === "settings") + if (action === "settings") { report_settings(data); + policy_received_callback({url, allow: data[1].allow}); + } } -function document_loaded(event) +function document_ready(event) { loaded = true; @@ -47,6 +54,9 @@ function document_loaded(event) function add_script(script_text) { + if (!is_html) + return; + let script = document.createElement("script"); script.textContent = script_text; script.setAttribute("nonce", nonce); @@ -56,11 +66,18 @@ function add_script(script_text) report_script(script_text); } -function handle_page_actions(script_nonce) { - document.addEventListener("DOMContentLoaded", document_loaded); +function handle_page_actions(script_nonce, policy_received_cb, + doc_ready_promise) { + policy_received_callback = policy_received_cb; + url = document.URL; + is_html = /html/.test(document.contentType); + report_content_type(document.contentType); + + doc_ready_promise.then(document_ready); + port = browser.runtime.connect({name : CONNECTION_TYPE.PAGE_ACTIONS}); port.onMessage.addListener(handle_message); - port.postMessage({url: document.URL}); + port.postMessage({url}); nonce = script_nonce; } @@ -31,11 +31,20 @@ Files: *.html README.txt copyright Copyright: 2021 Wojtek Kosior <koszko@koszko.org> License: GPL-3+ or Alicense-1.0 or CC-BY-SA-4.0 +Files: html/*.css +Copyright: 2021 Wojtek Kosior <koszko@koszko.org> +License: GPL-3+ or Alicense-1.0 or CC-BY-SA-4.0 + Files: html/base.css Copyright: 2021 Wojtek Kosior <koszko@koszko.org> 2021 Nicholas Johnson <nicholasjohnson@posteo.org> License: GPL-3+ or Alicense-1.0 or CC-BY-SA-4.0 +Files: html/reset.css +Copyright: 2008,2011 Eric A. Meyer +License: public-domain + This stylesheet has been place in public domain by its author. + Files: common/sha256.js Copyright: 2014-2017 Chen, Yi-Cyuan <emn178@gmail.com> License: Expat diff --git a/html/DOM_helpers.js b/html/DOM_helpers.js index 2bff966..01e2be9 100644 --- a/html/DOM_helpers.js +++ b/html/DOM_helpers.js @@ -10,9 +10,27 @@ function by_id(id) return document.getElementById(id); } +const known_templates = new Map(); + +function get_template(template_id) +{ + let template = known_templates.get(template_id) || null; + if (template) + return template; + + for (const template_node of document.getElementsByTagName("TEMPLATE")) { + template = template_node.content.getElementById(template_id); + if (template) + break; + } + + known_templates.set(template_id, template); + return template; +} + function clone_template(template_id) { - const clone = document.getElementById(template_id).cloneNode(true); + const clone = get_template(template_id).cloneNode(true); const result_object = {}; const to_process = [clone]; @@ -24,7 +42,7 @@ function clone_template(template_id) result_object[template_key] = element; element.removeAttribute("id"); - element.removeAttribute("template_key"); + element.removeAttribute("data-template"); for (const child of element.children) to_process.push(child); @@ -36,6 +54,7 @@ function clone_template(template_id) /* * EXPORTS_START * EXPORT by_id + * EXPORT get_template * EXPORT clone_template * EXPORTS_END */ diff --git a/html/MOZILLA_scrollbar_fix.css b/html/MOZILLA_scrollbar_fix.css new file mode 100644 index 0000000..5feb7c3 --- /dev/null +++ b/html/MOZILLA_scrollbar_fix.css @@ -0,0 +1,46 @@ +/** + * Hachette + * Hacky fix for vertical scrollbar width being included in child's width. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * Under Mozilla browsers to avoid vertical scrollbar forcing horizontal + * scrollbar to appear in an element we add the `firefox_scrollbars_hacky_fix' + * class to an element for which width has to be reserved. + * + * This is a bit hacky and relies on some assumed width of Firefox scrollbar, I + * know. And must be excluded from Chromium builds. + * + * I came up with this hack when working on popup. Before that I had the + * scrollbar issue with tables in the options page and gave up there and made + * the scrollbal always visible. Now we could try applying this "fix" there, + * too! + */ + +.firefox_scrollbars_hacky_fix { + font-size: 0; +} + +.firefox_scrollbars_hacky_fix>div { + display: inline-block; + width: -moz-available; +} + +.firefox_scrollbars_hacky_fix>*>* { + font-size: initial; +} + +.firefox_scrollbars_hacky_fix::after { + content: ""; + display: inline-block; + visibility: hidden; + font-size: initial; + width: 14px; +} + +.firefox_scrollbars_hacky_fix.has_inline_content::after { + width: calc(14px - 0.3em); +} diff --git a/html/back_button.css b/html/back_button.css new file mode 100644 index 0000000..1ddc5da --- /dev/null +++ b/html/back_button.css @@ -0,0 +1,50 @@ +/** + * part of Hachette + * Style for a "back" button with a CSS arrow image. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +.back_button { + display: block; + width: auto; + height: auto; + background-color: white; + border: solid #454 0.4em; + border-left: none; + border-radius: 0 1.5em 1.5em 0; + cursor: pointer; +} + +.back_button:hover { + box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); +} + +.back_button>div, .back_arrow { + width: 2em; + height: 0.5em; + background-color: #4CAF50; + border-radius: 0.3em; + margin: 1.15em 0.4em; +} + +.back_button>div::after, .back_arrow::after, +.back_button>div::before, .back_arrow::before { + content: ""; + display: block; + position: relative; + background-color: inherit; + width: 1.3em; + height: 0.5em; + transform: rotate(45deg); + border-radius: 0.3em; + top: 0.3em; + right: 0.2em; + margin: 0 -1.3em -0.5em 0; +} + +.back_button>div::before, .back_arrow::before { + transform: rotate(-45deg); + top: -0.3em; +} diff --git a/html/base.css b/html/base.css index 2256833..df52f7c 100644 --- a/html/base.css +++ b/html/base.css @@ -6,10 +6,40 @@ * Redistribution terms are gathered in the `copyright' file. */ +body { + font-family: sans-serif; + background-color: #f0f0f0; + color: #555; + overflow: auto; +} + +.bold, h2 { + font-weight: bold; +} + +h2 { + margin: 8px; + font-size: 120%; +} + +h3 { + padding: 5px; + font-size: 108%; + text-shadow: 0 0 0 #454; +} + +textarea { + font-family: monospace; +} + input[type="checkbox"], input[type="radio"], .hide { display: none; } +.camouflage { + visibility: hidden; +} + .show_next:not(:checked)+* { display: none; } @@ -32,6 +62,10 @@ button, .button { display: inline-block; padding: 6px 12px; margin: 2px 0px; + -moz-user-select: none; + user-select: none; + cursor: pointer; + font: 400 15px sans-serif; } button.slimbutton, .button.slimbutton { @@ -42,3 +76,38 @@ button.slimbutton, .button.slimbutton { button:hover, .button:hover { box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); } + +aside { + background: #3f8dc6; + margin: 5px 0; + padding: 0.3em 1em; + border-radius: 3px; + color: #fff; +} + +textarea: { + resize: none; +} + +.has_bottom_line::after, .has_upper_line::before { + content: ""; + display: block; + height: 8px; + background: linear-gradient(transparent, #555); +} + +.has_bottom_line::after { + background: linear-gradient(#555, transparent); +} + +.has_bottom_thin_line { + border-bottom: dashed #4CAF50 1px; +} + +.has_upper_thin_line { + border-top: dashed #4CAF50 1px; +} + +.nowrap { + white-space: nowrap; +} diff --git a/html/default_blocking_policy.html b/html/default_blocking_policy.html new file mode 100644 index 0000000..50c19ca --- /dev/null +++ b/html/default_blocking_policy.html @@ -0,0 +1,18 @@ +<!-- + Copyright (C) 2021 Wojtek Kosior + Redistribution terms are gathered in the `copyright' file. + + This is not a standalone page. This file is meant to be imported into other + HTML code. + --> +<style> + #blocking_policy_div { + line-height: 2em; + } +</style> +<span id="blocking_policy_span"> + Default policy for unmatched pages is to + <span id="current_policy_span" class="bold"></span> + their own scripts. + <button id="toggle_policy_but">Toggle policy</button> +</span> diff --git a/html/default_blocking_policy.js b/html/default_blocking_policy.js new file mode 100644 index 0000000..2f49bac --- /dev/null +++ b/html/default_blocking_policy.js @@ -0,0 +1,47 @@ +/** + * part of Hachette + * Default policy dialog logic. + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * IMPORTS_START + * IMPORT by_id + * IMPORT light_storage + * IMPORT observables + * IMPORTS_END + */ + +/* + * Used with `default_blocking_policy.html' to allow user to choose whether to + * block scripts globally or not. + */ + +const blocking_policy_span = by_id("blocking_policy_span"); +const current_policy_span = by_id("current_policy_span"); +const toggle_policy_but = by_id("toggle_policy_but"); + +let policy_observable; + +const update_policy = + allowed => current_policy_span.textContent = allowed ? "allow" : "block"; +const toggle_policy = + () => light_storage.set_var("default_allow", !policy_observable.value); + +async function init_default_policy_dialog() +{ + policy_observable = await light_storage.observe_var("default_allow"); + update_policy(policy_observable.value); + observables.subscribe(policy_observable, update_policy); + + toggle_policy_but.addEventListener("click", toggle_policy); + blocking_policy_span.classList.remove("hide"); +} + +/* + * EXPORTS_START + * EXPORT init_default_policy_dialog + * EXPORTS_END + */ diff --git a/html/display-panel.html b/html/display-panel.html index 1b9c77b..3ed1b7a 100644 --- a/html/display-panel.html +++ b/html/display-panel.html @@ -7,119 +7,337 @@ <head> <meta charset="utf-8"/> <title>Hachette - page settings</title> + <link type="text/css" rel="stylesheet" href="reset.css" /> <link type="text/css" rel="stylesheet" href="base.css" /> + <link type="text/css" rel="stylesheet" href="back_button.css" /> + <link type="text/css" rel="stylesheet" href="table.css" /> + <link type="text/css" rel="stylesheet" href="MOZILLA_scrollbar_fix.css" /> <style> body { - width: 300px; - height: 300px; + width: max-content; + width: -moz-fit-content; } - ul { - padding-inline-start: 15px; + .top>h2 { + padding-left: calc(0.8*3.2em - 8px); } - .bold { - font-weight: bold; + .top { + line-height: calc(0.8*3.6em - 16px); + } + + #main_view>.top>h2 { + padding-left: 0; + max-width: 550px } - .unroll_chbx:not(:checked)+*+label span.triangle:first-child+span.triangle, - .unroll_chbx:checked+*+label span.triangle:first-child, - .unroll_chbx:not(:checked)+*, - .unroll_chbx:not(:checked)+*+label+* { + .unroll_chbx:not(:checked)+div>:not(:first-child) { display: none; } + .unroll_triangle { + height: 1em; + width: 1em; + display: inline-block; + } + + .unroll_triangle::after { + content: ""; + width: 0.6em; + height: 0.6em; + background: linear-gradient(-45deg, currentColor 50%, transparent 50%); + display: block; + position: relative; + transform: rotate(-45deg); + top: 0.3em; + } + + .unroll_chbx:checked+div>:first-child .unroll_triangle::after { + transform: rotate(45deg); + left: 0.2em; + top: 0.2em; + } + + .unroll_chbx:checked+div>:first-child .unroll_block { + display: block; + } + + .unroll_chbx:checked+div>:first-child { + line-height: 1.4em; + } + + .l2_ul { + border-left: solid #454 5px; + } + + .l1_li { + margin-top: 0.3em; + margin-bottom: 0.3em; + } + + .l1_li>div { + padding: 0.3em 0.3em 0.3em 0; + } + + .l2_li { + padding: 0.3em; + } + + #container_for_injected>*:nth-child(odd), + .l2_li:nth-child(odd) { + background-color: #e5e5e5; + } + #container_for_injected>#none_injected:not(:last-child) { display: none; } + + #page_url_heading>span { + display: inline-block; + } + + .back_button { + position: fixed; + z-index: 1; + top: 0; + left: 0; + /* The following scales the entire button. */ + font-size: 80%; + } + + #show_main_view_radio:checked~.back_button { + margin-left: -3.2em; + } + + #show_main_view_radio:not(:checked)~.back_button { + transition: all 0.2s ease-out; + } + + pre { + font-family: monospace; + background-color: white; + padding: 1px 5px; + } + + .matched_pattern { + font-weight: bold; + } + + tr.matched_pattern~tr { + color: #777; + font-size: 90%; + } + + .padding_inline { + padding-left: 5px; + padding-right: 5px; + } + + .padding_top { + padding-top: 5px; + } + + .header { + padding-bottom: 0.3em; + margin-bottom: 0.5em; + text-align: center; + } + + .middle { + margin-top: 0.5em; + margin-bottom: 0.5em; + } + + .footer { + padding-top: 0.3em; + margin-top: 0.5em; + text-align: center; + } + + .active_setting_table { + margin-bottom: 0.5em; + } + + .active_setting_table td { + padding: 5px; + vertical-align: middle; + } </style> </head> <body> - <!-- The invisible div below is for elements that will be cloned. --> - <div class="hide"> - <li id="pattern_li_template"> - <span></span> - <button>View in settings</button> - </li> - <li id="query_match_li_template" class="queried_pattern_match" data-template="li"> + <template> + <tr id="pattern_entry" class="nowrap" data-template="entry"> + <td data-template="name"></td> + <td> + <div class="button" data-template="button">Add setting</div> + </td> + </tr> + + <li id="query_match_li" class="l2_li" data-template="li"> <div> <span>pattern:</span> <span class="bold" data-template="pattern"></span> - <button data-template="btn">Install</button> + <label class="button slimbutton" for="show_install_view_radio" data-template="btn"> + Install + </label> </div> - <div id="unrollable_component_template" data-template="unroll_container"> - <span data-template="component_label">payload:</span> + <div id="unrollable_component" data-template="unroll_container"> <input type="checkbox" class="unroll_chbx" data-template="chbx"></input> - <br data-template="br"/> - <label class="bold" data-template="lbl"> - <span data-template="triangle"> - <span class="triangle">⏵</span> - <span class="triangle">⏷</span> + <div> + <span>payload: + <label class="bold unroll_block" data-template="lbl"> + <div data-template="triangle" class="unroll_triangle"></div> + <span data-template="payload"></span> + </label> </span> - <span data-template="component"></span> + <div data-template="unroll"></div> + </div> + </div> + </li> + + <div id="injected_script" data-template="div"> + <input type="checkbox" class="unroll_chbx" data-template="chbx"></input> + <div> + <label data-template="lbl"> + <h3><div class="unroll_triangle"></div> script</h3> </label> - <div data-template="unroll"></div> + <pre class="has_bottom_thin_line has_upper_thin_line" data-template="script_contents"></pre> + </div> + </div> + + <div id="multi_repos_query_result" data-template="div"> + Results for <span class="bold" data-template="url_span"></span> + <ul class="l1_ul" data-template="ul"></ul> + </div> + + <li id="single_repo_query_result" class="l1_li" data-template="li"> + <div> + From <span class="bold" data-template="repo_url"></span> </div> </li> - </div> - <input id="show_install_view_chbx" type="checkbox" class="show_hide_next2"></input> + <ul id="result_patterns_list" class="l2_ul" data-template="ul"> + </ul> + </template> + + <input id="show_install_view_radio" type="radio" class="show_next" name="current_view"></input> <div id="install_view"> - <IMPORT html/import_frame.html /> - <!-- - <div id="install_status"></div> - <label for="show_install_chbx" class="bold">Cancel install</label> - <button id="commit_install_but">Commit install</button> - --> + <div class="top has_bottom_line"><h2> Site modifiers install </h2></div> + <div class="padding_inline"> + <IMPORT html/import_frame.html /> + </div> </div> - <div id="main_view"> - <h2 id="page_url_heading"></h2> - - <input id="show_privileged_notice_chbx" type="checkbox" class="show_next"></input> - <h3>Privileged page</h3> - - <input id="show_page_state_chbx" type="checkbox" class="show_next"></input> - <div> - <input id="possible_patterns_chbx" type="checkbox" class="unroll_chbx"></input> - <span></span> - <label for="possible_patterns_chbx"> - <h3> - <span class="triangle">⏵</span> - <span class="triangle">⏷</span> - Possible patterns - </h3> - </label> - <ul id="possible_patterns"></ul> - - <input id="connected_chbx" type="checkbox" class="show_hide_next2"></input> + + <input id="show_injected_view_radio" type="radio" class="show_next" name="current_view"></input> + <div id="injected_view"> + <div class="top has_bottom_line"><h2>Injected scripts</h2></div> + <div id="container_for_injected"> + <span id="none_injected">None</span> + </div> + </div> + + <input id="show_patterns_view_radio" type="radio" class="show_next" name="current_view"></input> + <div> + <div class="top has_bottom_line"><h2>Possible patterns for this page</h2></div> + <div class="padding_inline"> + <aside> + Patterns higher are more specific and override the ones below. + </aside> + </div> + <div class="table_wrapper firefox_scrollbars_hacky_fix"> <div> - Matched pattern: <span id="pattern" class="bold">...</span> - <button id="view_pattern" class="hide"> - View in settings - </button> - <br/> - Blocked: <span id="blocked" class="bold">...</span> - <br/> - Payload: <span id="payload" class="bold">...</span> - <button id="view_payload" class="hide"> - View in settings - </button> - <h3>Injected</h3> - <div id="container_for_injected"> - <span id="none_injected">None</span> + <table> + <tbody id="possible_patterns"> + </tbody> + </table> + </div> + </div> + <div class="padding_inline padding_top has_upper_thin_line firefox_scrollbars_hacky_fix has_inline_content"> + <span class="nowrap"> + <IMPORT html/default_blocking_policy.html /> + </span> + </div> + </div> + + <input id="show_queried_view_radio" type="radio" class="show_next" name="current_view"></input> + <div> + <div class="top has_bottom_line"><h2>Queried from repositories</h2></div> + <div id="container_for_repo_responses" class="padding_inline"> + </div> + </div> + + <input id="show_main_view_radio" type="radio" class="show_next" name="current_view" checked></input> + <div id="main_view"> + <div class="top has_bottom_line"><h2 id="page_url_heading"></h2></div> + <h3 id="privileged_notice" class="middle hide">Privileged page</h3> + + <div id="page_state" class="hide"> + <div class="header padding_inline has_bottom_thin_line"> + <label for="show_patterns_view_radio" class="button"> + Edit settings for this page + </label> + </div> + <div class="middle padding_inline"> + <input id="connected_chbx" type="checkbox" class="show_hide_next2"></input> + <div> + <table class="active_setting_table"> + <tbody> + <tr class="nowrap"> + <td>Matched pattern:</td> + <td id="pattern" class="bold">...</td> + <td> + <button id="view_pattern" class="hide"> + View in settings + </button> + </td> + </tr> + <tr class="nowrap"> + <td>Scripts blocked:</td> + <td id="blocked" class="bold">...</td> + <td></td> + </tr> + <tr class="nowrap"> + <td>Injected payload:</td> + <td id="payload" class="bold">...</td> + <td id="payload_buttons" class="hide"> + <button id="view_payload"> View in settings </button> + <br/> + <label id="view_injected" class="button" for="show_injected_view_radio"> + View injected scripts + </label> + </td> + </tr> + <tr> + <td id="content_type" colspan="3" class="hide"> + This is a non-HTML page. Chosen payload will not be injected. + </td> + </tr> + </tbody> + </table> + <label id="query_pattern" for="show_queried_view_radio" class="button"> + Install scripts for this page + </label> </div> - <input id="query_started_chbx" type="checkbox" class="show_hide_next2"></input> - <div id="container_for_repo_responses"> - <h3>Queried from repositories</h3> + <div> + <h3> + Connecting to content script..<span id="loading_point">.</span> + </h3> + <aside id="reload_notice"> + Try reloading the page. + </aside> </div> - <button id="query_pattern"> - Search for matching patterns - </button> </div> - <h3>Trying to connect..<input id="loading_chbx" type="checkbox" class="show_next"></input><span>.</span></h3> </div> - <button id="settings_but" type="button" style="margin-top: 20px;">Settings</button> - </div>_POPUPSCRIPTS_ + <div class="footer padding_inline has_upper_thin_line"> + <button id="settings_but" type="button"> + Open Hachette settings + </button> + </div> + </div> + + <div class="has_upper_line"></div> + + <label for="show_main_view_radio" class="back_button"><div></div></label>_POPUPSCRIPTS_ </body> </html> diff --git a/html/display-panel.js b/html/display-panel.js index 2539ded..66e51a6 100644 --- a/html/display-panel.js +++ b/html/display-panel.js @@ -14,13 +14,15 @@ *** temporarily, before all storage access gets reworked. * IMPORT get_remote_storage * IMPORT get_import_frame + * IMPORT init_default_policy_dialog * IMPORT query_all * IMPORT CONNECTION_TYPE * IMPORT is_privileged_url * IMPORT TYPE_PREFIX * IMPORT nice_name * IMPORT open_in_settings - * IMPORT for_each_possible_pattern + * IMPORT url_matches + * IMPORT each_url_pattern * IMPORT by_id * IMPORT clone_template * IMPORTS_END @@ -29,6 +31,16 @@ let storage; let tab_url; +/* Force popup <html>'s reflow on stupid Firefox. */ +if (is_mozilla) { + const reflow_forcer = + () => document.documentElement.style.width = "-moz-fit-content"; + for (const radio of document.querySelectorAll('[name="current_view"]')) + radio.addEventListener("change", reflow_forcer); +} + +const show_queried_view_radio = by_id("show_queried_view_radio"); + const tab_query = {currentWindow: true, active: true}; async function get_current_tab() @@ -47,8 +59,21 @@ async function get_current_tab() } const page_url_heading = by_id("page_url_heading"); -const show_privileged_notice_chbx = by_id("show_privileged_notice_chbx"); -const show_page_state_chbx = by_id("show_page_state_chbx"); +const privileged_notice = by_id("privileged_notice"); +const page_state = by_id("page_state"); + +/* Helper functions to convert string into a list of one-letter <span>'s. */ +function char_to_span(char, doc) +{ + const span = document.createElement("span"); + span.textContent = char; + return span; +} + +function to_spans(string, doc=document) +{ + return string.split("").map(c => char_to_span(c, doc)); +} async function show_page_activity_info() { @@ -60,79 +85,74 @@ async function show_page_activity_info() } tab_url = /^([^?#]*)/.exec(tab.url)[1]; - page_url_heading.textContent = tab_url; + to_spans(tab_url).forEach(s => page_url_heading.append(s)); if (is_privileged_url(tab_url)) { - show_privileged_notice_chbx.checked = true; + privileged_notice.classList.remove("hide"); return; } populate_possible_patterns_list(tab_url); - show_page_state_chbx.checked = true; + page_state.classList.remove("hide"); try_to_connect(tab.id); } -const possible_patterns_ul = by_id("possible_patterns"); -const pattern_li_template = by_id("pattern_li_template"); -pattern_li_template.removeAttribute("id"); +const possible_patterns_list = by_id("possible_patterns"); const known_patterns = new Map(); function add_pattern_to_list(pattern) { - const li = pattern_li_template.cloneNode(true); - li.id = `pattern_li_${known_patterns.size}`; - known_patterns.set(pattern, li.id); + const template = clone_template("pattern_entry"); + template.name.textContent = pattern; - const span = li.firstElementChild; - span.textContent = pattern; - - const button = span.nextElementSibling; const settings_opener = () => open_in_settings(TYPE_PREFIX.PAGE, pattern); - button.addEventListener("click", settings_opener); + template.button.addEventListener("click", settings_opener); - possible_patterns_ul.appendChild(li) + known_patterns.set(pattern, template); + possible_patterns_list.append(template.entry); - return li.id; + return template; } function ensure_pattern_exists(pattern) { - let id = known_patterns.get(pattern); + let entry_object = known_patterns.get(pattern); /* * As long as pattern computation works well, we should never get into this * conditional block. This is just a safety measure. To be removed as part * of a bigger rework when we start taking iframes into account. */ - if (id === undefined) { + if (entry_object === undefined) { console.log(`unknown pattern: ${pattern}`); - id = add_pattern_to_list(pattern); + entry_object = add_pattern_to_list(pattern); } - return id; + return entry_object; } -function set_pattern_li_button_text(li_id, text) +function style_possible_pattern_entry(pattern, exists_in_settings) { - by_id(li_id).firstElementChild.nextElementSibling.textContent = text; + const [text, class_action] = exists_in_settings ? + ["Edit", "add"] : ["Add", "remove"]; + const entry_object = ensure_pattern_exists(pattern); + + entry_object.button.textContent = `${text} setting`; + entry_object.entry.classList[class_action]("matched_pattern"); } function handle_page_change(change) { - const li_id = ensure_pattern_exists(change.item); - if (change.old_val === undefined) - set_pattern_li_button_text(li_id, "Edit in settings"); - if (change.new_val === undefined) - set_pattern_li_button_text(li_id, "Add setting"); + if (url_matches(tab_url, change.item)) + style_possible_pattern_entry(change.item, change.new_val !== undefined); } function populate_possible_patterns_list(url) { - for_each_possible_pattern(url, add_pattern_to_list); + for (const pattern of each_url_pattern(url)) + add_pattern_to_list(pattern); - for (const [pattern, settings] of query_all(storage, url)) { - set_pattern_li_button_text(ensure_pattern_exists(pattern), - "Edit in settings"); - } + for (const [pattern, settings] of query_all(storage, url)) + style_possible_pattern_entry(pattern, true); storage.add_change_listener(handle_page_change, [TYPE_PREFIX.PAGE]); } @@ -158,17 +178,16 @@ function try_to_connect(tab_id) setTimeout(() => monitor_connecting(tab_id), 1000); } -const query_started_chbx = by_id("query_started_chbx"); - -function start_querying_repos(port) +function start_querying_repos() { + query_pattern_but.removeEventListener("click", start_querying_repos); const repo_urls = storage.get_all_names(TYPE_PREFIX.REPO); if (content_script_port) content_script_port.postMessage([TYPE_PREFIX.URL, tab_url, repo_urls]); - query_started_chbx.checked = true; } -const loading_chbx = by_id("loading_chbx"); +const loading_point = by_id("loading_point"); +const reload_notice = by_id("reload_notice"); function handle_disconnect(tab_id, button_cb) { @@ -182,7 +201,9 @@ function handle_disconnect(tab_id, button_cb) if (connected_chbx.checked) return; - loading_chbx.checked = !loading_chbx.checked; + loading_point.classList.toggle("camouflage"); + reload_notice.classList.remove("hide"); + setTimeout(() => try_to_connect(tab_id), 1000); } @@ -196,7 +217,8 @@ function monitor_connecting(tab_id) else return; - loading_chbx.checked = !loading_chbx.checked; + loading_point.classList.toggle("camouflage"); + reload_notice.classList.remove("hide"); try_to_connect(tab_id); } @@ -204,11 +226,16 @@ const pattern_span = by_id("pattern"); const view_pattern_but = by_id("view_pattern"); const blocked_span = by_id("blocked"); const payload_span = by_id("payload"); +const payload_buttons_div = by_id("payload_buttons"); const view_payload_but = by_id("view_payload"); +const view_injected_but = by_id("view_injected"); const container_for_injected = by_id("container_for_injected"); +const content_type_cell = by_id("content_type"); const queried_items = new Map(); +let max_injected_script_id = 0; + function handle_activity_report(message) { connected_chbx.checked = true; @@ -218,7 +245,6 @@ function handle_activity_report(message) if (type === "settings") { let [pattern, settings] = data; - settings = settings || {}; blocked_span.textContent = settings.allow ? "no" : "yes"; if (pattern) { @@ -229,30 +255,32 @@ function handle_activity_report(message) view_pattern_but.addEventListener("click", settings_opener); } else { pattern_span.textContent = "none"; + blocked_span.textContent = blocked_span.textContent + " (default)"; } const components = settings.components; if (components) { payload_span.textContent = nice_name(...components); + payload_buttons_div.classList.remove("hide"); const settings_opener = () => open_in_settings(...components); - view_payload_but.classList.remove("hide"); view_payload_but.addEventListener("click", settings_opener); } else { payload_span.textContent = "none"; } } if (type === "script") { - const h4 = document.createElement("h4"); - const pre = document.createElement("pre"); - h4.textContent = "script"; - pre.textContent = data; - - container_for_injected.appendChild(h4); - container_for_injected.appendChild(pre); + const template = clone_template("injected_script"); + const chbx_id = `injected_script_${max_injected_script_id++}`; + template.chbx.id = chbx_id; + template.lbl.setAttribute("for", chbx_id); + template.script_contents.textContent = data; + container_for_injected.appendChild(template.div); + } + if (type === "content_type") { + if (!/html/.test(data)) + content_type_cell.classList.remove("hide"); } if (type === "repo_query_action") { - query_started_chbx.checked = true; - const key = data.prefix + data.item; const results = queried_items.get(key) || {}; Object.assign(results, data.results); @@ -272,35 +300,26 @@ const results_lists = new Map(); function create_results_list(url) { - const list_div = document.createElement("div"); - const list_head = document.createElement("h4"); - const list = document.createElement("ul"); + const cloned_template = clone_template("multi_repos_query_result"); + cloned_template.url_span.textContent = url; + container_for_repo_responses.appendChild(cloned_template.div); - list_head.textContent = url; - list_div.appendChild(list_head); - list_div.appendChild(list); - container_for_repo_responses.appendChild(list_div); + cloned_template.by_repo = new Map(); + results_lists.set(url, cloned_template); - const list_object = {list, by_repo: new Map()}; - - results_lists.set(url, list_object); - - return list_object; + return cloned_template; } function create_result_item(list_object, repo_url, result) { - const result_li = document.createElement("li"); - const repo_url_span = document.createElement("span"); - const result_item = {result_li, appended: null}; - - repo_url_span.textContent = repo_url; - result_li.appendChild(repo_url_span); + const cloned_template = clone_template("single_repo_query_result"); + cloned_template.repo_url.textContent = repo_url; + cloned_template.appended = null; - list_object.list.appendChild(result_li); - list_object.by_repo.set(repo_url, result_item); + list_object.ul.appendChild(cloned_template.li); + list_object.by_repo.set(repo_url, cloned_template); - return result_item; + return cloned_template; } function set_appended(result_item, element) @@ -308,7 +327,7 @@ function set_appended(result_item, element) if (result_item.appended) result_item.appended.remove(); result_item.appended = element; - result_item.result_li.appendChild(element); + result_item.li.appendChild(element); } function show_message(result_item, text) @@ -331,11 +350,9 @@ function unroll_chbx_first_checked(entry_object) entry_object.chbx.removeEventListener("change", entry_object.unroll_cb); delete entry_object.unroll_cb; - entry_object.unroll.textContent = "preview not implemented..."; + entry_object.unroll.innerHTML = "preview not implemented...<br />(consider contributing)"; } -const show_install_chbx = by_id("show_install_view_chbx"); - let import_frame; let install_target = null; @@ -461,7 +478,6 @@ function record_fetched_install_dep(prefix, item, repo_url, result) function install_clicked(entry_object) { - show_install_chbx.checked = true; import_frame.show_loading(); install_target = { @@ -481,25 +497,24 @@ var max_query_result_id = 0; function show_query_successful_result(result_item, repo_url, result) { - const ul = document.createElement("ul"); - - set_appended(result_item, ul); + const cloned_ul_template = clone_template("result_patterns_list"); + set_appended(result_item, cloned_ul_template.ul); for (const match of result) { - const entry_object = clone_template("query_match_li_template"); + const entry_object = clone_template("query_match_li"); entry_object.pattern.textContent = match.pattern; - ul.appendChild(entry_object.li); + cloned_ul_template.ul.appendChild(entry_object.li); if (!match.payload) { entry_object.payload.textContent = "(none)"; - for (const key of ["chbx", "br", "triangle", "unroll"]) + for (const key of ["chbx", "triangle", "unroll"]) entry_object[key].remove(); continue; } - entry_object.component.textContent = nice_name(...match.payload); + entry_object.payload.textContent = nice_name(...match.payload); const install_cb = () => install_clicked(entry_object); entry_object.btn.addEventListener("click", install_cb); @@ -540,9 +555,11 @@ by_id("settings_but") async function main() { + init_default_policy_dialog(); + storage = await get_remote_storage(); import_frame = await get_import_frame(); - import_frame.onclose = () => show_install_chbx.checked = false; + import_frame.onclose = () => show_queried_view_radio.checked = true; show_page_activity_info(); } diff --git a/html/import_frame.html b/html/import_frame.html index c86c3de..754b289 100644 --- a/html/import_frame.html +++ b/html/import_frame.html @@ -1,13 +1,28 @@ -<div style="display: none;"> - <li id="import_li_template"> - <span></span> - <input type="checkbox" style="display: inline;" checked></input> - <span></span> - </li> -</div> -<h2> Settings import </h2> +<!-- + Copyright (C) 2021 Wojtek Kosior + Redistribution terms are gathered in the `copyright' file. + + This is not a standalone page. This file is meant to be imported into other + HTML code. + --> +<style> + .padding_right { + padding-right: 0.3em; + } +</style> +<template> + <tr id="import_entry" class="nowrap" data-template="entry"> + <td> + <input type="checkbox" style="display: inline;" checked data-template="chbx"></input> + <span data-template="name_span"></span> + </td> + <td class="bold padding_right" data-template="warning"></td> + </tr> +</template> + <input id="import_loading_radio" type="radio" name="import_window_content" class="show_next"></input> <span> Loading... </span> + <input id="import_failed_radio" type="radio" name="import_window_content" class="show_next"></input> <div> <span id="import_errormsg"></span> @@ -15,13 +30,23 @@ <pre id="import_errordetail"></pre> <button id="import_failok_but"> OK </button> </div> + <input id="import_selection_radio" type="radio" name="import_window_content" class="show_next"></input> <div> <button id="check_all_import_but"> Check all </button> <button id="uncheck_all_import_but"> Uncheck all </button> <button id="uncheck_colliding_import_but"> Uncheck existing </button> - <ul id="import_ul"> - </ul> + <aside id="existing_settings_note"> + Settings that would owerwrite existing ones are marked "!". + </aside> + <div id="import_table_wrapper" class="table_wrapper"> + <div> + <table> + <tbody id="import_list"> + </tbody> + </table> + </div> + </div> <button id="commit_import_but"> OK </button> <button id="cancel_import_but"> Cancel </button> </div> diff --git a/html/import_frame.js b/html/import_frame.js index 4075433..c0eb2f0 100644 --- a/html/import_frame.js +++ b/html/import_frame.js @@ -9,6 +9,7 @@ * IMPORTS_START * IMPORT get_remote_storage * IMPORT by_id + * IMPORT clone_template * IMPORT nice_name * IMPORT make_once * IMPORTS_END @@ -16,48 +17,38 @@ let storage; -const import_li_template = by_id("import_li_template"); -import_li_template.removeAttribute("id"); - -function import_li_id(prefix, item) -{ - return `ili_${prefix}_${item}`; -} - -let import_ul = by_id("import_ul"); +let import_list = by_id("import_list"); let import_chbxs_colliding = undefined; +let entry_objects = undefined; let settings_import_map = undefined; -function add_import_li(prefix, name) +function add_import_entry(prefix, name) { - let li = import_li_template.cloneNode(true); - let name_span = li.firstElementChild; - let chbx = name_span.nextElementSibling; - let warning_span = chbx.nextElementSibling; + const cloned_template = clone_template("import_entry"); + Object.assign(cloned_template, {prefix, name}); - li.setAttribute("data-prefix", prefix); - li.setAttribute("data-name", name); - li.id = import_li_id(prefix, name); - name_span.textContent = nice_name(prefix, name); + cloned_template.name_span.textContent = nice_name(prefix, name); if (storage.get(prefix, name) !== undefined) { - import_chbxs_colliding.push(chbx); - warning_span.textContent = "(will overwrite existing setting!)"; + import_chbxs_colliding.push(cloned_template.chbx); + cloned_template.warning.textContent = "!"; } - import_ul.appendChild(li); + import_list.appendChild(cloned_template.entry); + + return cloned_template; } function check_all_imports() { - for (let li of import_ul.children) - li.firstElementChild.nextElementSibling.checked = true; + for (const entry_object of entry_objects) + entry_object.chbx.checked = true; } function uncheck_all_imports() { - for (let li of import_ul.children) - li.firstElementChild.nextElementSibling.checked = false; + for (const entry_object of entry_objects) + entry_object.chbx.checked = false; } function uncheck_colliding_imports() @@ -68,17 +59,13 @@ function uncheck_colliding_imports() function commit_import() { - for (let li of import_ul.children) { - let chbx = li.firstElementChild.nextElementSibling; - - if (!chbx.checked) + for (const entry_object of entry_objects) { + if (!entry_object.chbx.checked) continue; - let prefix = li.getAttribute("data-prefix"); - let name = li.getAttribute("data-name"); - let key = prefix + name; - let value = settings_import_map.get(key); - storage.set(prefix, name, value); + const key = entry_object.prefix + entry_object.name; + const value = settings_import_map.get(key); + storage.set(entry_object.prefix, entry_object.name, value); } deactivate(); @@ -105,38 +92,48 @@ function show_error(errormsg, errordetail) } const import_selection_radio = by_id("import_selection_radio"); +const existing_settings_note = by_id("existing_settings_note"); function show_selection(settings) { import_selection_radio.checked = true; - let old_children = import_ul.children; + let old_children = import_list.children; while (old_children[0] !== undefined) - import_ul.removeChild(old_children[0]); + import_list.removeChild(old_children[0]); import_chbxs_colliding = []; + entry_objects = []; settings_import_map = new Map(); for (let setting of settings) { let [key, value] = Object.entries(setting)[0]; let prefix = key[0]; let name = key.substring(1); - add_import_li(prefix, name); + entry_objects.push(add_import_entry(prefix, name)); settings_import_map.set(key, value); } + + const op = import_chbxs_colliding.length > 0 ? "remove" : "add"; + existing_settings_note.classList[op]("hide"); } function deactivate() { /* Let GC free some memory */ import_chbxs_colliding = undefined; + entry_objects = undefined; settings_import_map = undefined; if (exports.onclose) exports.onclose(); } -const exports = {show_loading, show_error, show_selection, deactivate}; +const wrapper = by_id("import_table_wrapper"); +const style_table = (...cls) => cls.forEach(c => wrapper.classList.add(c)); + +const exports = + {show_loading, show_error, show_selection, deactivate, style_table}; async function init() { diff --git a/html/options.html b/html/options.html index 2246f9a..f2a75e1 100644 --- a/html/options.html +++ b/html/options.html @@ -1,32 +1,18 @@ <!doctype html> <!-- Copyright (C) 2021 Wojtek Kosior - Copyright (C) 2021 Nicholas Johnson Redistribution terms are gathered in the `copyright' file. --> <html> <head> <meta charset="utf-8"/> <title>Hachette options</title> + <link type="text/css" rel="stylesheet" href="reset.css" /> <link type="text/css" rel="stylesheet" href="base.css" /> + <link type="text/css" rel="stylesheet" href="table.css" /> <style> - /* pages list */ - #page_components_ul { - max-height: 80vh; - overflow-y: auto; - } - #page_components_ul li.dragover_li { - border-top: 2px solid blue; - } - #page_components_ul li { - border-top: 2px solid white; - } - li[draggable=true] * { - pointer-events: none; - } - li[draggable=true] label, - li[draggable=true] button { - pointer-events: auto; + body { + width: 100%; } /* tabbed view */ @@ -37,20 +23,38 @@ display: none; } - #show_repos:checked ~ #repos_lbl, - #show_pages:checked ~ #pages_lbl, - #show_bags:checked ~ #bags_lbl, - #show_scripts:checked ~ #scripts_lbl { + #show_repos:checked ~ div #repos_lbl, + #show_pages:checked ~ div #pages_lbl, + #show_bags:checked ~ div #bags_lbl, + #show_scripts:checked ~ div #scripts_lbl { background: #4CAF50; color: white; } - body > div { - border-top: 6px solid #4CAF50; + #tab_heads>* { + font-size: 130%; + padding: 10px; + display: inline-block; + cursor: pointer; } - .tab_head { - display: inline-block; + #tab_heads { + -moz-user-select: none; + user-select: none; + } + + #import_but { + font: unset; + font-size: 130%; + float: right; + margin: 0; + border-radius: 0; + } + + div.tab { + min-width: 50vw; + width: fit-content; + padding-left: 6px; } /* popup window with list of selectable components */ @@ -60,7 +64,7 @@ height: 100vh; left: 0; top: 0; - background-color: rgba(0,0,0,0.4); + background-color: rgba(60,60,60,0.4); z-index: 1; overflow: auto; vertical-align: center; @@ -68,135 +72,264 @@ } .popup_frame { - background-color: white; - width: 50vw; + background-color: #f0f0f0; + margin: 5vh auto; + padding: 15px; + border: solid #333 4px; + border-radius: 15px; + width: -moz-fit-content; + width: fit-content; } - input[type="radio"]:not(:checked)+.import_window_content { - display: none; + .work_li .table_wrapper::before { + background: linear-gradient(#e0f0f0, #555); + } + + .work_li .table_wrapper::after { + background: linear-gradient(#555, #e0f0f0); + } + + .table_wrapper.always_scrollbar>* { + border-left: solid #454 8px; + max-height: 80vh; + overflow-y: scroll; + } + + .table_wrapper .table_wrapper.always_scrollbar>*, + .popup_frame .table_wrapper.always_scrollbar>* { + max-height: 60vh; + } + + .popup_frame .table_wrapper table { + min-width: 30vw; + } + + .popup_frame .table_wrapper { + margin: 0 auto; + } + + td:first-child { + max-width: 70vw; + overflow: hidden; + } + + tr.work_li>td:first-child { + padding-right: 0; + max-width: unset; + } + + tr.work_li>td>div { + background-color: #e0f0f0; + border-left: solid #454 5px; + border-right: solid #454 2px; + padding: 5px 10px; + } + + .form_grid { + display: grid; + grid-template-columns: auto auto; + } + + .form_grid>label { + grid-column: 1 / span 1; + margin-right: 10px; + } + + .form_grid label { + line-height: 34px; /* center text vertically */ + } + + .form_grid>input, .form_grid>span { + grid-column: 2 / span 1; + } + + .form_grid>label[for="script_contents_field"], + .form_grid>* { + grid-column: 1 / span 2; + } + + .form_grid>textarea { + min-width: 70vw; + resize: none; } </style> </head> <body> - <!-- The invisible div below is for elements that will be cloned. --> - <div class="hide"> - <li id="item_li_template"> - <span></span> - <button> Edit </button> - <button> Remove </button> - <button> Export </button> - </li> - <li id="bag_component_li_template"> - <span></span> - <button> Remove </button> - </li> - <li id="chbx_component_li_template"> - <input type="checkbox" style="display: inline;"></input> - <span></span> - </li> - <li id="radio_component_li_template"> - <input type="radio" style="display: inline;" name="page_components"></input> - <span></span> - </li> - </div> + <template> + <tr id="item_li" class="nowrap"> + <td></td> + <td><div class="button"> Edit </div></td> + <td><div class="button"> Remove </div></td> + <td><div class="button"> Export </div></td> + </tr> + <tr id="bag_component_li" class="nowrap"> + <td></td> + <td><div class="button"> Remove </div></td> + </tr> + <tr id="chbx_component_li" class="nowrap"> + <td> + <input type="checkbox" style="display: inline;"></input> + <span></span> + </td> + </tr> + <tr id="radio_component_li" class="nowrap"> + <td> + <input type="radio" style="display: inline;" name="page_components"></input> + <span></span> + </td> + </tr> + </template> <!-- Mind the show_*s ids below - their format is assumed in js code --> <input type="radio" name="tabs" id="show_repos"></input> <input type="radio" name="tabs" id="show_pages" checked></input> <input type="radio" name="tabs" id="show_bags"></input> <input type="radio" name="tabs" id="show_scripts"></input> - <label for="show_repos" id="repos_lbl" - class="tab_head"> Repos </label> - <label for="show_pages" id="pages_lbl" - class="tab_head"> Pages </label> - <label for="show_bags" id="bags_lbl" - class="tab_head"> Bags </label> - <label for="show_scripts" id="scripts_lbl" - class="tab_head"> Scripts </label> - - <div id="repos"> - <ul id="repos_ul"> - <li id="work_repo_li" class="hide"> - <label for="repo_url_field">URL: </label> - <input id="repo_url_field"></input> - <br/> - <button id="save_repo_but" type="button"> Save </button> - <button id="discard_repo_but" type="button"> Cancel </button> - </li> - </ul> + <div id="tab_heads" class="has_bottom_line"> + <label for="show_repos" id="repos_lbl"> Repos </label> + <label for="show_pages" id="pages_lbl"> Pages </label> + <label for="show_bags" id="bags_lbl"> Bags </label> + <label for="show_scripts" id="scripts_lbl"> Scripts </label> + <button id="import_but" style="margin-left: 40px;"> Import </button> + </div> + <div id="repos" class="tab"> + <div class="table_wrapper tight_table has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="repos_ul"> + <tr id="work_repo_li" class="hide work_li"> + <td colspan="4"> + <div class="form_grid"> + <label for="repo_url_field">URL: </label> + <input id="repo_url_field"></input> + <div> + <button id="save_repo_but" type="button"> Save </button> + <button id="discard_repo_but" type="button"> Cancel </button> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + </div> <button id="add_repo_but" type="button"> Add repository </button> </div> - - <div id="pages"> - <ul id="pages_ul"> - <li id="work_page_li" class="hide"> - <label for="page_url_field">URL: </label> - <input id="page_url_field"></input> - <br/> - <label>Payload: </label> - <span id="page_payload"></span> - <button id="select_page_components_but"> - Choose payload - </button> - <br/> - <input id="page_allow_chbx" type="checkbox" style="display: inline;"></input> - <label for="page_allow_chbx">Allow native scripts</label> - <br/> - <button id="save_page_but" type="button"> Save </button> - <button id="discard_page_but" type="button"> Cancel </button> - </li> - </ul> + <div id="pages" class="tab"> + <div class="table_wrapper tight_table has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="pages_ul"> + <tr id="work_page_li" class="hide work_li"> + <td colspan="4"> + <div class="form_grid"> + <label for="page_url_field">URL: </label> + <input id="page_url_field"></input> + <label>Payload: </label> + <span> + <span id="page_payload"></span> + <button id="select_page_components_but"> + Choose payload + </button> + </span> + <div> + <input id="page_allow_chbx" type="checkbox" style="display: inline;"></input> + <label for="page_allow_chbx">Allow native scripts</label> + </div> + <div> + <button id="save_page_but" type="button"> Save </button> + <button id="discard_page_but" type="button"> Cancel </button> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + </div> <button id="add_page_but" type="button"> Add page </button> + <IMPORT html/default_blocking_policy.html /> </div> - - <div id="bags"> - <ul id="bags_ul"> - <li id="work_bag_li" class="hide"> - <label for="bag_name_field"> Name: </label> - <input id="bag_name_field"></input> - <ul id="bag_components_ul"> - <li id="empty_bag_component_li" class="hide"></li> - </ul> - <button id="select_bag_components_but"> - Add scripts - </button> - <br/> - <button id="save_bag_but"> Save </button> - <button id="discard_bag_but"> Cancel </button> - </li> - </ul> + <div id="bags" class="tab"> + <div class="table_wrapper tight_table has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="bags_ul"> + <tr id="work_bag_li" class="hide work_li"> + <td colspan="4"> + <div class="form_grid"> + <label for="bag_name_field"> Name: </label> + <input id="bag_name_field"></input> + <div class="table_wrapper tight_table has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="bag_components_ul"> + <tr id="empty_bag_component_li" class="hide"></tr> + </tbody> + </table> + </div> + </div> + <div> + <button id="select_bag_components_but"> + Add scripts + </button> + </div> + <div> + <button id="save_bag_but"> Save </button> + <button id="discard_bag_but"> Cancel </button> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + </div> <button id="add_bag_but" type="button"> Add bag </button> </div> - - <div id="scripts"> - <ul id="scripts_ul"> - <li id="work_script_li" class="hide"> - <label for="script_name_field"> Name: </label> - <input id="script_name_field"></input> - <br/> - <label for="script_url_field"> URL: </label> - <input id="script_url_field"></input> - <br/> - <label for="script_sha256_field"> sha256: </label> - <input id="script_sha256_field"></input> - <br/> - <label for="script_contents_field"> contents: </label> - <textarea id="script_contents_field" rows="20" cols="80"></textarea> - <br/> - <button id="save_script_but"> Save </button> - <button id="discard_script_but"> Cancel </button> - </li> - </ul> + <div id="scripts" class="tab"> + <div class="table_wrapper tight_table has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="scripts_ul"> + <tr id="work_script_li" class="hide work_li"> + <td colspan="4"> + <div class="form_grid"> + <label for="script_name_field"> Name: </label> + <input id="script_name_field"></input> + <label for="script_url_field"> URL: </label> + <input id="script_url_field"></input> + <label for="script_sha256_field"> SHA256: </label> + <input id="script_sha256_field"></input> + <aside> + Note: URL and SHA256 are ignored if script text is provided. + </aside> + <label for="script_contents_field"> contents: </label> + <textarea id="script_contents_field" rows="20" cols="80"></textarea> + <div> + <button id="save_script_but"> Save </button> + <button id="discard_script_but"> Cancel </button> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + </div> <button id="add_script_but" type="button"> Add script </button> </div> - <button id="import_but" style="margin-top: 40px;"> Import </button> - <div id="chbx_components_window" class="hide popup" position="absolute"> <div class="popup_frame"> - <ul id="chbx_components_ul"> - - </ul> + <div class="table_wrapper tight_table has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="chbx_components_ul"> + </tbody> + </table> + </div> + </div> <button id="commit_bag_components_but"> Add </button> <button id="cancel_bag_components_but"> Cancel </button> </div> @@ -204,13 +337,20 @@ <div id="radio_components_window" class="hide popup" position="absolute"> <div class="popup_frame"> - <ul id="radio_components_ul"> - - <li id="radio_component_none_li"> - <input id="radio_component_none_input" type="radio" style="display: inline;" name="page_components"></input> - <span>(None)</span> - </li> - </ul> + <div class="table_wrapper tight_table always_scrollbar has_bottom_line has_upper_line"> + <div> + <table> + <tbody id="radio_components_ul"> + <tr id="radio_component_none_li"> + <td> + <input id="radio_component_none_input" type="radio" style="display: inline;" name="page_components"></input> + <span>(None)</span> + </td> + </tr> + </tbody> + </table> + </div> + </div> <button id="commit_page_components_but"> Choose </button> <button id="cancel_page_components_but"> Cancel </button> </div> @@ -218,6 +358,7 @@ <div id="import_window" class="hide popup" position="absolute"> <div class="popup_frame"> + <h2> Settings import </h2> <IMPORT html/import_frame.html /> </div> </div> diff --git a/html/options_main.js b/html/options_main.js index 830c860..2f4f154 100644 --- a/html/options_main.js +++ b/html/options_main.js @@ -13,24 +13,36 @@ * IMPORT list_prefixes * IMPORT nice_name * IMPORT parse_json_with_schema + * IMPORT get_template * IMPORT by_id * IMPORT matchers * IMPORT get_import_frame + * IMPORT init_default_policy_dialog * IMPORTS_END */ var storage; -const item_li_template = by_id("item_li_template"); -const bag_component_li_template = by_id("bag_component_li_template"); -const chbx_component_li_template = by_id("chbx_component_li_template"); -const radio_component_li_template = by_id("radio_component_li_template"); +const item_li_template = get_template("item_li"); +const bag_component_li_template = get_template("bag_component_li"); +const chbx_component_li_template = get_template("chbx_component_li"); +const radio_component_li_template = get_template("radio_component_li"); /* Make sure they are later cloned without id. */ item_li_template.removeAttribute("id"); bag_component_li_template.removeAttribute("id"); chbx_component_li_template.removeAttribute("id"); radio_component_li_template.removeAttribute("id"); +function list_set_scrollbar(list_elem) +{ + const op = ((list_elem.children.length === 1 && + list_elem.children[0].classList.contains("hide")) || + list_elem.children.length < 1) ? "remove" : "add"; + while (!list_elem.classList.contains("table_wrapper")) + list_elem = list_elem.parentElement; + list_elem.classList[op]("always_scrollbar"); +} + function item_li_id(prefix, item) { return `li_${prefix}_${item}`; @@ -65,11 +77,17 @@ function add_li(prefix, item, at_the_end=false) continue; ul.ul.insertBefore(li, element); - return; + break; } } + if (!li.parentElement) { + if (ul.work_li !== ul.ul.lastElementChild) + ul.ul.appendChild(li); + else + ul.work_li.before(li); + } - ul.ul.appendChild(li); + list_set_scrollbar(ul.ul); } const chbx_components_ul = by_id("chbx_components_ul"); @@ -97,12 +115,13 @@ function add_chbx_li(prefix, name) li.setAttribute("data-prefix", prefix); li.setAttribute("data-name", name); - let chbx = li.firstElementChild; + let chbx = li.firstElementChild.firstElementChild; let span = chbx.nextElementSibling; span.textContent = nice_name(prefix, name); chbx_components_ul.appendChild(li); + list_set_scrollbar(chbx_components_ul); } var radio_component_none_li = by_id("radio_component_none_li"); @@ -117,12 +136,13 @@ function add_radio_li(prefix, name) li.setAttribute("data-prefix", prefix); li.setAttribute("data-name", name); - let radio = li.firstElementChild; + let radio = li.firstElementChild.firstElementChild; let span = radio.nextElementSibling; span.textContent = nice_name(prefix, name); - radio_components_ul.insertBefore(li, radio_component_none_li); + radio_component_none_li.before(li); + list_set_scrollbar(radio_components_ul); } /* Used to reset edited repo. */ @@ -190,6 +210,13 @@ function work_page_li_data(ul) const empty_bag_component_li = by_id("empty_bag_component_li"); var bag_components_ul = by_id("bag_components_ul"); +function remove_bag_component_entry(entry) +{ + const list = entry.parentElement; + entry.remove(); + list_set_scrollbar(list); +} + /* Used to construct and update components list of edited bag. */ function add_bag_components(components) { @@ -198,15 +225,17 @@ function add_bag_components(components) let li = bag_component_li_template.cloneNode(true); li.setAttribute("data-prefix", prefix); li.setAttribute("data-name", name); + let span = li.firstElementChild; span.textContent = nice_name(prefix, name); let remove_but = span.nextElementSibling; - remove_but.addEventListener("click", () => - bag_components_ul.removeChild(li)); + remove_but.addEventListener("click", + () => remove_bag_component_entry(li)); bag_components_ul.appendChild(li); } bag_components_ul.appendChild(empty_bag_component_li); + list_set_scrollbar(bag_components_ul); } /* Used to reset edited bag. */ @@ -218,8 +247,7 @@ function reset_work_bag_li(ul, item, components) let old_components_ul = bag_components_ul; bag_components_ul = old_components_ul.cloneNode(false); - ul.work_li.insertBefore(bag_components_ul, old_components_ul); - ul.work_li.removeChild(old_components_ul); + old_components_ul.replaceWith(bag_components_ul); add_bag_components(components); } @@ -227,8 +255,7 @@ function reset_work_bag_li(ul, item, components) /* Used to get edited bag data for saving. */ function work_bag_li_data(ul) { - let components_ul = ul.work_name_input.nextElementSibling; - let component_li = components_ul.firstElementChild; + let component_li = bag_components_ul.firstElementChild; let components = []; @@ -284,6 +311,8 @@ function cancel_work(prefix) } ul.work_li.classList.add("hide"); + ul.ul.append(ul.work_li); + list_set_scrollbar(ul.ul); ul.state = UL_STATE.IDLE; } @@ -322,6 +351,7 @@ function edit_item(prefix, item) ul.ul.insertBefore(ul.work_li, li); ul.ul.removeChild(li); ul.work_li.classList.remove("hide"); + list_set_scrollbar(ul.ul); ul.state = UL_STATE.EDITING_ENTRY; ul.edited_item = item; @@ -379,6 +409,7 @@ function add_new_item(prefix, name) ul.reset_work_li(ul); ul.work_li.classList.remove("hide"); ul.ul.appendChild(ul.work_li); + list_set_scrollbar(ul.ul); if (name !== undefined) ul.work_name_input.value = name; @@ -393,7 +424,7 @@ function bag_components() radio_components_window.classList.add("hide"); for (let li of chbx_components_ul.children) { - let chbx = li.firstElementChild; + let chbx = li.firstElementChild.firstElementChild; chbx.checked = false; } } @@ -403,7 +434,7 @@ function commit_bag_components() let selected = []; for (let li of chbx_components_ul.children) { - let chbx = li.firstElementChild; + let chbx = li.firstElementChild.firstElementChild; if (!chbx.checked) continue; @@ -431,10 +462,11 @@ function page_components() let [prefix, item] = components; let li = by_id(radio_li_id(prefix, item)); + if (li === null) radio_component_none_input.checked = false; else - li.firstElementChild.checked = true; + li.firstElementChild.firstElementChild.checked = true; } function commit_page_components() @@ -442,7 +474,7 @@ function commit_page_components() let components = null; for (let li of radio_components_ul.children) { - let radio = li.firstElementChild; + let radio = li.firstElementChild.firstElementChild; if (!radio.checked) continue; @@ -603,6 +635,8 @@ async function initialize_import_facility() import_frame = await get_import_frame(); import_frame.onclose = hide_import_window; + import_frame.style_table("has_bottom_line", "always_scrollbar", + "has_upper_line", "tight_table"); } /* @@ -637,6 +671,8 @@ function jump_to_item(url_with_item) async function main() { + init_default_policy_dialog(); + storage = await get_remote_storage(); for (let prefix of list_prefixes) { @@ -709,6 +745,7 @@ function handle_change(change) for (let [components_ul, id_creator] of uls_creators) { let li = by_id(id_creator(change.prefix, change.item)); components_ul.removeChild(li); + list_set_scrollbar(components_ul); } } diff --git a/html/reset.css b/html/reset.css new file mode 100644 index 0000000..dab51cd --- /dev/null +++ b/html/reset.css @@ -0,0 +1,49 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + Copyright 2008,2011 Eric A. Meyer + Redistribution terms are gathered in the `copyright' file. +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/html/table.css b/html/table.css new file mode 100644 index 0000000..36f88bb --- /dev/null +++ b/html/table.css @@ -0,0 +1,45 @@ +.table_wrapper { + display: block; + background-color: #f0f0f0; + margin: 6px 0; +} + +.table_wrapper table { + border-collapse: unset; + width: 100%; +} + +.table_wrapper.tight_table, +.table_wrapper.tight_table>*, +.table_wrapper.tight_table>*>table { + width: -moz-min-content; + width: min-content; +} + +tr:nth-child(odd) { + background-color: #e5e5e5; +} + +td { + vertical-align: middle; + min-width: fit-content; + min-width: -moz-fit-content; +} + +.tight_table td { + width: 1%; +} + +td:first-child { + padding: 3px 10px 6px; +} + +.tight_table td:first-child { + width: 100%; +} + +td>div.button { + margin-right: 4px; + white-space: nowrap; + float: right; +} diff --git a/manifest.json b/manifest.json index 328b27f..bd963fe 100644 --- a/manifest.json +++ b/manifest.json @@ -6,7 +6,7 @@ "short_name": "Hachette", "version": "0.0.1",_CHROMIUM_KEY_ "author": "various", - "description": "Kill the web&js",_GECKO_APPLICATIONS_ + "description": "Control your \"Web\" browsing.",_GECKO_APPLICATIONS_ "icons":{ "128": "icons/hachette128.png", "64": "icons/hachette64.png", @@ -23,7 +23,8 @@ "sessions", "storage", "tabs", - "<all_urls>" + "<all_urls>", + "unlimitedStorage" ], "browser_action": { "browser_style": true, |