diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 57b3f42..c8c0260 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -122,5 +122,44 @@ "searchServer": { "message": "Search Server", "description": "Placeholder for the Input field Where users can search the Serverlist" + }, + "titleSubscribeNow": { + "message": "Subscribe to Mozilla VPN" + }, + "bodySubscribeNow": { + "message": "No subscription found. Click the button below to subscribe to Mozilla VPN.", + "description": "Body of an Error Message, shown if the user is trying to use the Extension without a Subscription." + }, + "btnSubscribeNow": { + "message": "Subscribe now", + "description": "Call to Action button for users to subscribe to Mozilla VPN" + }, + "getHelp": { + "message": "Get Help", + "description": "Text of a link pointing to a relevant help page" + }, + "headerSignedOut": { + "message": "Sign in to your Mozilla account" + }, + "bodySignedOut": { + "message": "You’re currently signed out of your Mozilla account. To use the Mozilla VPN extension, please open the desktop app to sign in first." + }, + "btnOpenVpn": { + "message": "Open Mozilla VPN" + }, + "headerInstallMsg": { + "message": "Install Mozilla VPN", + "description": "Header if a VPN installation was not found" + }, + "bodyInstallMsg": { + "message": "In order to use the Mozilla VPN extension, you must first download Mozilla VPN on your desktop." + }, + "bodyInstallMsgFooter": { + "message": "Note that Mozilla VPN is a paid subscription service.", + "description": "This is a footnote for 'bodyInstallMsg' " + }, + "btnDownloadNow": { + "message": "Download now", + "description": "This is a call to action button to download the VPN." } } diff --git a/src/assets/img/message-header.svg b/src/assets/img/message-header.svg new file mode 100644 index 0000000..c2fe861 --- /dev/null +++ b/src/assets/img/message-header.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/message-install.svg b/src/assets/img/message-install.svg new file mode 100644 index 0000000..db932dd --- /dev/null +++ b/src/assets/img/message-install.svg @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/message-signin.svg b/src/assets/img/message-signin.svg new file mode 100644 index 0000000..f00bde7 --- /dev/null +++ b/src/assets/img/message-signin.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/background/vpncontroller/states.js b/src/background/vpncontroller/states.js index 1b0da8f..cc31f9c 100644 --- a/src/background/vpncontroller/states.js +++ b/src/background/vpncontroller/states.js @@ -14,17 +14,25 @@ export const REQUEST_TYPES = [ "disabled_apps", "status", "deactivate", + "focus", + "openAuth", ]; export class VPNState { // Name of the current state state = ""; + // Whether the Native Message adapter exists + installed = true; // If the Native Message adapter is alive alive = false; // True if the VPN is enabled. connected = false; // True if firefox is split-tunneled isExcluded = false; + // True if a subscription is found + subscribed = true; + // True if it is authenticated + authenticated = false; /** * A socks:// url to connect to * to bypass the vpn. @@ -53,17 +61,42 @@ export class VPNState { export class StateVPNUnavailable extends VPNState { state = "Unavailable"; alive = false; + installed = false; +} +export class StateVPNClosed extends VPNState { + state = "Closed"; + alive = false; + installed = true; connected = false; } +/** + * Helper base class to imply the vpn process is installed and + * running + */ +class StateVPNOpened extends VPNState { + alive = true; + installed = true; +} +export class StateVPNSignedOut extends StateVPNOpened { + state = "SignedOut"; + authenticated = false; +} + +export class StateVPNSubscriptionNeeded extends StateVPNSignedOut { + state = "SubscriptionNeeded"; + subscribed = false; + authenticated = true; +} + /** * This state is used if the VPN Client is * alive but the Connection is Disabled */ -export class StateVPNDisabled extends VPNState { +export class StateVPNDisabled extends StateVPNSubscriptionNeeded { state = "Disabled"; - alive = true; connected = false; + subscribed = true; /** * @@ -81,7 +114,7 @@ export class StateVPNDisabled extends VPNState { * This state is used if the VPN Client is * alive but the Connection is Disabled */ -export class StateVPNEnabled extends VPNState { +export class StateVPNEnabled extends StateVPNDisabled { /** * * @param {string|boolean} aloophole - False if loophole is not supported, @@ -89,14 +122,12 @@ export class StateVPNEnabled extends VPNState { * @param {ServerCountry | undefined } exitServerCountry */ constructor(exitServerCity, exitServerCountry, aloophole, connectedSince) { - super(); - this.exitServerCity = exitServerCity; - this.exitServerCountry = exitServerCountry; + super(exitServerCity, exitServerCountry); this.loophole = aloophole; this.connectedSince = connectedSince; } state = "Enabled"; - alive = true; + subscribed = true; connected = true; } diff --git a/src/background/vpncontroller/vpncontroller.js b/src/background/vpncontroller/vpncontroller.js index c52183c..838f83c 100644 --- a/src/background/vpncontroller/vpncontroller.js +++ b/src/background/vpncontroller/vpncontroller.js @@ -18,9 +18,12 @@ import { StateVPNUnavailable, StateVPNEnabled, StateVPNDisabled, + StateVPNSubscriptionNeeded, REQUEST_TYPES, ServerCountry, vpnStatusResponse, + StateVPNClosed, + StateVPNSignedOut, } from "./states.js"; const log = Logger.logger("TabHandler"); @@ -77,16 +80,17 @@ export class VPNController extends Component { // invalid proxy connection. this.#port.onDisconnect.addListener(() => { this.#increaseIsolationKey(); - this.#mState.value = new StateVPNUnavailable(); + this.#mState.value = new StateVPNClosed(); }); } catch (e) { + // If we get an exception here it is super likely the VPN is simply not installed. log(e); this.#mState.value = new StateVPNUnavailable(); } } async init() { - this.#mState.value = new StateVPNUnavailable(); + this.#mState.value = new StateVPNClosed(); this.#mServers.value = await fromStorage( browser.storage.local, MOZILLA_VPN_SERVERS_KEY, @@ -116,13 +120,14 @@ export class VPNController extends Component { log(e); // @ts-ignore if (e.toString() === "Attempt to postMessage on disconnected port") { - this.#mState.value = new StateVPNUnavailable(); + this.#mState.value = new StateVPNClosed(); } } } // Handle responses from MozillaVPN client async handleResponse(response) { + console.log(response); if (!response.t) { // The VPN Client always sends a ".t : string" // to determing the message type. @@ -156,7 +161,7 @@ export class VPNController extends Component { // We can only get 2 types of messages right now: client-down/up if (response.status && response.status === "vpn-client-down") { if (this.#mState.value.alive) { - this.#mState.value = new StateVPNUnavailable(); + this.#mState.value = new StateVPNClosed(); } return; } @@ -275,6 +280,16 @@ export function fromVPNStatusResponse( return; } const status = response.status; + const appState = status.app; + if (["StateInitialize", "StateAuthenticating"].includes(appState)) { + return new StateVPNSignedOut(); + } + + if (appState === "StateSubscriptionNeeded") { + return new StateVPNSubscriptionNeeded(); + } + + // const controllerState = status.vpn; const connectedSince = (() => { if (!status.connectedSince) { diff --git a/src/components/conditional-view.js b/src/components/conditional-view.js new file mode 100644 index 0000000..d35ad42 --- /dev/null +++ b/src/components/conditional-view.js @@ -0,0 +1,49 @@ +/* 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/. */ + +import { html, LitElement } from "../vendor/lit-all.min.js"; + +/** + * `ConditionalView` + * + * Takes N elements each with a slot="" attribute, + * the active rendered view can be controlled using slotName="slot" + * if no view matches "slotName" the slot named "default" will be rendered. + * + * + * + *

Hidden

+ *

This is rendered

+ *
+ */ + +export class ConditionalView extends LitElement { + static properties = { + slotName: { reflect: true }, + }; + constructor() { + super(); + this.slotName = "default"; + } + + hasSlot(slotName) { + return Array.from(this.children).some((e) => { + return e.slot === slotName; + }); + } + getTargetSlot() { + const slot = this.slotName; + if (slot == "") { + return "default"; + } + if (!this.hasSlot(slot)) { + return "default"; + } + return slot; + } + render() { + return html` `; + } +} +customElements.define("conditional-view", ConditionalView); diff --git a/src/components/message-screen.js b/src/components/message-screen.js new file mode 100644 index 0000000..9ed3e1e --- /dev/null +++ b/src/components/message-screen.js @@ -0,0 +1,151 @@ +/* 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/. */ + +import { html, LitElement, when, css } from "../vendor/lit-all.min.js"; +import { fontSizing } from "./styles.js"; +import "./titlebar.js"; + +/** + * MessageScreen + * This is a basic message Screen component. + * Properties: + * - titleHeader -> The header in the Title bar + * - heading -> The H1 Heading + * - img -> The image in /assets/img/ to use + * - primaryAction -> The Primary Call to action Button + * - onPrimaryAction -> A function to call when the primary button is clicked + * - secondaryAction -> The 2ndary button text + * - onSecondaryAction -> A function to call when the 2ndary action is clicked. + */ + +export class MessageScreen extends LitElement { + static properties = { + titleHeader: { type: String }, + heading: { type: String }, + img: { type: String }, + primaryAction: { type: String }, + onPrimaryAction: { type: Function }, + secondaryAction: { type: String }, + onSecondaryAction: { type: Function }, + }; + constructor() { + super(); + this.titleHeader = ""; + this.heading = ""; + this.img = ""; + this.primaryAction = ""; + this.secondarAction = ""; + this.onPrimaryAction = () => {}; + this.onSecondaryAction = () => {}; + } + + render() { + return html` + +
+
+ +

${this.heading}

+ +
+
+ ${when( + this.primaryAction, + () => html` + + ` + )} + ${when( + this.secondaryAction, + () => html` + + ` + )} +
+
+ `; + } + static styles = css` + ${fontSizing} + :host { + width: var(--window-width); + height: var(--window-max-height); + contain: strict; + } + :host, + .inner, + .upper, + .lower { + display: flex; + flex-direction: column; + align-items: center; + } + .inner { + width: 85%; + justify-content: space-between; + height: 100%; + } + * { + width: 100%; + text-align: center; + color: var(--text-color-primary); + font-family: var(--font-family); + } + img { + margin-top: 16px; + margin-bottom: 16px; + width: 180px; + max-height: 80px; + } + h1 { + font-family: var(--font-family-bold); + margin-bottom: 8px; + } + .slot { + padding: 24px 24px; + } + .filler { + flex-grow: 1; + } + ::slotted(p) { + margin: 0; + text-align: center; + font-family: var(--font-family); + font-size: 15px; + font-style: normal; + font-weight: 400; + } + ::slotted(.footnote) { + margin-top: 8px; + margin-bottom: 8px; + } + button { + border: none; + height: 32px; + margin-bottom: 8px; + } + .secondarybtn { + background-color: transparent; + color: var(--action-button-color); + } + .primarybtn { + background-color: var(--action-button-color); + color: white; + border-radius: 4px; + } + `; +} +customElements.define("message-screen", MessageScreen); diff --git a/src/components/prefab-screens.js b/src/components/prefab-screens.js new file mode 100644 index 0000000..7a9380b --- /dev/null +++ b/src/components/prefab-screens.js @@ -0,0 +1,87 @@ +/* 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/. */ + +import { html, render } from "../vendor/lit-all.min.js"; +import { MessageScreen } from "./message-screen.js"; +import { tr } from "../shared/i18n.js"; + +const open = (url) => { + browser.tabs.create({ + url, + }); +}; + +const defineMessageScreen = ( + tag, + img, + heading, + bodyText, + primaryAction, + onPrimaryAction, + secondarAction = tr("getHelp"), + onSecondaryAction = () => + open("https://support.mozilla.org/products/firefox-private-network-vpn") +) => { + const body = + typeof bodyText === "string" ? html`

${bodyText}

` : bodyText; + + class Temp extends MessageScreen { + connectedCallback() { + super.connectedCallback(); + this.titleHeader = tr("productName"); + this.img = img; + this.heading = heading; + this.primaryAction = primaryAction; + this.onPrimaryAction = onPrimaryAction; + this.secondaryAction = secondarAction; + this.onSecondaryAction = onSecondaryAction; + render(body, this); + } + } + customElements.define(tag, Temp); +}; + +const sendToApp = (customElement, command = "") => { + customElement.dispatchEvent( + new CustomEvent("requestMessage", { + bubbles: true, + detail: command, + }) + ); +}; + +defineMessageScreen( + "subcribenow-message-screen", + "message-header.svg", + "Subscribe to Mozilla VPN", + tr("bodySubscribeNow"), + tr("btnSubscribeNow"), + (elm) => sendToApp(elm, "focus") +); + +defineMessageScreen( + "signin-message-screen", + "message-signin.svg", + tr("headerSignedOut"), + tr("bodySignedOut"), + tr("btnOpenVpn"), + (elm) => { + sendToApp(elm, "focus"); + sendToApp(elm, "openAuth"); + } +); + +defineMessageScreen( + "install-message-screen", + "message-signin.svg", + tr("headerInstallMsg"), + html` +

${tr("bodyInstallMsg")}

+

${tr("bodyInstallMsgFooter")}

+ `, + tr("btnDownloadNow"), + () => { + open("https://www.mozilla.org/products/vpn/download/"); + } +); diff --git a/src/ui/browserAction/backend.js b/src/ui/browserAction/backend.js index f4a49b5..2c7a1d0 100644 --- a/src/ui/browserAction/backend.js +++ b/src/ui/browserAction/backend.js @@ -1,5 +1,28 @@ import { getExposedObject } from "../../shared/ipc.js"; +/** + * Import Types + * + * @typedef {import("../../shared/property.js").IBindable} IBindable + * @typedef {import("../../background/vpncontroller/states.js").ServerCountry} ServerCountry + * @typedef {import("../../background/vpncontroller/states.js").ServerCity} ServerCity + * @typedef {import("../../background/vpncontroller/states.js").VPNState} State + * @typedef {Array} ServerCountryList + */ + +/** + * Manually define the types for convinence, please update if making changes :) + * + * @typedef {Object} vpnController + * @property {IBindable} servers - A bindable property that contains the list or configuration of VPN servers. + * @property {IBindable} isExcluded - A bindable property that indicates whether the VPN is excluded from certain operations. + * @property {IBindable} state - A bindable property representing the current state of the VPN controller (e.g., connected, disconnected). + * @property {(String)=>Promise } postToApp - A function that handles posting messages or data from the VPN controller to the application. + * @property {IBindable} isolationKey - A bindable property used to manage and isolate VPN sessions or connections. + */ +/** + * @type {vpnController} + */ export const vpnController = await getExposedObject("VPNController"); export const tabHandler = await getExposedObject("TabHandler"); export const proxyHandler = await getExposedObject("ProxyHandler"); diff --git a/src/ui/browserAction/popup.html b/src/ui/browserAction/popup.html index f9e5e7f..1652830 100644 --- a/src/ui/browserAction/popup.html +++ b/src/ui/browserAction/popup.html @@ -15,9 +15,23 @@ + + + + Mozilla VPN - + + + + + + + + + diff --git a/src/ui/browserAction/popupConditional.js b/src/ui/browserAction/popupConditional.js new file mode 100644 index 0000000..c7905a3 --- /dev/null +++ b/src/ui/browserAction/popupConditional.js @@ -0,0 +1,58 @@ +/* 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/. */ + +import { ConditionalView } from "../../components/conditional-view.js"; +import { vpnController } from "./backend.js"; + +export class PopUpConditionalView extends ConditionalView { + constructor() { + super(); + } + + connectedCallback() { + super.connectedCallback(); + vpnController.state.subscribe((s) => { + this.slotName = PopUpConditionalView.toSlotname(s); + }); + // Messages may dispatch an event requesting to send a Command to the VPN + this.addEventListener("requestMessage", (e) => { + console.log(`Message requested ${e}`); + if (!e.detail) { + return; + } + if (typeof e.detail != "string") { + return; + } + vpnController.postToApp(e.detail); + }); + } + + /** + * @typedef {import("../../background/vpncontroller/states.js").VPNState} State + * @param {State} state + * @returns {String} + */ + static toSlotname(state) { + if (!state.installed) { + return "MessageInstallVPN"; + } + if (!state.alive) { + return "MessageStartVPN"; + } + if (!state.authenticated) { + return "MessageSignIn"; + } + if (!state.subscribed) { + return "MessageSubscription"; + } + /** + * TODO: + * if( did not have onboarding){ + * return "onBoarding" + * } + */ + return "default"; + } +} +customElements.define("popup-condview", PopUpConditionalView); diff --git a/src/ui/browserAction/popupPage.js b/src/ui/browserAction/popupPage.js index 078c2cf..65ee6d1 100644 --- a/src/ui/browserAction/popupPage.js +++ b/src/ui/browserAction/popupPage.js @@ -126,18 +126,6 @@ export class BrowserActionPopup extends LitElement { let title = this.stackView?.value?.currentElement?.dataset?.title; title ??= tr("productName"); - let card = html` - - `; - if (!this.vpnState.alive) { - card = html``; - } - return html` ${canGoBack ? BrowserActionPopup.backBtn(back) : null} @@ -150,7 +138,15 @@ export class BrowserActionPopup extends LitElement {
-
${card} ${this.locationSettings()}
+
+ + ${this.locationSettings()} +
`; diff --git a/tests/jest/background/vpncontroller/vpncontroller.test.mjs b/tests/jest/background/vpncontroller/vpncontroller.test.mjs index 77c7655..9a4c5de 100644 --- a/tests/jest/background/vpncontroller/vpncontroller.test.mjs +++ b/tests/jest/background/vpncontroller/vpncontroller.test.mjs @@ -152,4 +152,25 @@ describe("fromVPNStatusResponse", () => { expect(result.connectedSince).toBe(0); expect(result.state).toBe("Enabled"); }); + + it("It can Handle Subscription needed", () => { + const obj = { + status: { + app: "StateSubscriptionNeeded", + authenticated: true, + location: { + entry_city_name: "", + entry_country_code: "", + exit_city_name: "", + exit_country_code: "", + }, + vpn: "StateInitializing", + }, + t: "status", + }; + const result = fromVPNStatusResponse(obj); + expect(result).not.toBeNull(); + expect(result.state).toBe("SubscriptionNeeded"); + expect(result.subscribed).toBe(false); + }); }); diff --git a/tests/jest/components/conditional-view.test.mjs b/tests/jest/components/conditional-view.test.mjs new file mode 100644 index 0000000..72f3ce1 --- /dev/null +++ b/tests/jest/components/conditional-view.test.mjs @@ -0,0 +1,67 @@ +/** + * @jest-environment jsdom + */ + +/* 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/. */ + +import { describe, expect, test } from "@jest/globals"; +import { + render, + html, + ref, + createRef, +} from "../../../src/vendor/lit-all.min.js"; + +import { ConditionalView } from "../../../src/components/conditional-view.js"; + +describe("ConditionalView", () => { + test("use jsdom in this test file", () => { + const element = document.createElement("div"); + expect(element).not.toBeNull(); + }); + test("can we crate a conditional-view", () => { + const element = document.createElement("conditional-view"); + document.body.append(element); + // Make sure importing the Module registers the custom element + expect(customElements.get("conditional-view")).toBe(ConditionalView); + // Make sure once adopted to the dom it has rendered into a shadowdom + expect(element.shadowRoot).not.toBeNull(); + }); + test("It selects the proper thing", async () => { + const element = document.createElement("conditional-view"); + document.body.append(element); + render( + html` +

this is hidden

+

this is visible

+ `, + element + ); + element.slotName = "visible"; + await element.requestUpdate(); + const slot = element.shadowRoot.querySelector("slot"); + const selectedHeader = slot.assignedNodes()[0]; + + expect(selectedHeader.textContent).toBe("this is visible"); + }); + test("It selects the default slot if none match", async () => { + const element = document.createElement("conditional-view"); + document.body.append(element); + render( + html` +

this is hidden

+

this is visible

+

this is default

+ `, + element + ); + element.slotName = "this-slot-does-not-exist"; + await element.requestUpdate(); + const slot = element.shadowRoot.querySelector("slot"); + const selectedHeader = slot.assignedNodes()[0]; + + expect(selectedHeader.textContent).toBe("this is default"); + }); +});