diff --git a/browser/components/shell/ShellService.sys.mjs b/browser/components/shell/ShellService.sys.mjs index ee2cdd8fc7ba..d047c15358fe 100644 --- a/browser/components/shell/ShellService.sys.mjs +++ b/browser/components/shell/ShellService.sys.mjs @@ -12,6 +12,8 @@ ChromeUtils.defineESModuleGetters(lazy, { ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", ScheduledTask: "resource://gre/modules/ScheduledTask.sys.mjs", Subprocess: "resource://gre/modules/Subprocess.sys.mjs", + WindowsSetDefaultRedirect: + "moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs", WindowsVersionInfo: "resource://gre/modules/components-utils/WindowsVersionInfo.sys.mjs", }); @@ -422,7 +424,36 @@ let ShellServiceInternal = { ); }, - async setAsDefaultPDFHandler(onlyIfKnownBrowser = false) { + /** + * Returns the on-disk nsIFile for a PDF bundled under the browser directory + * in NS_GRE_DIR. + * + * @param {string} aLeafName - The bundled PDF's file name, e.g. + * "confused_fox.pdf". + * @returns {nsIFile} The bundled file (which may not exist on disk). + */ + getBundledPdfFile(aLeafName) { + const file = Services.dirsvc.get("GreD", Ci.nsIFile); + file.append("browser"); + file.append(aLeafName); + return file; + }, + + /** + * Set Firefox as the Windows default PDF handler. + * + * @param {boolean} [onlyIfKnownBrowser] - When true, only proceed if the + * current default PDF handler is a known browser. + * @param {boolean} [openInFirefox] - Only meaningful on the "Open with" + * picker code path. After the user picks Firefox, the OS relaunches Firefox + * with the bundled stub PDF; this flag decides whether we then open a PDF in + * a new tab (true), to land the user in Firefox, or silently absorb that + * relaunch (false). + */ + async setAsDefaultPDFHandler( + onlyIfKnownBrowser = false, + openInFirefox = false + ) { if (AppConstants.platform != "win") { throw new Error("Windows-only"); } @@ -453,10 +484,6 @@ let ShellServiceInternal = { ); } - const winShell = this.shellService.QueryInterface( - Ci.nsIWindowsShellService - ); - // Optional second attempt via the undocumented IOpenWithLauncher API, // which surfaces the OS "Open with" picker so the user can pick Firefox // themselves. Gated by a pref so it can be remotely disabled if it @@ -469,10 +496,27 @@ let ShellServiceInternal = { ) ) { method = "open_with"; + const openWithArg = this.getBundledPdfFile("confused_fox.pdf").path; + // Arm the round-trip: the OS hands `openWithArg` back to Firefox if the user + // selects us. We redirect that launch to the bundled PDF); otherwise overrideUri + // is null and the launch is suppressed. + const overrideUri = openInFirefox + ? Services.io.newFileURI(this.getBundledPdfFile("blank.pdf")).spec + : null; + lazy.WindowsSetDefaultRedirect.arm( + openWithArg, + overrideUri, + lazy.WindowsSetDefaultRedirect.TYPE.FILE + ); + try { - winShell.launchOpenWithDefaultPickerForFileType(".pdf"); + const flags = this._isWindows11() + ? Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER + : Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER_WIN10; + this.shellService.launchSetDefaultAppPicker(openWithArg, flags); success = true; } catch (e) { + lazy.WindowsSetDefaultRedirect.clear(); // The picker API itself failed (e.g. COM error). Fall through to the // modern settings dialog rather than leaving the user without any // default-handler UI. @@ -487,7 +531,7 @@ let ShellServiceInternal = { if (!success && this._isWindows11()) { method = "settings"; try { - winShell.launchModernSettingsDialogDefaultApps(); + this.shellService.launchModernSettingsDialogDefaultApps(); Glean.browser.setDefaultPdfHandlerModernSettingsResult.Success.add(1); success = true; } catch (e) { @@ -524,9 +568,7 @@ let ShellServiceInternal = { */ isDefaultHandlerFor(aFileExtensionOrProtocol) { if (AppConstants.platform == "win") { - return this.shellService - .QueryInterface(Ci.nsIWindowsShellService) - .isDefaultHandlerFor(aFileExtensionOrProtocol); + return this.shellService.isDefaultHandlerFor(aFileExtensionOrProtocol); } return false; }, diff --git a/browser/components/shell/WindowsSetDefaultAppCmdHandler.sys.mjs b/browser/components/shell/WindowsSetDefaultAppCmdHandler.sys.mjs new file mode 100644 index 000000000000..88de305e30d8 --- /dev/null +++ b/browser/components/shell/WindowsSetDefaultAppCmdHandler.sys.mjs @@ -0,0 +1,97 @@ +/* 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/. */ + +/** + * Command-line handler for the Windows IOpenWithLauncher round-trip. + * + * When the ShellService.setAsDefault{PDF,Protocol}Handler launches the windows only + * "Open with" picker, it hands it a bundled stub file path for file-type + * defaults, or a URL for protocol defaults, and stashes a one-shot + * { openWithArg, overrideUri } redirect. + * + * If the user picks Firefox, Windows invokes Firefox with + * `-osint -url `. This handler runs before BrowserContentHandler and + * intercepts the launch: it asks ShellService whether the -url + * value matches the pending openWithArg and, if so, suppresses the + * open and optionally redirects to the stashed overrideUri so the user lands + * somewhere meaningful in Firefox. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + WindowsSetDefaultRedirect: + "moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { + return console.createInstance({ + prefix: "WindowsSetDefaultAppCmdHandler", + maxLogLevel: "Warn", + }); +}); + +export class CommandLineHandler { + static classID = Components.ID("{da7de528-7a15-452e-b5a7-521099997ca1}"); + static contractID = "@mozilla.org/browser/windows-default-clh;1"; + + QueryInterface = ChromeUtils.generateQI([Ci.nsICommandLineHandler]); + + handle(aCmdLine) { + if (aCmdLine.findFlag("osint", false) < 0) { + return; + } + + const urlIdx = aCmdLine.findFlag("url", false); + if (urlIdx < 0) { + return; + } + + const cmdArg = aCmdLine.getArgument(urlIdx + 1); + + // null: not our openWithArg, leave -url for BrowserContentHandler + const redirect = lazy.WindowsSetDefaultRedirect.consume(cmdArg); + if (!redirect) { + return; + } + + const { overrideUri } = redirect; + + lazy.logConsole.debug( + `Claimed IOpenWithLauncher openWithArg ${cmdArg}: state=${aCmdLine.state}, overrideUri=${overrideUri}` + ); + + // Consume the arg and suppress the default open so BrowserContentHandler + // doesn't act on it + aCmdLine.handleFlagWithParam("url", false); + aCmdLine.preventDefault = true; + if (overrideUri === null) { + return; + } + + lazy.logConsole.info( + `Redirecting IOpenWithLauncher round-trip to ${overrideUri}` + ); + + try { + const win = lazy.BrowserWindowTracker.getTopWindow(); + if (win) { + win.openTrustedLinkIn(overrideUri, "tab"); + return; + } + + const args = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + args.data = overrideUri; + lazy.BrowserWindowTracker.openWindow({ args }); + } catch (e) { + lazy.logConsole.error( + `Failed to open redirect target ${overrideUri}:`, + e + ); + } + } +} diff --git a/browser/components/shell/WindowsSetDefaultRedirect.sys.mjs b/browser/components/shell/WindowsSetDefaultRedirect.sys.mjs new file mode 100644 index 000000000000..d2c8d5812ae7 --- /dev/null +++ b/browser/components/shell/WindowsSetDefaultRedirect.sys.mjs @@ -0,0 +1,135 @@ +/* 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/. */ + +/** + * The IOpenWithLauncher api call protocol shared by the producer + * (ShellService.setAsDefault{PDF,Protocol}Handler) and the consumer + * (WindowsSetDefaultAppCmdHandler). + * + * ShellService arms a one-shot redirect before launching the OS "Open with" + * picker via IOpenWithLauncher; once the user picks Firefox, the OS relaunches + * Firefox with the same value and the command-line handler consumes it. This + * module owns that shared state (the pref shape and the matching rules). + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +// This pref is an object { openWithArg, overrideUri, type } consumed by +// WindowsSetDefaultAppCmdHandler when the user picks a default (file type or +// protocol) using the IOpenWithLauncher API. It is reset anytime the dialog is +// used again, or when we intercept the OS reopening one of our openWithArgs. +export const SET_DEFAULT_REDIRECT_PREF = + "browser.shell.setDefaultApp.pendingRedirect"; + +export class WindowsSetDefaultRedirect { + // Supported default types to set using IOpenWithLauncher. + static TYPE = { + FILE: 1 << 0, + PROTOCOL: 1 << 1, + }; + + /** + * Stash a one-shot redirect for the IOpenWithLauncher call. + * + * @param {string} openWithArg + * The value handed to launchSetDefaultAppPicker, which the OS hands back as + * "-osint -url " once the user picks a new default. Depending on + * type, this is either a file path on the system (file-type defaults) or a + * URL (protocol defaults). + * @param {?string} overrideUri + * URI spec to open when openWithArg comes back, or null to consume the relaunch + * and open nothing. + * @param {number} type + * One of WindowsSetDefaultRedirect.TYPE, identifying whether openWithArg is a + * file path or a URL. + */ + static arm(openWithArg, overrideUri, type) { + // Clear any stale object left by an older call. + Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF); + + Services.prefs.setStringPref( + SET_DEFAULT_REDIRECT_PREF, + JSON.stringify({ openWithArg, overrideUri: overrideUri ?? null, type }) + ); + } + + /** + * Clear a pending redirect. + */ + static clear() { + Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF); + } + + /** + * If `arg` is the openWithArg stashed by the most recent + * launchSetDefaultAppPicker call, consume the one-shot redirect and return + * its `{ overrideUri }`, where overrideUri is a URI spec to open or null to + * just suppress the relaunch. Returns null when `arg` is unrelated to a + * pending attempt to set a default. + * + * @param {string} arg - The -url value the OS handed back. + * @returns {?{overrideUri: ?string}} + */ + static consume(arg) { + const state = this.#read(); + if (!state || !this.#matches(state, arg)) { + return null; + } + Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF); + return { overrideUri: state.overrideUri ?? null }; + } + + /** + * Read and validate the pending redirect stashed by arm(). + * + * @returns {?{openWithArg: string, overrideUri: ?string, type: number}} The + * stored state, or null when the pref is unset, holds the wrong type, or is + * malformed JSON. + */ + static #read() { + let raw; + try { + raw = Services.prefs.getStringPref(SET_DEFAULT_REDIRECT_PREF, ""); + } catch (e) { + return null; + } + if (!raw) { + return null; + } + try { + const state = JSON.parse(raw); + return state && typeof state.openWithArg === "string" ? state : null; + } catch (e) { + return null; + } + } + + /** + * Checks if the -url value the OS handed back matches the stashed redirect. + * + * @param {{openWithArg: string, type: number}} state - The stashed redirect. + * @param {string} arg - The -url value from the OS relaunch. + * @returns {boolean} + */ + static #matches(state, arg) { + switch (state.type) { + case this.TYPE.PROTOCOL: + return state.openWithArg === arg; + case this.TYPE.FILE: + try { + return new lazy.FileUtils.File(state.openWithArg).equals( + new lazy.FileUtils.File(arg) + ); + } catch (e) { + return false; + } + default: + return false; + } + } +} diff --git a/browser/components/shell/components.conf b/browser/components/shell/components.conf new file mode 100644 index 000000000000..58a246cd8245 --- /dev/null +++ b/browser/components/shell/components.conf @@ -0,0 +1,18 @@ +# 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/. + +Classes = [] + +if buildconfig.substs["OS_ARCH"] == "WINNT": + Classes += [ + { + "cid": "{da7de528-7a15-452e-b5a7-521099997ca1}", + "contract_ids": ["@mozilla.org/browser/windows-default-clh;1"], + "categories": { + "command-line-handler": "l-windows-default", + }, + "esModule": "moz-src:///browser/components/shell/WindowsSetDefaultAppCmdHandler.sys.mjs", + "constructor": "CommandLineHandler", + }, + ] diff --git a/browser/components/shell/content/blank.pdf b/browser/components/shell/content/blank.pdf new file mode 100644 index 000000000000..ffff6d4bcf19 Binary files /dev/null and b/browser/components/shell/content/blank.pdf differ diff --git a/browser/components/shell/content/confused_fox.pdf b/browser/components/shell/content/confused_fox.pdf new file mode 100644 index 000000000000..65b44d374489 Binary files /dev/null and b/browser/components/shell/content/confused_fox.pdf differ diff --git a/browser/components/shell/moz.build b/browser/components/shell/moz.build index 01fc43e1d938..55052eb44fe1 100644 --- a/browser/components/shell/moz.build +++ b/browser/components/shell/moz.build @@ -73,6 +73,15 @@ elif CONFIG["OS_ARCH"] == "WINNT": "crypt32", "propsys", ] + FINAL_TARGET_FILES += ["content/blank.pdf", "content/confused_fox.pdf"] + + MOZ_SRC_FILES += [ + "WindowsSetDefaultAppCmdHandler.sys.mjs", + ] + + XPCOM_MANIFESTS += [ + "components.conf", + ] XPIDL_MODULE = "shellservice" @@ -83,6 +92,7 @@ MOZ_SRC_FILES += [ "HeadlessShell.sys.mjs", "ScreenshotChild.sys.mjs", "ShellService.sys.mjs", + "WindowsSetDefaultRedirect.sys.mjs", ] MOZ_SRC_FILES += [ diff --git a/browser/components/shell/nsIWindowsShellService.idl b/browser/components/shell/nsIWindowsShellService.idl index aae7ba134a1a..d34c6a1edabf 100644 --- a/browser/components/shell/nsIWindowsShellService.idl +++ b/browser/components/shell/nsIWindowsShellService.idl @@ -10,6 +10,27 @@ interface nsIFile; [scriptable, uuid(fb9b59db-5a91-4e67-92b6-35e7d6e6d3fd)] interface nsIWindowsShellService : nsIShellService { + /* + * Bit flags for launchSetDefaultAppPicker's aFlags argument. They map 1:1 + * onto the flags accepted by the undocumented Windows IOpenWithLauncher COM + * interface's Launch method. + * The mapping below was determined empirically and determines the picker's + * messaging and actions. + */ + // Suppresses the default open-in-selected-app behavior. Remote-controlled + // by Microsoft. + const long OPEN_WITH_SUPPRESS_OPEN = 1 << 2; + // Switches picker messaging to protocol mode ("Select a default handler + // for ___ links"). + const long OPEN_WITH_PROTOCOL_MESSAGING = 1 << 3; + // Opens with the selected app just once; does not set a default. + const long OPEN_WITH_OPEN_ONCE = 1 << 6; + // Asks the user to set a default for the given file type or protocol. Messaging is + // ideal on Windows 11 only. + const long OPEN_WITH_SET_HANDLER = 1 << 7; + // Like OPEN_WITH_SET_HANDLER, but with ideal messaging for Windows 10. + const long OPEN_WITH_SET_HANDLER_WIN10 = 1 << 13; + /* * Creates a new shortcut (.lnk) file. This shortcut will be recorded in * a new shortcuts log file located in %PROGRAMDATA%\Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38 @@ -349,16 +370,19 @@ interface nsIWindowsShellService : nsIShellService ); /* - * Launch the default app picker for a given file type via the Windows - * IOpenWithLauncher COM interface. + * Launch the default app picker via the Windows IOpenWithLauncher COM + * interface. The caller picks the target (a file path whose extension + * determines the picker for file-type defaults, or a URL for protocol + * defaults) and the flag bits that drive the picker's messaging and actions. * - * @param aFileType - * The file extension (e.g. ".pdf") for which to open - * the default app picker. + * @param aTarget + * File path or URL. + * @param aFlags + * Flag combination that determines the behavior of the IOpenWithLauncher dialog. * @throws NS_ERROR_FAILURE * If IOpenWithLauncher is unavailable or fails. */ - void launchOpenWithDefaultPickerForFileType(in AString aFileType); + void launchSetDefaultAppPicker(in AString aTarget, in long aFlags); /* * Open the Windows modern settings dialog for choosing default apps diff --git a/browser/components/shell/nsWindowsShellService.cpp b/browser/components/shell/nsWindowsShellService.cpp index b2b1bc836114..2b36517e5bd9 100644 --- a/browser/components/shell/nsWindowsShellService.cpp +++ b/browser/components/shell/nsWindowsShellService.cpp @@ -459,13 +459,21 @@ nsWindowsShellService::CanSetDefaultBrowserUserChoice(bool* aResult) { class __declspec(novtable) IOpenWithLauncher : public IUnknown { public: + // lpszPath selects what the picker offers to set as default. It accepts + // several shapes: + // - a file path: "C:\\path\\to\\file.pdf" + // - a file type: ".pdf" + // - a protocol: "http" + // - a protocol URI: "https://example.com", "mailto:foo@example.com" + // flags determines the messaging and actions available of the + // IOpenWithLauncher dialog. virtual HRESULT STDMETHODCALLTYPE Launch(HWND hWndParent, LPCWSTR lpszPath, int flags) = 0; }; NS_IMETHODIMP -nsWindowsShellService::LaunchOpenWithDefaultPickerForFileType( - const nsAString& aFileType) { +nsWindowsShellService::LaunchSetDefaultAppPicker(const nsAString& aTarget, + int32_t aFlags) { static constexpr GUID IID_IOpenWithLauncher = { 0x6a283fe2, 0xecfa, @@ -500,10 +508,7 @@ nsWindowsShellService::LaunchOpenWithDefaultPickerForFileType( // Make sure the dialog is foregrounded. CoAllowSetForegroundWindow(pOWL, nullptr); - // The flag is a bit of a mystery; on Win11+ 0x84 gives ideal messaging, on - // Win10 we use 0x2004. - int flag = mozilla::IsWin11OrLater() ? 0x84 : 0x2004; - hr = pOWL->Launch(nullptr, aFileType.Data(), flag); + hr = pOWL->Launch(nullptr, PromiseFlatString(aTarget).get(), aFlags); return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE; } diff --git a/browser/components/shell/test/browser.toml b/browser/components/shell/test/browser.toml index 11e34f2bba1c..c409640d839e 100644 --- a/browser/components/shell/test/browser.toml +++ b/browser/components/shell/test/browser.toml @@ -120,3 +120,9 @@ tags = "os_integration" ["browser_setDesktopBackgroundPreview.js"] support-files = ["large.png", "canvas.html"] tags = "os_integration" + +["browser_windowsSetDefaultAppCmdHandler.js"] +run-if = [ + "os == 'win'", +] +tags = "os_integration" diff --git a/browser/components/shell/test/browser_setDefaultPDFHandler.js b/browser/components/shell/test/browser_setDefaultPDFHandler.js index 04cc1f069303..3ffa6cb573c4 100644 --- a/browser/components/shell/test/browser_setDefaultPDFHandler.js +++ b/browser/components/shell/test/browser_setDefaultPDFHandler.js @@ -8,6 +8,9 @@ ChromeUtils.defineESModuleGetters(this, { sinon: "resource://testing-common/Sinon.sys.mjs", }); +const confusedFoxPath = ShellService.getBundledPdfFile("confused_fox.pdf").path; +const SET_HANDLER_WIN11 = Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER; + const setDefaultBrowserUserChoiceStub = sinon.stub(); const setDefaultExtensionHandlersUserChoiceStub = sinon .stub() @@ -34,17 +37,15 @@ const _userChoiceImpossibleTelemetryResultStub = sinon const setDefaultStub = sinon.stub(); // We'll dynamically update this as needed during the tests. const queryCurrentDefaultHandlerForStub = sinon.stub(); -const launchOpenWithDefaultPickerForFileTypeStub = sinon.stub(); +const launchSetDefaultAppPickerStub = sinon.stub(); const launchModernSettingsDialogDefaultAppsStub = sinon.stub(); const shellStub = sinon.stub(ShellService, "shellService").value({ setDefaultBrowser: setDefaultStub, queryCurrentDefaultHandlerFor: queryCurrentDefaultHandlerForStub, - QueryInterface: () => ({ - launchOpenWithDefaultPickerForFileType: - launchOpenWithDefaultPickerForFileTypeStub, - launchModernSettingsDialogDefaultApps: - launchModernSettingsDialogDefaultAppsStub, - }), + launchSetDefaultAppPicker: launchSetDefaultAppPickerStub, + launchModernSettingsDialogDefaultApps: + launchModernSettingsDialogDefaultAppsStub, + QueryInterface: ChromeUtils.generateQI([]), }); registerCleanupFunction(() => { @@ -228,7 +229,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() { const expectedArguments = [aumi, [".pdf", "FirefoxPDF"]]; const resetStubs = () => { setDefaultExtensionHandlersUserChoiceStub.resetHistory(); - launchOpenWithDefaultPickerForFileTypeStub.resetHistory(); + launchSetDefaultAppPickerStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory(); }; @@ -250,7 +251,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() { "Called default browser agent with expected arguments" ); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.notCalled, + launchSetDefaultAppPickerStub.notCalled, "Did not fall back to open-with picker" ); Assert.ok( @@ -271,7 +272,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() { "Called default browser agent with expected arguments" ); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.notCalled, + launchSetDefaultAppPickerStub.notCalled, "Did not fall back to open-with picker" ); Assert.ok( @@ -289,7 +290,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() { "Did not use userChoice" ); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.notCalled, + launchSetDefaultAppPickerStub.notCalled, "Did not fall back to open-with picker" ); Assert.ok( @@ -310,7 +311,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() { "Called default browser agent with expected arguments" ); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.notCalled, + launchSetDefaultAppPickerStub.notCalled, "Did not fall back to open-with picker" ); Assert.ok( @@ -365,8 +366,11 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() { Assert.ok(userChoiceStub.called, "Attempted userChoice"); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.calledWith(".pdf"), - "Fell back to open-with picker for .pdf" + launchSetDefaultAppPickerStub.calledWith( + confusedFoxPath, + SET_HANDLER_WIN11 + ), + "Fell back to open-with picker with bundled PDF path and Win11 flag" ); Assert.ok( launchModernSettingsDialogDefaultAppsStub.notCalled, @@ -392,7 +396,7 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() { ); userChoiceStub.resetHistory(); isDefaultHandlerForStub.resetHistory(); - launchOpenWithDefaultPickerForFileTypeStub.resetHistory(); + launchSetDefaultAppPickerStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory(); info( @@ -413,22 +417,25 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() { isDefaultHandlerForStub.returns(true); userChoiceStub.resetHistory(); isDefaultHandlerForStub.resetHistory(); - launchOpenWithDefaultPickerForFileTypeStub.resetHistory(); + launchSetDefaultAppPickerStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory(); info( "When userChoice fails and open-with picker fails, should fall back to settings dialog" ); Services.fog.testResetFOG(); - launchOpenWithDefaultPickerForFileTypeStub.throws( + launchSetDefaultAppPickerStub.throws( new Error("mock IOpenWithLauncher failure") ); await ShellService.setAsDefaultPDFHandler(false); Assert.ok(userChoiceStub.called, "Attempted userChoice"); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.calledWith(".pdf"), - "Attempted open-with picker for .pdf" + launchSetDefaultAppPickerStub.calledWith( + confusedFoxPath, + SET_HANDLER_WIN11 + ), + "Attempted open-with picker with bundled PDF path and Win11 flag" ); Assert.ok( launchModernSettingsDialogDefaultAppsStub.called, @@ -463,7 +470,7 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() { ); userChoiceStub.resetHistory(); isDefaultHandlerForStub.resetHistory(); - launchOpenWithDefaultPickerForFileTypeStub.resetHistory(); + launchSetDefaultAppPickerStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory(); info( @@ -499,7 +506,7 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() { "Event result_is_default is false when no method set the default" ); } finally { - launchOpenWithDefaultPickerForFileTypeStub.reset(); + launchSetDefaultAppPickerStub.reset(); launchModernSettingsDialogDefaultAppsStub.reset(); sandbox.restore(); await SpecialPowers.popPrefEnv(); @@ -529,7 +536,7 @@ add_task(async function test_setAsDefaultPDFHandler_useOpenWithDisabled() { await ShellService.setAsDefaultPDFHandler(false); Assert.ok( - launchOpenWithDefaultPickerForFileTypeStub.notCalled, + launchSetDefaultAppPickerStub.notCalled, "Did not invoke open-with picker when pref is disabled" ); Assert.ok( @@ -550,7 +557,7 @@ add_task(async function test_setAsDefaultPDFHandler_useOpenWithDisabled() { "Event result_is_default reflects isDefaultHandlerFor" ); } finally { - launchOpenWithDefaultPickerForFileTypeStub.reset(); + launchSetDefaultAppPickerStub.reset(); launchModernSettingsDialogDefaultAppsStub.reset(); sandbox.restore(); await SpecialPowers.popPrefEnv(); diff --git a/browser/components/shell/test/browser_windowsSetDefaultAppCmdHandler.js b/browser/components/shell/test/browser_windowsSetDefaultAppCmdHandler.js new file mode 100644 index 000000000000..62c31979f9c3 --- /dev/null +++ b/browser/components/shell/test/browser_windowsSetDefaultAppCmdHandler.js @@ -0,0 +1,293 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CommandLineHandler } = ChromeUtils.importESModule( + "moz-src:///browser/components/shell/WindowsSetDefaultAppCmdHandler.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + SET_DEFAULT_REDIRECT_PREF: + "moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs", + ShellService: "moz-src:///browser/components/shell/ShellService.sys.mjs", + WindowsSetDefaultRedirect: + "moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +Assert.equal(AppConstants.platform, "win", "Platform is Windows"); + +const confusedFoxPath = ShellService.getBundledPdfFile("confused_fox.pdf").path; +const blankURISpec = Services.io.newFileURI( + ShellService.getBundledPdfFile("blank.pdf") +).spec; + +const workingDir = Services.dirsvc.get("GreD", Ci.nsIFile); + +// Build a real nsICommandLine from an args array, so we exercise the same +// flag-parsing the OS-initiated launch goes through +function makeCmdLine(args, state = Ci.nsICommandLine.STATE_INITIAL_LAUNCH) { + return Cu.createCommandLine(args, workingDir, state); +} + +// Arm the one-shot redirect via the real ShellService helper, the way +// setAsDefault{*}Handler does right before launching the OS picker. These +// tests all use file openWithArgs, so default the type. +function armRedirect( + openWithArg, + overrideUri, + type = WindowsSetDefaultRedirect.TYPE.FILE +) { + WindowsSetDefaultRedirect.arm(openWithArg, overrideUri, type); +} + +let fakeWin; +let getTopWindowStub; +let openWindowStub; + +add_setup(function () { + fakeWin = { openTrustedLinkIn: sinon.stub() }; + // Stubbed rather than spied: getTopWindow must return our fake window (and + // null on demand), and openWindow must not actually open a window in the + // test harness. We only inspect the calls. + getTopWindowStub = sinon.stub(BrowserWindowTracker, "getTopWindow"); + openWindowStub = sinon.stub(BrowserWindowTracker, "openWindow"); +}); + +registerCleanupFunction(() => { + sinon.restore(); + Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF); +}); + +function resetState() { + fakeWin.openTrustedLinkIn.resetHistory(); + getTopWindowStub.reset(); + getTopWindowStub.returns(fakeWin); + openWindowStub.reset(); + Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF); +} + +add_task(async function test_no_osint_returns_early() { + resetState(); + armRedirect(confusedFoxPath, blankURISpec); + const cmdLine = makeCmdLine(["-url", confusedFoxPath]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + false, + "preventDefault left untouched without -osint" + ); + Assert.greaterOrEqual( + cmdLine.findFlag("url", false), + 0, + "-url remains for the next handler" + ); + Assert.ok( + fakeWin.openTrustedLinkIn.notCalled, + "No tab opened without -osint" + ); + Assert.ok(openWindowStub.notCalled, "No window opened without -osint"); +}); + +add_task(async function test_osint_without_url_returns_early() { + resetState(); + armRedirect(confusedFoxPath, blankURISpec); + const cmdLine = makeCmdLine(["-osint"]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + false, + "preventDefault left untouched without -url" + ); + Assert.ok(fakeWin.openTrustedLinkIn.notCalled, "No tab opened without -url"); + Assert.ok(openWindowStub.notCalled, "No window opened without -url"); +}); + +add_task(async function test_no_pending_redirect_leaves_arg() { + // No armed redirect: even a -url that looks like our stub PDF is a real, + // user-initiated open (e.g. they double-clicked the file), so we must leave + // it for BrowserContentHandler. + resetState(); + + const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + false, + "preventDefault not set when no redirect is pending" + ); + Assert.greaterOrEqual( + cmdLine.findFlag("url", false), + 0, + "-url preserved for BrowserContentHandler" + ); + Assert.ok( + fakeWin.openTrustedLinkIn.notCalled, + "No tab opened when no redirect is pending" + ); + Assert.ok( + openWindowStub.notCalled, + "No window opened when no redirect is pending" + ); +}); + +add_task(async function test_unrelated_url_arg_is_ignored() { + resetState(); + armRedirect(confusedFoxPath, blankURISpec); + + const cmdLine = makeCmdLine([ + "-osint", + "-url", + "https://example.com/some-page", + ]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + false, + "preventDefault not set for a -url that isn't the pending openWithArg" + ); + Assert.greaterOrEqual( + cmdLine.findFlag("url", false), + 0, + "-url preserved for subsequent handler" + ); + Assert.ok( + fakeWin.openTrustedLinkIn.notCalled, + "No tab opened for an unrelated -url" + ); + Assert.ok( + Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF), + "Pending redirect untouched when the openWithArg doesn't match" + ); +}); + +add_task(async function test_suppress_only_when_target_null() { + resetState(); + armRedirect(confusedFoxPath, null); + + const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + true, + "openWithArg suppressed so BrowserContentHandler skips it" + ); + Assert.equal( + cmdLine.findFlag("url", false), + -1, + "-url consumed even with no redirect target" + ); + Assert.ok( + fakeWin.openTrustedLinkIn.notCalled, + "No redirect when target is null" + ); + Assert.ok(openWindowStub.notCalled, "No fallback window when target is null"); + Assert.ok( + !Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF), + "Redirect intent consumed" + ); +}); + +add_task(async function test_redirects_to_top_window() { + resetState(); + armRedirect(confusedFoxPath, blankURISpec); + + const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + true, + "Default open suppressed for the pending openWithArg" + ); + Assert.equal(cmdLine.findFlag("url", false), -1, "-url consumed"); + Assert.ok( + fakeWin.openTrustedLinkIn.calledOnce, + "Redirected into the top window" + ); + Assert.deepEqual(fakeWin.openTrustedLinkIn.firstCall.args, [ + blankURISpec, + "tab", + ]); + Assert.ok(openWindowStub.notCalled, "No new window when one exists"); + Assert.ok( + !Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF), + "Redirect intent is one-shot and cleared after use" + ); +}); + +add_task(async function test_opens_new_window_when_no_top() { + resetState(); + getTopWindowStub.returns(null); + armRedirect(confusedFoxPath, blankURISpec); + + const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]); + new CommandLineHandler().handle(cmdLine); + + Assert.equal( + cmdLine.preventDefault, + true, + "Default open suppressed even when no top window exists" + ); + Assert.ok( + fakeWin.openTrustedLinkIn.notCalled, + "Top-window path skipped when getTopWindow returns null" + ); + Assert.ok(openWindowStub.calledOnce, "Falls back to openWindow"); + + const opts = openWindowStub.firstCall.args[0]; + Assert.ok( + opts && opts.args, + "openWindow called with a {args} options object" + ); + Assert.ok( + opts.args instanceof Ci.nsISupportsString, + "args is an nsISupportsString" + ); + Assert.equal( + opts.args.data, + blankURISpec, + "nsISupportsString carries the redirect URI" + ); + Assert.ok( + !Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF), + "Redirect intent is one-shot and cleared after use" + ); +}); + +add_task(async function test_intent_is_one_shot() { + resetState(); + armRedirect(confusedFoxPath, blankURISpec); + + new CommandLineHandler().handle( + makeCmdLine(["-osint", "-url", confusedFoxPath]) + ); + Assert.ok( + fakeWin.openTrustedLinkIn.calledOnce, + "First call honors the pending redirect" + ); + + fakeWin.openTrustedLinkIn.resetHistory(); + const second = makeCmdLine(["-osint", "-url", confusedFoxPath]); + new CommandLineHandler().handle(second); + + Assert.equal( + second.preventDefault, + false, + "Second call leaves the openWithArg alone because the intent was consumed" + ); + Assert.greaterOrEqual( + second.findFlag("url", false), + 0, + "-url preserved on the second call (no pending redirect)" + ); + Assert.ok( + fakeWin.openTrustedLinkIn.notCalled, + "Second call does not redirect" + ); +}); diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index 83e52b260c38..203811a0fb98 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -221,6 +221,13 @@ @BINPATH@/@MOZ_DXC_DLL_NAME@ #endif +; Bundled stub PDFs handed to the OS "Open with" picker by the +; set-default-PDF-handler flow (see WindowsSetDefaultAppCmdHandler). +#ifdef XP_WIN +@RESPATH@/browser/confused_fox.pdf +@RESPATH@/browser/blank.pdf +#endif + ; [Browser Chrome Files] @RESPATH@/browser/chrome.manifest @RESPATH@/browser/chrome/browser@JAREXT@ diff --git a/toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs b/toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs index 6aca21afcc4a..ad02aa48f3b8 100644 --- a/toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs +++ b/toolkit/components/messaging-system/lib/SpecialMessageActions.sys.mjs @@ -185,8 +185,14 @@ export const SpecialMessageActions = { * * @param {Window} window Reference to a window object */ - async setDefaultPDFHandler(window, onlyIfKnownBrowser = false) { - await window.getShellService().setAsDefaultPDFHandler(onlyIfKnownBrowser); + async setDefaultPDFHandler( + window, + onlyIfKnownBrowser = false, + openInFirefox = false + ) { + await window + .getShellService() + .setAsDefaultPDFHandler(onlyIfKnownBrowser, openInFirefox); }, /** @@ -815,7 +821,8 @@ export const SpecialMessageActions = { case "SET_DEFAULT_PDF_HANDLER": await this.setDefaultPDFHandler( window, - action.data?.onlyIfKnownBrowser ?? false + action.data?.onlyIfKnownBrowser ?? false, + action.data?.openInFirefox ?? false ); break; case "DECLINE_DEFAULT_PDF_HANDLER": diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json index beb347d42f7e..01270166f1ab 100644 --- a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json +++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json @@ -478,6 +478,10 @@ "onlyIfKnownBrowser": { "type": "boolean", "description": "Only set Firefox as the default PDF handler if the current PDF handler is a known browser." + }, + "openInFirefox": { + "type": "boolean", + "description": "If the OS hands the stub PDF back to Firefox after the user picks Firefox in the open-with dialog, open a follow-up PDF in a new tab instead of suppressing the launch." } }, "additionalProperties": false diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md index c3daa6fa36e7..49322eeb6816 100644 --- a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md +++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md @@ -267,6 +267,10 @@ Windows only. // Only set Firefox as the default PDF handler if the current PDF handler is a // known browser. onlyIfKnownBrowser?: boolean; + // If the OS hands the stub PDF back to Firefox after the user picks Firefox + // in the open-with dialog, open a follow-up PDF in a new tab instead of + // suppressing the launch. + openInFirefox?: boolean; } ``` diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_pdf_handler.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_pdf_handler.js index edb26cc3c296..012e6b9d0295 100644 --- a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_pdf_handler.js +++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_pdf_handler.js @@ -24,7 +24,7 @@ add_task(async function test_set_default_pdf_handler_no_data() { "setAsDefaultPDFHandler was called by the action" ); Assert.ok( - stub.calledWithExactly(false), + stub.calledWithExactly(false, false), "setAsDefaultPDFHandler called with onlyIfKnownBrowser = false" ); }); @@ -55,7 +55,7 @@ add_task(async function test_set_default_pdf_handler_data_false() { "setAsDefaultPDFHandler was called by the action" ); Assert.ok( - stub.calledWithExactly(false), + stub.calledWithExactly(false, false), "setAsDefaultPDFHandler called with onlyIfKnownBrowser = false" ); }); @@ -86,7 +86,7 @@ add_task(async function test_set_default_pdf_handler_data_true() { "setAsDefaultPDFHandler was called by the action" ); Assert.ok( - stub.calledWithExactly(true), + stub.calledWithExactly(true, false), "setAsDefaultPDFHandler called with onlyIfKnownBrowser = true" ); });