aboutsummaryrefslogtreecommitdiff
/**
 * This file is part of Haketilo.
 *
 * Function: Popup logic.
 *
 * Copyright (C) 2021 Wojtek Kosior
 * Redistribution terms are gathered in the `copyright' file.
 */

/*
 * IMPORTS_START
 * IMPORT browser
 * IMPORT is_chrome
 * IMPORT is_mozilla
 *** Using remote storage here seems inefficient, we only resort to that
 *** 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 each_url_pattern
 * IMPORT by_id
 * IMPORT clone_template
 * IMPORTS_END
 */

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()
{
    /* Fix for fact that Chrome does not use promises here */
    const promise = is_chrome ?
	  new Promise((resolve, reject) =>
		      browser.tabs.query(tab_query, tab => resolve(tab))) :
	  browser.tabs.query(tab_query);

    try {
	return (await promise)[0];
    } catch(e) {
	console.log(e);
    }
}

const page_url_heading = by_id("page_url_heading");
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()
{
    const tab = await get_current_tab();

    if (tab === undefined) {
	page_url_heading.textContent = "unknown page";
	return;
    }

    tab_url = /^([^?#]*)/.exec(tab.url)[1];
    to_spans(tab_url).forEach(s => page_url_heading.append(s));
    if (is_privileged_url(tab_url)) {
	privileged_notice.classList.remove("hide");
	return;
    }

    populate_possible_patterns_list(tab_url);
    page_state.classList.remove("hide");

    try_to_connect(tab.id);
}

const possible_patterns_list = by_id("possible_patterns");
const known_patterns = new Map();

function add_pattern_to_list(pattern)
{
    const template = clone_template("pattern_entry");
    template.name.textContent = pattern;

    const settings_opener = () => open_in_settings(TYPE_PREFIX.PAGE, pattern);
    template.button.addEventListener("click", settings_opener);

    known_patterns.set(pattern, template);
    possible_patterns_list.append(template.entry);

    return template;
}

function style_possible_pattern_entry(pattern, exists_in_settings)
{
    const [text, class_action] = exists_in_settings ?
	  ["Edit", "add"] : ["Add", "remove"];
    const entry_object = known_patterns.get(pattern);

    if (entry_object) {
	entry_object.button.textContent = `${text} setting`;
	entry_object.entry.classList[class_action]("matched_pattern");
    }
}

function handle_page_change(change)
{
    style_possible_pattern_entry(change.item, change.new_val !== undefined);
}

function populate_possible_patterns_list(url)
{
    for (const pattern of each_url_pattern(url))
	add_pattern_to_list(pattern);

    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]);
}

const connected_chbx = by_id("connected_chbx");
const query_pattern_but = by_id("query_pattern");

var content_script_port;

function try_to_connect(tab_id)
{
    /* This won't connect to iframes. We'll add support for them later */
    const connect_info = {name: CONNECTION_TYPE.ACTIVITY_INFO, frameId: 0};
    content_script_port = browser.tabs.connect(tab_id, connect_info);

    const disconnect_cb = () => handle_disconnect(tab_id, start_querying_repos);
    content_script_port.onDisconnect.addListener(disconnect_cb);
    content_script_port.onMessage.addListener(handle_activity_report);

    query_pattern_but.addEventListener("click", start_querying_repos);

    if (is_mozilla)
	setTimeout(() => monitor_connecting(tab_id), 1000);
}

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]);
}

const loading_point = by_id("loading_point");
const reload_notice = by_id("reload_notice");

function handle_disconnect(tab_id, button_cb)
{
    query_pattern_but.removeEventListener("click", button_cb);
    content_script_port = null;

    if (is_chrome && !browser.runtime.lastError)
	return;

    /* return if error was not during connection initialization */
    if (connected_chbx.checked)
	return;

    loading_point.classList.toggle("camouflage");
    reload_notice.classList.remove("hide");

    setTimeout(() => try_to_connect(tab_id), 1000);
}

