Bug 2039411 - Set the default PDF handler through the OS "Open with" picker and intercept the round-trip. r=nrishel,omc-reviewers,sachung

Differential Revision: https://phabricator.services.mozilla.com/D300462
This commit is contained in:
Harshit
2026-06-14 18:45:38 +00:00
committed by hsohaney@mozilla.com
parent 425b0d93e3
commit d0fc8fa5c2
17 changed files with 710 additions and 51 deletions
+52 -10
View File
@@ -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;
},
@@ -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 <openWithArg>`. 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
);
}
}
}
@@ -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 <openWithArg>" 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;
}
}
}
+18
View File
@@ -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",
},
]
Binary file not shown.
Binary file not shown.
+10
View File
@@ -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 += [
@@ -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
@@ -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;
}
@@ -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"
@@ -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();
@@ -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"
);
});
+7
View File
@@ -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@
@@ -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":
@@ -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
@@ -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;
}
```
@@ -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"
);
});