diff --git a/@types/gecko.d.mts b/@types/gecko.d.mts index 0024117f..4326fbfd 100644 --- a/@types/gecko.d.mts +++ b/@types/gecko.d.mts @@ -203,6 +203,8 @@ declare type _ExtensionCommon = declare type UserSearchEngine = import("../engine/toolkit/components/search/UserSearchEngine.sys.mjs").UserSearchEngine; declare type GlideHintIPC = import("../src/glide/browser/base/content/hinting.mts").GlideHintIPC; +declare type AppUpdater = import("../engine/toolkit/mozapps/update/AppUpdater.sys.mjs").AppUpdater; +declare type DownloadUtils = typeof import("../engine/toolkit/mozapps/downloads/DownloadUtils.sys.mjs").DownloadUtils; declare type GlideResolvedHint = GlideHintIPC & { label: string; @@ -233,6 +235,8 @@ declare namespace MockedExports { "chrome://glide/content/utils/objects.mjs": typeof import("../src/glide/browser/base/content/utils/objects.mts"); "chrome://glide/content/utils/strings.mjs": typeof import("../src/glide/browser/base/content/utils/strings.mts"); "chrome://glide/content/utils/promises.mjs": typeof import("../src/glide/browser/base/content/utils/promises.mts"); + "chrome://glide/content/utils/browser-update.mjs": + typeof import("../src/glide/browser/base/content/utils/browser-update.mts"); "chrome://glide/content/utils/browser-ui.mjs": typeof import("../src/glide/browser/base/content/utils/browser-ui.mts"); "chrome://glide/content/utils/resources.mjs": @@ -321,6 +325,9 @@ declare namespace MockedExports { typeof import("../src/glide/generated/@types/subs/AppConstants.sys.d.ts"); "resource://gre/modules/AppMenuNotifications.sys.mjs": typeof import("../engine/toolkit/modules/AppMenuNotifications.sys.mjs"); + "resource://gre/modules/AppUpdater.sys.mjs": typeof import("../engine/toolkit/mozapps/update/AppUpdater.sys.mjs"); + "resource://gre/modules/DownloadUtils.sys.mjs": + typeof import("../engine/toolkit/mozapps/downloads/DownloadUtils.sys.mjs"); "resource://devtools/shared/loader/Loader.sys.mjs": typeof import("../engine/devtools/shared/loader/Loader.sys.mjs"); diff --git a/scripts/polyfill-chromeutils.cjs b/scripts/polyfill-chromeutils.cjs index 18cfbfa3..23be261d 100644 --- a/scripts/polyfill-chromeutils.cjs +++ b/scripts/polyfill-chromeutils.cjs @@ -62,6 +62,8 @@ globalThis.ChromeUtils = { return a_require(`${SRC_DIR}/glide/browser/base/content/please.mts`); case "chrome://glide/content/event-utils.mjs": return a_require(`${SRC_DIR}/glide/browser/base/content/event-utils.mts`); + case "chrome://glide/content/utils/browser-update.mjs": + return a_require(`${SRC_DIR}/glide/browser/base/content/utils/browser-update.mts`); case "chrome://glide/content/browser.mjs": return a_require(`${SRC_DIR}/glide/browser/base/content/browser.mts`); diff --git a/src/glide/browser/base/content/browser-commandline.mts b/src/glide/browser/base/content/browser-commandline.mts index f58f33ad..8f276c69 100644 --- a/src/glide/browser/base/content/browser-commandline.mts +++ b/src/glide/browser/base/content/browser-commandline.mts @@ -3,9 +3,15 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import type { UpdateOption } from "./utils/browser-update.mts"; + const DOM = ChromeUtils.importESModule("chrome://glide/content/utils/dom.mjs", { global: "current" }); const DocumentMirror = ChromeUtils.importESModule("chrome://glide/content/document-mirror.mjs", { global: "current" }); const { is_present } = ChromeUtils.importESModule("chrome://glide/content/utils/guards.mjs"); +const { AppUpdater } = ChromeUtils.importESModule("resource://gre/modules/AppUpdater.sys.mjs", {}); +const { format_download_progress, get_status_text, get_action_label, is_actionable } = ChromeUtils.importESModule( + "chrome://glide/content/utils/browser-update.mjs", +); export class ExcmdsCompletionSource implements GlideCompletionSource { id = "excmds"; @@ -364,3 +370,191 @@ export class CustomCompletionSource implements GlideCompletionSource { + id = "update"; + readonly container: HTMLElement; + + #appUpdater: AppUpdater; + #listener: (status: number, ...args: any[]) => void; + #status_label: HTMLElement; + #action_row: HTMLElement; + #action_label: HTMLElement; + #current_status: number; + #resolved_options: UpdateOption[] | null = null; + + constructor() { + this.container = DOM.create_element("div", { + attributes: { anonid: "glide-commandline-completions-update" }, + children: [ + DOM.create_element("div", { className: "section-header", children: ["update"] }), + DOM.create_element("table", { className: "gcl-table" }), + ], + }); + + this.#status_label = DOM.create_element("span", { + children: ["Initializing…"], + }); + + this.#action_row = DOM.create_element("tr", { + className: "gcl-option", + }); + this.#action_label = DOM.create_element("td", { + colSpan: 3, + children: [""], + }); + this.#action_row.appendChild(this.#action_label); + this.#action_row.hidden = true; + + this.#appUpdater = new AppUpdater(); + this.#current_status = AppUpdater.STATUS.NEVER_CHECKED; + + this.#listener = (status: number, ...args: any[]) => { + this.#on_status(status, ...args); + }; + this.#appUpdater.addListener(this.#listener); + } + + #on_status(status: number, ...args: any[]) { + const STATUS = AppUpdater.STATUS; + this.#current_status = status; + + if (status === STATUS.DOWNLOADING && args.length >= 2) { + const [progress, progressMax] = args as [number, number]; + this.#status_label.textContent = format_download_progress(progress, progressMax); + } else { + this.#status_label.textContent = get_status_text(STATUS, status, this.#appUpdater.update); + } + + if (is_actionable(STATUS, status)) { + this.#action_label.textContent = get_action_label(STATUS, status, this.#appUpdater.update); + this.#action_row.hidden = false; + } else { + this.#action_row.hidden = true; + } + } + + check() { + if (this.#current_status === AppUpdater.STATUS.NEVER_CHECKED) { + this.#appUpdater.check(); + } + } + + destroy() { + this.#appUpdater.removeListener(this.#listener); + this.#appUpdater.stop(); + } + + get appUpdater(): AppUpdater { + return this.#appUpdater; + } + + get currentStatus(): number { + return this.#current_status; + } + + is_enabled({ input }: GlideCompletionContext) { + return input.toLowerCase().startsWith("update"); + } + + search(_ctx: GlideCompletionContext, options: UpdateOption[]) { + for (const option of options) { + option.set_hidden(option.kind === "action" && !!this.#action_row.hidden); + } + } + + resolve_options(): UpdateOption[] { + const source = this; + const STATUS = AppUpdater.STATUS; + + this.#on_status(this.#current_status); + + const status_option: UpdateOption = { + kind: "status", + element: DOM.create_element("tr", { + className: "gcl-option", + children: [ + DOM.create_element("td", { + colSpan: 3, + children: [this.#status_label], + }), + ], + }), + async accept() {}, + async delete() {}, + matches() { + return true; + }, + is_focused() { + return this.element.classList.contains("focused"); + }, + set_focused(focused) { + if (focused === this.is_focused()) return; + if (focused) { + this.element.classList.add("focused"); + } else { + this.element.classList.remove("focused"); + } + }, + is_hidden() { + return !!source.container.hidden; + }, + set_hidden() {}, + }; + + const action_option: UpdateOption = { + kind: "action", + element: this.#action_row, + async accept() { + const current = source.#current_status; + + if (current === STATUS.DOWNLOAD_AND_INSTALL) { + source.#appUpdater.allowUpdateDownload(); + await GlideBrowser.upsert_commandline({ prefill: "update" }); + return; + } + + if (current === STATUS.READY_FOR_RESTART) { + const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"]!.createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", "restart"); + if (cancelQuit.data) { + return; + } + + if (Services.appinfo.inSafeMode) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + return; + } + + Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart); + } + }, + async delete() {}, + matches() { + return true; + }, + is_focused() { + return this.element.classList.contains("focused"); + }, + set_focused(focused) { + if (focused === this.is_focused()) return; + if (focused) { + this.element.classList.add("focused"); + } else { + this.element.classList.remove("focused"); + } + }, + is_hidden() { + return !!source.container.hidden || !!source.#action_row.hidden; + }, + set_hidden(hidden) { + if (hidden === this.is_hidden()) return; + source.#action_row.hidden = hidden; + }, + }; + + this.#resolved_options = [status_option, action_option]; + this.check(); + return this.#resolved_options; + } +} diff --git a/src/glide/browser/base/content/browser-excmds-registry.mts b/src/glide/browser/base/content/browser-excmds-registry.mts index 88089170..3fa63ac5 100644 --- a/src/glide/browser/base/content/browser-excmds-registry.mts +++ b/src/glide/browser/base/content/browser-excmds-registry.mts @@ -76,6 +76,7 @@ export const GLIDE_EXCOMMANDS = [ { name: "quit", description: "Close all windows", content: false, repeatable: false }, { name: "clear", description: "Clear all notifications", content: false, repeatable: false }, + { name: "update", description: "Check for Glide updates", content: false, repeatable: false }, { name: "set", diff --git a/src/glide/browser/base/content/browser-excmds.mts b/src/glide/browser/base/content/browser-excmds.mts index 1a4166f3..d916673e 100644 --- a/src/glide/browser/base/content/browser-excmds.mts +++ b/src/glide/browser/base/content/browser-excmds.mts @@ -241,6 +241,11 @@ class GlideExcmdsClass { break; } + case "update": { + await GlideBrowser.upsert_commandline({ prefill: "update" }); + break; + } + case "repl": { const { require } = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs"); const { BrowserConsoleManager } = require("devtools/client/webconsole/browser-console-manager"); diff --git a/src/glide/browser/base/content/browser.mts b/src/glide/browser/base/content/browser.mts index 5ceadfb2..2513a843 100644 --- a/src/glide/browser/base/content/browser.mts +++ b/src/glide/browser/base/content/browser.mts @@ -1354,6 +1354,7 @@ class GlideBrowserClass { get commandline_sources(): GlideCompletionSource[] { return redefine_getter(this, "commandline_sources", [ + new CommandLine.UpdateCompletionSource(), new CommandLine.TabsCompletionSource(), new CommandLine.ExcmdsCompletionSource(), ]); diff --git a/src/glide/browser/base/content/test/commandline/browser_commandline.ts b/src/glide/browser/base/content/test/commandline/browser_commandline.ts index 9d210f22..0ff7fff9 100644 --- a/src/glide/browser/base/content/test/commandline/browser_commandline.ts +++ b/src/glide/browser/base/content/test/commandline/browser_commandline.ts @@ -725,3 +725,25 @@ add_task(async function test_commandline_close() { is(result, true, "close() should return true when the commandline was open"); await wait_for_mode("normal"); }); + +add_task(async function test_update_commandline() { + await reload_config(function _() {}); + + await BrowserTestUtils.withNewTab(FILE, async () => { + await GlideTestUtils.commandline.open(); + await new Promise(r => requestAnimationFrame(r)); + + await keys("update"); + + is( + GlideTestUtils.commandline.current_source_header(), + "update", + "entering `update` should result in update completions", + ); + let visible_rows = GlideTestUtils.commandline.visible_rows(); + // Note: Updates are disabled in the test environment + // There will be 1 visible row stating that "Updates are disabled by policy" + await waiter(() => visible_rows.length).is(1, "there should only be 1 update option present"); + await keys(""); + }); +}); diff --git a/src/glide/browser/base/content/utils/browser-update.mts b/src/glide/browser/base/content/utils/browser-update.mts new file mode 100644 index 00000000..2d7a5140 --- /dev/null +++ b/src/glide/browser/base/content/utils/browser-update.mts @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { AppUpdater } = ChromeUtils.importESModule("resource://gre/modules/AppUpdater.sys.mjs", {}); +const { DownloadUtils } = ChromeUtils.importESModule("resource://gre/modules/DownloadUtils.sys.mjs", { + global: "current", +}); + +export function format_download_progress(progress: number, progressMax: number): string { + const transfer = DownloadUtils.getTransferTotal(progress, progressMax); + const pct = progressMax > 0 ? Math.round((progress / progressMax) * 100) : 0; + return `Downloading update… ${pct}% (${transfer})`; +} + +export function is_actionable(STATUS: typeof AppUpdater.STATUS, status: number): boolean { + return status === STATUS.DOWNLOAD_AND_INSTALL || status === STATUS.READY_FOR_RESTART; +} + +export function get_action_label( + STATUS: typeof AppUpdater.STATUS, + status: number, + update: nsIUpdate | null, +): string { + if (status === STATUS.DOWNLOAD_AND_INSTALL) { + const version = update?.displayVersion ?? ""; + return version ? `Download and install ${version}` : "Download and install"; + } + if (status === STATUS.READY_FOR_RESTART) { + return "Restart to apply update"; + } + return ""; +} + +export interface UpdateOption extends GlideCompletionOption { + kind: "status" | "action"; +} + +export function get_status_text(STATUS: typeof AppUpdater.STATUS, status: number, update: nsIUpdate | null): string { + switch (status) { + case STATUS.NEVER_CHECKED: + return "Ready to check for updates…"; + case STATUS.CHECKING: + return "Checking for updates…"; + case STATUS.CHECKING_FAILED: + return "Failed to check for updates"; + case STATUS.NO_UPDATES_FOUND: + return "Glide is up to date"; + case STATUS.NO_UPDATER: + return "Update system is not available"; + case STATUS.UPDATE_DISABLED_BY_POLICY: + return "Updates are disabled by policy"; + case STATUS.OTHER_INSTANCE_HANDLING_UPDATES: + return "Another instance is handling updates"; + case STATUS.UNSUPPORTED_SYSTEM: + return "Updates are not supported on this system"; + case STATUS.MANUAL_UPDATE: + return "Please download the update manually"; + case STATUS.DOWNLOAD_AND_INSTALL: { + const version = update?.displayVersion ?? "unknown"; + return `Update ${version} available`; + } + case STATUS.DOWNLOADING: + return "Downloading update…"; + case STATUS.DOWNLOAD_FAILED: + return "Download failed"; + case STATUS.STAGING: + return "Applying update…"; + case STATUS.READY_FOR_RESTART: + return "Update ready — restart to apply"; + case STATUS.INTERNAL_ERROR: + return "An internal error occurred"; + default: + return "Unknown update state"; + } +} diff --git a/src/glide/browser/base/jar.mn b/src/glide/browser/base/jar.mn index 5d7c8b77..7618b340 100644 --- a/src/glide/browser/base/jar.mn +++ b/src/glide/browser/base/jar.mn @@ -45,7 +45,8 @@ glide.jar: content/utils/strings.mjs (content/utils/dist/strings.mjs) content/utils/promises.mjs (content/utils/dist/promises.mjs) content/utils/resources.mjs (content/utils/dist/resources.mjs) - content/utils/browser-ui.mjs (content/utils/dist/browser-ui.mjs) + content/utils/browser-ui.mjs (content/utils/dist/browser-ui.mjs) + content/utils/browser-update.mjs (content/utils/dist/browser-update.mjs) # default plugins content/plugins/shims.mjs (content/plugins/dist/shims.mjs)