function monitor_connecting(tab_id)
{
    if (connected_chbx.checked)
	return;

    if (content_script_port)
	content_script_port.disconnect();
    else
	return;

    loading_point.classList.toggle("camouflage");
    reload_notice.classList.remove("hide");
    try_to_connect(tab_id);
}

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;

    const [type, data] = message;

    if (type === "settings") {
	const settings = data;

	blocked_span.textContent = settings.allow ? "no" : "yes";

	if (settings.pattern) {
	    pattern_span.textContent = pattern;
	    const settings_opener =
		  () => open_in_settings(TYPE_PREFIX.PAGE, settings.pattern);
	    view_pattern_but.classList.remove("hide");
	    view_pattern_but.addEventListener("click", settings_opener);
	} else {
	    pattern_span.textContent = "none";
	    blocked_span.textContent = blocked_span.textContent + " (default)";
	}

	if (settings.payload) {
	    payload_span.textContent = nice_name(...settings.payload);
	    payload_buttons_div.classList.remove("hide");
	    const settings_opener = () => open_in_settings(...settings.payload);
	    view_payload_but.addEventListener("click", settings_opener);
	} else {
	    payload_span.textContent = "none";
	}
    }
    if (type === "script") {
	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 === "is_html") {
	if (!data)
	    content_type_cell.classList.remove("hide");
    }
    if (type === "repo_query_action") {
	const key = data.prefix + data.item;
	const results = queried_items.get(key) || {};
	Object.assign(results, data.results);
	queried_items.set(key, results);

	const action = data.prefix === TYPE_PREFIX.URL ?
	      show_query_result : record_fetched_install_dep;

	for (const [repo_url, result] of Object.entries(data.results))
	    action(data.prefix, data.item, repo_url, result);
    }
}

const container_for_repo_responses = by_id("container_for_repo_responses");

const results_lists = new Map();

function create_results_list(url)
{
    const cloned_template = clone_template("multi_repos_query_result");
    cloned_template.url_span.textContent = url;
    container_for_repo_responses.appendChild(cloned_template.div);

    cloned_template.by_repo = new Map();
    results_lists.set(url, cloned_template);

    return cloned_template;
}

function create_result_item(list_object, repo_url, result)
{
    const cloned_template = clone_template("single_repo_query_result");
    cloned_template.repo_url.textContent = repo_url;
    cloned_template.appended = null;

    list_object.ul.appendChild(cloned_template.li);
    list_object.by_repo.set(repo_url, cloned_template);

    return cloned_template;
}

function set_appended(result_item, element)
{
    if (result_item.appended)
	result_item.appended.remove();
    result_item.appended = element;
    result_item.li.appendChild(element);
}

function show_message(result_item, text)
{
    const div = document.createElement("div");
    div.textContent = text;
    set_appended(result_item, div);
}

function showcb(text)
{
    return item => show_message(item, text);
}

function unroll_chbx_first_checked(entry_object)
{
    if (!entry_object.chbx.checked)
	return;

    entry_object.chbx.removeEventListener("change", entry_object.unroll_cb);
    delete entry_object.unroll_cb;

    entry_object.unroll.innerHTML = "preview not implemented...<br />(consider contributing)";
}

let import_frame;
let install_target = null;

function install_abort(error_state)
{
    import_frame.show_error(`Error: ${error_state}`);
    install_target = null;
}

/*
 * Translate objects from the format in which they are sent by Hydrilla to the
 * format in which they are stored in settings.
 */

function translate_script(script_object, repo_url)
{
    return {
	[TYPE_PREFIX.SCRIPT + script_object.name]: {
	    hash: script_object.sha256,
	    url: `${repo_url}/content/${script_object.location}`
	}
    };
}

function translate_bag(bag_object)
{
    return {
	[TYPE_PREFIX.BAG + bag_object.name]: bag_object.components
    };
}

const format_translators = {
    [TYPE_PREFIX.BAG]: translate_bag,
    [TYPE_PREFIX.SCRIPT]: translate_script
};

function install_check_ready()
{
    if (install_target.to_fetch.size > 0)
	return;

    const page_key = [TYPE_PREFIX.PAGE + install_target.pattern];
    const to_install = [{[page_key]: {components: install_target.payload}}];

    for (const key of install_target.fetched) {
	const old_object =
	      queried_items.get(key)[install_target.repo_url].response;
	const new_object =
	      format_translators[key[0]](old_object, install_target.repo_url);
	to_install.push(new_object);
    }

    import_frame.show_selection(to_install);
}

