diff --git a/browser/components/extensions/ext-browser.json b/browser/components/extensions/ext-browser.json index aae33f58a26f..917e01f98e50 100644 --- a/browser/components/extensions/ext-browser.json +++ b/browser/components/extensions/ext-browser.json @@ -24,6 +24,14 @@ ["browsingData"] ] }, + "captivePortal": { + "url": "chrome://extensions/content/parent/ext-captivePortal.js", + "schema": "chrome://extensions/content/schemas/captive_portal.json", + "scopes": ["addon_parent"], + "paths": [ + ["captivePortal"] + ] + }, "chrome_settings_overrides": { "url": "chrome://browser/content/parent/ext-chrome-settings-overrides.js", "scopes": [], diff --git a/toolkit/components/extensions/jar.mn b/toolkit/components/extensions/jar.mn index d637cc3ddae3..0cf26113cc5e 100644 --- a/toolkit/components/extensions/jar.mn +++ b/toolkit/components/extensions/jar.mn @@ -10,6 +10,9 @@ toolkit.jar: content/extensions/parent/ext-alarms.js (parent/ext-alarms.js) content/extensions/parent/ext-backgroundPage.js (parent/ext-backgroundPage.js) content/extensions/parent/ext-browserSettings.js (parent/ext-browserSettings.js) +#ifndef ANDROID + content/extensions/parent/ext-captivePortal.js (parent/ext-captivePortal.js) +#endif content/extensions/parent/ext-contentScripts.js (parent/ext-contentScripts.js) content/extensions/parent/ext-contextualIdentities.js (parent/ext-contextualIdentities.js) content/extensions/parent/ext-clipboard.js (parent/ext-clipboard.js) diff --git a/toolkit/components/extensions/parent/ext-captivePortal.js b/toolkit/components/extensions/parent/ext-captivePortal.js new file mode 100644 index 000000000000..55b0bcd1ca6a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-captivePortal.js @@ -0,0 +1,83 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter(this, "gCPS", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService"); + +XPCOMUtils.defineLazyPreferenceGetter(this, "gCaptivePortalEnabled", + "network.captive-portal-service.enabled", + false); + +function nameForCPSState(state) { + switch (state) { + case gCPS.UNKNOWN: return "unknown"; + case gCPS.NOT_CAPTIVE: return "not_captive"; + case gCPS.UNLOCKED_PORTAL: return "unlocked_portal"; + case gCPS.LOCKED_PORTAL: return "locked_portal"; + default: return "unknown"; + } +} + +var { + ExtensionError, +} = ExtensionUtils; + +this.captivePortal = class extends ExtensionAPI { + getAPI(context) { + function checkEnabled() { + if (!gCaptivePortalEnabled) { + throw new ExtensionError("Captive Portal detection is not enabled"); + } + } + + return { + captivePortal: { + getState() { + checkEnabled(); + return nameForCPSState(gCPS.state); + }, + getLastChecked() { + checkEnabled(); + return gCPS.lastChecked; + }, + onStateChanged: new EventManager({ + context, + name: "captivePortal.onStateChanged", + register: fire => { + checkEnabled(); + + let observer = (subject, topic) => { + fire.async({state: nameForCPSState(gCPS.state)}); + }; + + Services.obs.addObserver(observer, "ipc:network:captive-portal-set-state"); + return () => { + Services.obs.removeObserver(observer, "ipc:network:captive-portal-set-state"); + }; + }, + }).api(), + onConnectivityAvailable: new EventManager({ + context, + name: "captivePortal.onConnectivityAvailable", + register: fire => { + checkEnabled(); + + let observer = (subject, topic, data) => { + fire.async({status: data}); + }; + + Services.obs.addObserver(observer, "network:captive-portal-connectivity"); + return () => { + Services.obs.removeObserver(observer, "network:captive-portal-connectivity"); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/schemas/captive_portal.json b/toolkit/components/extensions/schemas/captive_portal.json new file mode 100644 index 000000000000..b525136ce044 --- /dev/null +++ b/toolkit/components/extensions/schemas/captive_portal.json @@ -0,0 +1,69 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "captivePortal" + ] + }] + } + ] + }, + { + "namespace": "captivePortal", + "description": "This API provides the ability detect the captive portal state of the users connection.", + "permissions": ["captivePortal"], + "functions": [ + { + "name": "getState", + "type": "function", + "description": "Returns the current portal state, one of `unknown`, `not_captive`, `unlocked_portal`, `locked_portal`.", + "async": true, + "parameters": [] + }, + { + "name": "getLastChecked", + "type": "function", + "description": "Returns the time difference between NOW and the last time a request was completed in milliseconds.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onStateChanged", + "type": "function", + "description": "Fired when the captive portal state changes.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "state": { + "type": "string", + "enum": ["unknown", "not_captive", "unlocked_portal", "locked_portal"], + "description": "The current captive portal state." + } + } + } + ] + }, + { + "name": "onConnectivityAvailable", + "type": "function", + "description": "This notification will be emitted when the captive portal service has determined that we can connect to the internet. The service will pass either `captive` if there is an unlocked captive portal present, or `clear` if no captive portal was detected.", + "parameters": [ + { + "name": "status", + "enum": ["captive", "clear"], + "type": "string" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/toolkit/components/extensions/schemas/jar.mn b/toolkit/components/extensions/schemas/jar.mn index 5cbf40ea3609..5df22c1b2b3a 100644 --- a/toolkit/components/extensions/schemas/jar.mn +++ b/toolkit/components/extensions/schemas/jar.mn @@ -6,6 +6,9 @@ toolkit.jar: % content extensions %content/extensions/ content/extensions/schemas/alarms.json content/extensions/schemas/browser_settings.json +#ifndef ANDROID + content/extensions/schemas/captive_portal.json +#endif content/extensions/schemas/clipboard.json content/extensions/schemas/content_scripts.json content/extensions/schemas/contextual_identities.json diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js new file mode 100644 index 000000000000..40c9446b4157 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js @@ -0,0 +1,98 @@ +"use strict"; + +/** + * This duplicates the test from netwerk/test/unit/test_captive_portal_service.js + * however using an extension to gather the captive portal information. + */ + +PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/); + +const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled"; +const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode"; +const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval"; +const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL"; +const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost"; + +const SUCCESS_STRING = "success\n"; +let cpResponse = SUCCESS_STRING; + +const httpserver = createHttpServer(); +httpserver.registerPathHandler("/captive.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.write(cpResponse); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED); + Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE); + Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT); + Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME); + Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST); +}); + +add_task(function setup() { + Services.prefs.setCharPref(PREF_CAPTIVE_ENDPOINT, `http://localhost:${httpserver.identity.primaryPort}/captive.txt`); + Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true); + Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 0); + Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true); +}); + +add_task(async function test_captivePortal_basic() { + let cps = Cc["@mozilla.org/network/captive-portal-service;1"] + .getService(Ci.nsICaptivePortalService); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["captivePortal"], + }, + isPrivileged: true, + async background() { + browser.captivePortal.onConnectivityAvailable.addListener(details => { + browser.test.log(`onConnectivityAvailable received ${JSON.stringify(details)}`); + browser.test.sendMessage("connectivity", details); + }); + + browser.captivePortal.onStateChanged.addListener(details => { + browser.test.log(`onStateChanged received ${JSON.stringify(details)}`); + browser.test.sendMessage("state", details); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg == "getstate") { + browser.test.sendMessage("getstate", await browser.captivePortal.getState()); + } + }); + browser.test.assertEq("unknown", await browser.captivePortal.getState(), "initial state unknown"); + }, + }); + await extension.startup(); + + // The captive portal service is started by nsIOService when the pref becomes true, so we + // toggle the pref. We cannot set to false before the extension loads above. + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + + let details = await extension.awaitMessage("connectivity"); + equal(details.status, "clear", "initial connectivity"); + extension.sendMessage("getstate"); + details = await extension.awaitMessage("getstate"); + equal(details, "not_captive", "initial state"); + + info("REFRESH to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("REFRESH to success"); + cpResponse = SUCCESS_STRING; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("connectivity"); + equal(details.status, "captive", "final connectivity"); + + details = await extension.awaitMessage("state"); + equal(details.state, "unlocked_portal", "state after unlocking portal"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js index c612fe55b14b..f93f7b1ccaf3 100644 --- a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js @@ -429,6 +429,7 @@ add_task(async function test_alreadyGranted() { const GRANTED_WITHOUT_USER_PROMPT = [ "activeTab", "alarms", + "captivePortal", "contextMenus", "contextualIdentities", "cookies", diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini index 4450b2555511..418058a512f0 100644 --- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini @@ -21,6 +21,10 @@ skip-if = os == "android" [test_ext_browserSettings.js] [test_ext_browserSettings_homepage.js] skip-if = appname == "thunderbird" || os == "android" +[test_ext_captivePortal.js] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = os == "android" # CP service is disabled on Android +run-sequentially = node server exceptions dont replay well [test_ext_cookieBehaviors.js] [test_ext_cookies_samesite.js] [test_ext_content_security_policy.js]