const possible_errors = ["connection_error", "parse_error"];

function fetch_install_deps(components)
{
    const needed = [...components];
    const processed = new Set();

    while (needed.length > 0) {
	const [prefix, item] = needed.pop();
	const key = prefix + item;
	processed.add(key);
	const results = queried_items.get(key);
	let relevant_result = null;

	if (results)
	    relevant_result = results[install_target.repo_url];

	if (!relevant_result) {
	    content_script_port.postMessage([prefix, item,
					     [install_target.repo_url]]);
	    install_target.to_fetch.add(key);
	    continue;
	}

	if (possible_errors.includes(relevant_result.state)) {
	    install_abort(relevant_result.state);
	    return false;
	}

	install_target.fetched.add(key);

	if (prefix !== TYPE_PREFIX.BAG)
	    continue;

	for (const dependency of relevant_result.response.components) {
	    if (processed.has(dependency.join('')))
		continue;
	    needed.push(dependency);
	}
    }
}

function record_fetched_install_dep(prefix, item, repo_url, result)
{
    const key = prefix + item;

    if (!install_target || repo_url !== install_target.repo_url ||
	!install_target.to_fetch.has(key))
	return;

    if (possible_errors.includes(result.state)) {
	install_abort(result.state);
	return;
    }

    if (result.state !== "completed")
	return;

    install_target.to_fetch.delete(key);
    install_target.fetched.add(key);

    if (prefix === TYPE_PREFIX.BAG &&
	fetch_install_deps(result.response.components) === false)
	return;

    install_check_ready();
}

function install_clicked(entry_object)
{
    import_frame.show_loading();

    install_target = {
	repo_url: entry_object.repo_url,
	pattern: entry_object.match_object.pattern,
	payload: entry_object.match_object.payload,
	fetched: new Set(),
	to_fetch: new Set()
    };

    fetch_install_deps([install_target.payload]);

    install_check_ready();
}

var max_query_result_id = 0;

function show_query_successful_result(result_item, repo_url, result)
{
    if (result.length === 0) {
	show_message(result_item, "No results :(");
	return;
    }

    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");

	entry_object.pattern.textContent = match.pattern;

	cloned_ul_template.ul.appendChild(entry_object.li);

	if (!match.payload) {
	    entry_object.payload.textContent = "(none)";
	    for (const key of ["chbx", "triangle", "unroll"])
		entry_object[key].remove();
	    continue;
	}

	entry_object.payload.textContent = nice_name(...match.payload);

	const install_cb = () => install_clicked(entry_object);
	entry_object.btn.addEventListener("click", install_cb);

	const chbx_id = `query_result_${max_query_result_id++}`;
	entry_object.chbx.id = chbx_id;
	entry_object.lbl.setAttribute("for", chbx_id);

	entry_object.unroll_cb = () => unroll_chbx_first_checked(entry_object);
	entry_object.chbx.addEventListener("change", entry_object.unroll_cb);

	entry_object.component_object = match.payload;
	entry_object.match_object = match;
	entry_object.repo_url = repo_url;
    }
}

function show_query_result(url_prefix, url, repo_url, result)
{
    const results_list_object = results_lists.get(url) ||
	  create_results_list(url);
    const result_item = results_list_object.by_repo.get(repo_url) ||
	  create_result_item(results_list_object, repo_url, result);

    const completed_cb =
	  item => show_query_successful_result(item, repo_url, result.response);
    const possible_actions = {
	completed: completed_cb,
	started: showcb("loading..."),
	connection_error: showcb("Error when querying repository."),
	parse_error: showcb("Bad data format received.")
    };
    possible_actions[result.state](result_item, repo_url);
}

by_id("settings_but")
    .addEventListener("click", (e) => browser.runtime.openOptionsPage());

async function main()
{
    init_default_policy_dialog();

    storage = await get_remote_storage();
    import_frame = await get_import_frame();
    import_frame.onclose = () => show_queried_view_radio.checked = true;
    show_page_activity_info();
}

main();