From a323036995df7cc37517ed4ca41298d1bc88cf09 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:11:02 +0200 Subject: [PATCH] gh-15266: Add support for sharing spaces, folders and split views (gh-15267) --- crowdin.yml | 2 + locales/en-US/browser/browser/zen-share.ftl | 45 + prefs/zen/share.yaml | 15 +- .../base/content/zen-assets.jar.inc.mn | 1 + .../base/content/zen-commands.inc.xhtml | 1 + .../base/content/zen-locales.inc.xhtml | 1 + .../base/content/zen-panels/popups.inc | 2 + src/zen/common/ZenPreloadedScripts.js | 1 + .../common/styles/zen-browser-container.css | 3 +- src/zen/common/styles/zen-buttons.css | 49 ++ src/zen/common/zen-sets.js | 3 + src/zen/folders/ZenFolders.mjs | 3 + src/zen/folders/jar.inc.mn | 2 +- src/zen/folders/zen-folder-icon.inc.css | 19 + src/zen/folders/zen-folders.css | 26 +- src/zen/glance/ZenGlanceManager.mjs | 2 +- src/zen/share/ZenShareClient.sys.mjs | 238 +++++ src/zen/share/ZenShareManager.mjs | 832 ++++++++++++++++++ src/zen/share/jar.inc.mn | 7 + src/zen/share/moz.build | 2 + src/zen/share/share.schema.json | 25 +- src/zen/share/zen-share-confirm.inc.xhtml | 61 ++ src/zen/share/zen-share-confirm.js | 27 + src/zen/share/zen-share.css | 259 ++++++ src/zen/spaces/ZenSpaceManager.mjs | 6 + src/zen/spaces/zen-workspaces.css | 1 - src/zen/sync/ZenSpacesSync.sys.mjs | 2 +- src/zen/sync/ZenSpacesSyncModel.sys.mjs | 120 ++- src/zen/welcome/ZenWelcome.mjs | 4 +- src/zen/welcome/zen-welcome.css | 42 +- src/zen/zen.globals.mjs | 2 + 31 files changed, 1673 insertions(+), 130 deletions(-) create mode 100644 locales/en-US/browser/browser/zen-share.ftl create mode 100644 src/zen/folders/zen-folder-icon.inc.css create mode 100644 src/zen/share/ZenShareClient.sys.mjs create mode 100644 src/zen/share/ZenShareManager.mjs create mode 100644 src/zen/share/jar.inc.mn create mode 100644 src/zen/share/zen-share-confirm.inc.xhtml create mode 100644 src/zen/share/zen-share-confirm.js create mode 100644 src/zen/share/zen-share.css diff --git a/crowdin.yml b/crowdin.yml index 2fd87a2fc..dfb77e78b 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -26,3 +26,5 @@ files: translation: browser/browser/zen-space-routing.ftl - source: en-US/browser/browser/zen-command-palette.ftl translation: browser/browser/zen-command-palette.ftl + - source: en-US/browser/browser/zen-share.ftl + translation: browser/browser/zen-share.ftl diff --git a/locales/en-US/browser/browser/zen-share.ftl b/locales/en-US/browser/browser/zen-share.ftl new file mode 100644 index 000000000..5df7e87c6 --- /dev/null +++ b/locales/en-US/browser/browser/zen-share.ftl @@ -0,0 +1,45 @@ +# 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/. + +zen-share-space = + .label = Share Space… +zen-share-folder = + .label = Share Folder… +zen-share-split-view = + .label = Share Split View… + +zen-share-link-copied-toast = Share link copied! +zen-share-link-expires-description = Anyone with the link can open it until { $date } +zen-share-link-permanent-description = Anyone with the link can open it + +zen-share-error-toast = Couldn't create a share link +zen-share-error-auth-description = The share server rejected your key +zen-share-error-too-large-description = This share is too big to upload +zen-share-error-rate-limited-description = You're sharing too fast, try again in a minute +zen-share-error-network-description = Couldn't reach the share server +zen-share-error-empty-description = There's nothing shareable here +zen-share-error-server-description = The share server returned an error + +zen-share-overlay-loading = Loading shared tabs… +zen-share-overlay-from-space = A Space from { $name } +zen-share-overlay-from-folder = A Folder from { $name } +zen-share-overlay-shared-with-you = Shared with you +zen-share-overlay-add-space = Add Space to Zen +zen-share-overlay-add-folder = Add Folder to Zen +zen-share-imported-toast = Added to your browser! +zen-share-import-error-toast = Couldn't import this share +zen-share-import-error-dead-description = The link has expired or doesn't exist +zen-share-import-error-invalid-description = The shared data is invalid + +zen-share-confirm-window = + .title = Share with your buds +zen-share-confirm-title = Share with your buds +zen-share-confirm-dialog = + .buttonlabelaccept = Share +zen-share-confirm-description = Zen uploads a copy of these tabs to the share server and copies a link anyone can open. The link expires after 30 days. +zen-share-confirm-anonymous = Sharing is anonymous unless you add a name. +zen-share-confirm-name-input = + .placeholder = Your name (optional) +zen-share-confirm-dont-ask = + .label = Don't ask me again diff --git a/prefs/zen/share.yaml b/prefs/zen/share.yaml index d4cdd1a20..a9853c5c1 100644 --- a/prefs/zen/share.yaml +++ b/prefs/zen/share.yaml @@ -2,5 +2,18 @@ # 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/. -- name: zen.share.enabled +- name: zen.share.base-url + value: "https://share.zen-browser.app" + +- name: zen.share.secret-key + value: "" + +- name: zen.share.display-name + value: "" + +# Allow zen.share.display-name to be synced across devices. +- name: services.sync.prefs.sync.zen.share.display-name value: true + +- name: zen.share.dont-ask-before-sharing + value: false diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn index 07d61a897..a663368a2 100644 --- a/src/browser/base/content/zen-assets.jar.inc.mn +++ b/src/browser/base/content/zen-assets.jar.inc.mn @@ -21,3 +21,4 @@ #include ../../../zen/boosts/jar.inc.mn #include ../../../zen/live-folders/jar.inc.mn #include ../../../zen/space-routing/jar.inc.mn +#include ../../../zen/share/jar.inc.mn diff --git a/src/browser/base/content/zen-commands.inc.xhtml b/src/browser/base/content/zen-commands.inc.xhtml index 3019feaca..4205420cc 100644 --- a/src/browser/base/content/zen-commands.inc.xhtml +++ b/src/browser/base/content/zen-commands.inc.xhtml @@ -40,6 +40,7 @@ + diff --git a/src/browser/base/content/zen-locales.inc.xhtml b/src/browser/base/content/zen-locales.inc.xhtml index 2b0ec6692..fca9b29ea 100644 --- a/src/browser/base/content/zen-locales.inc.xhtml +++ b/src/browser/base/content/zen-locales.inc.xhtml @@ -12,4 +12,5 @@ + diff --git a/src/browser/base/content/zen-panels/popups.inc b/src/browser/base/content/zen-panels/popups.inc index 2ff92383d..2fff39cca 100644 --- a/src/browser/base/content/zen-panels/popups.inc +++ b/src/browser/base/content/zen-panels/popups.inc @@ -32,6 +32,7 @@ + @@ -55,6 +56,7 @@ + diff --git a/src/zen/common/ZenPreloadedScripts.js b/src/zen/common/ZenPreloadedScripts.js index e8128603a..652683803 100644 --- a/src/zen/common/ZenPreloadedScripts.js +++ b/src/zen/common/ZenPreloadedScripts.js @@ -28,6 +28,7 @@ "chrome://browser/content/zen-components/ZenEmojiPicker.mjs", "chrome://browser/content/zen-components/ZenLiveFoldersUI.mjs", "chrome://browser/content/zen-components/ZenDownloadAnimation.mjs", + "resource:///modules/zen/share/ZenShareManager.mjs", ]; for (let script of scripts) { diff --git a/src/zen/common/styles/zen-browser-container.css b/src/zen/common/styles/zen-browser-container.css index 395a97c2b..e07ed229a 100644 --- a/src/zen/common/styles/zen-browser-container.css +++ b/src/zen/common/styles/zen-browser-container.css @@ -5,6 +5,7 @@ */ :root:not([inDOMFullscreen="true"]):not([chromehidden~="location"]):not([chromehidden~="toolbar"]) { & #tabbrowser-tabbox #tabbrowser-tabpanels .browserSidebarContainer { + --zen-transparent-browser-background: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.1)); overflow: clip; :root:not([zen-no-padding="true"]) &:not(.zen-glance-overlay) { @@ -18,7 +19,7 @@ } &[transparent="true"] { - background: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.1)); + background: var(--zen-transparent-browser-background); } } diff --git a/src/zen/common/styles/zen-buttons.css b/src/zen/common/styles/zen-buttons.css index 0f6bbae5c..5e8700a00 100644 --- a/src/zen/common/styles/zen-buttons.css +++ b/src/zen/common/styles/zen-buttons.css @@ -78,3 +78,52 @@ dialog::part(dialog-button) { scale: 0.98; } } + +:root { + --zen-accent-button-color: light-dark( + oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h), + oklch(from var(--zen-primary-color) clamp(0.7, l, 0.85) c h) + ); + --zen-accent-button-background: light-dark( + oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h), + oklch(from var(--zen-primary-color) clamp(0.55, l, 0.7) c h) + ); +} + +.zen-big-accent-button { + appearance: none; + border: none; + border-radius: 12px; + padding: 1.2rem 1rem; + background: transparent; + color: var(--zen-accent-button-color); + font: inherit; + font-size: 1.2rem; + font-weight: 600; + cursor: pointer; + will-change: transform; + transition: scale 0.15s ease-out; + + &:hover { + scale: 1.03; + filter: brightness(1.1); + } + + &:active:hover { + scale: 1; + color: color-mix(in srgb, var(--zen-accent-button-color) 80%, transparent); + } + + &.primary { + background: var(--zen-accent-button-background); + color: white; + + &:hover { + filter: brightness(1.1); + } + + &:active:hover { + color: rgba(255, 255, 255, 0.8); + } + } +} diff --git a/src/zen/common/zen-sets.js b/src/zen/common/zen-sets.js index a1b663436..34a57d79d 100644 --- a/src/zen/common/zen-sets.js +++ b/src/zen/common/zen-sets.js @@ -90,6 +90,9 @@ document.addEventListener( case "cmd_zenCtxDeleteWorkspace": gZenWorkspaces.contextDeleteWorkspace(event); break; + case "cmd_zenCtxShareWorkspace": + gZenWorkspaces.contextShareWorkspace(); + break; case "cmd_zenChangeWorkspaceName": gZenVerticalTabsManager.renameTabStart({ target: gZenWorkspaces.activeWorkspaceIndicator.querySelector( diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs index 40078fc9f..1c27e5c6f 100644 --- a/src/zen/folders/ZenFolders.mjs +++ b/src/zen/folders/ZenFolders.mjs @@ -183,6 +183,9 @@ class nsZenFolders extends nsZenDOMOperatedFeature { case "context_zenFolderChangeIcon": this.changeFolderUserIcon(this.#lastFolderContextMenu); break; + case "context_zenShareFolder": + gZenShareManager.shareFolder(this.#lastFolderContextMenu); + break; } }); } diff --git a/src/zen/folders/jar.inc.mn b/src/zen/folders/jar.inc.mn index 091b8bf6d..1a0767344 100644 --- a/src/zen/folders/jar.inc.mn +++ b/src/zen/folders/jar.inc.mn @@ -4,4 +4,4 @@ content/browser/zen-components/ZenFolder.mjs (../../zen/folders/ZenFolder.mjs) content/browser/zen-components/ZenFolders.mjs (../../zen/folders/ZenFolders.mjs) - content/browser/zen-styles/zen-folders.css (../../zen/folders/zen-folders.css) +* content/browser/zen-styles/zen-folders.css (../../zen/folders/zen-folders.css) diff --git a/src/zen/folders/zen-folder-icon.inc.css b/src/zen/folders/zen-folder-icon.inc.css new file mode 100644 index 000000000..8bd922e14 --- /dev/null +++ b/src/zen/folders/zen-folder-icon.inc.css @@ -0,0 +1,19 @@ +/* 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/. */ + +& g, +& rect, +& path { + transition: + transform 0.3s cubic-bezier(0.42, 0, 0, 1), + opacity 0.3s cubic-bezier(0.42, 0, 0, 1); +} + +&[state="open"] .back { + transform: skewX(16deg) translate(-4px, 2px) scale(0.85); +} + +&[state="open"] :is(.front, .dots, .icon) { + transform: skewX(-16deg) translate(8px, 2px) scale(0.85); +} diff --git a/src/zen/folders/zen-folders.css b/src/zen/folders/zen-folders.css index a413d3445..5cb7f2b1e 100644 --- a/src/zen/folders/zen-folders.css +++ b/src/zen/folders/zen-folders.css @@ -8,15 +8,17 @@ display: none !important; } +:root { + --zen-folder-behind-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color) 60%, gray), color-mix(in srgb, var(--zen-primary-color) 60%, #c1c1c1)); + --zen-folder-front-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color), white 70%), color-mix(in srgb, var(--zen-primary-color), black 40%)); + --zen-folder-stroke: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb)); +} + zen-folder { display: flex; flex-direction: column; visibility: visible; - --zen-folder-behind-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color) 60%, gray), color-mix(in srgb, var(--zen-primary-color) 60%, #c1c1c1)); - --zen-folder-front-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color), white 70%), color-mix(in srgb, var(--zen-primary-color), black 40%)); - --zen-folder-stroke: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb)); - -moz-window-dragging: no-drag; transition: var(--zen-tabbox-element-indent-transition); @@ -114,21 +116,7 @@ zen-folder { transform: translate(8.5px, 10.5px); } - & g, - & rect, - & path { - transition: - transform 0.3s cubic-bezier(0.42, 0, 0, 1), - opacity 0.3s cubic-bezier(0.42, 0, 0, 1); - } - - &[state="open"] .back { - transform: skewX(16deg) translate(-4px, 2px) scale(0.85); - } - - &[state="open"] :is(.front, .dots, .icon) { - transform: skewX(-16deg) translate(8px, 2px) scale(0.85); - } +%include zen-folder-icon.inc.css & .icon { opacity: 1; diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs index 4db6e72fd..4742604f4 100644 --- a/src/zen/glance/ZenGlanceManager.mjs +++ b/src/zen/glance/ZenGlanceManager.mjs @@ -392,7 +392,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { return Promise.resolve(this.#currentTab); } - // Existing-tab glances perform no navigation and are exempt; for fresh + // Existing-tab glances perform no navigation and are exempt. For fresh // loads, refuse any URL the triggering principal isn't allowed to load // (e.g. a web page linking to file://). if (!existingTab && !this.#isGlanceLoadAllowed(data)) { diff --git a/src/zen/share/ZenShareClient.sys.mjs b/src/zen/share/ZenShareClient.sys.mjs new file mode 100644 index 000000000..53951bdae --- /dev/null +++ b/src/zen/share/ZenShareClient.sys.mjs @@ -0,0 +1,238 @@ +// 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gBaseUrl", + "zen.share.base-url", + "", + null, + value => value.trim().replace(/\/+$/, "") +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gSecretKey", + "zen.share.secret-key", + "" +); + +const SCHEMA_URL = "resource:///modules/zen/share/share.schema.json"; + +// The server rejects bigger bodies at 25 MiB. +const MAX_SHARE_BYTES = 25 * 1024 * 1024; +const MAX_NAME_LENGTH = 200; + +// Uploads can be tens of MiB, reads are small JSON. +const CREATE_TIMEOUT_MS = 120000; +const READ_TIMEOUT_MS = 30000; + +// Public share page paths: /{slug}/{XXXX-XXXX-XXXX-XXXX id}. +const SHARE_PATH_RE = + /^\/(space|folder|split-view|split)\/([0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4})\/?$/; + +export class ZenShareError extends Error { + /** + * @param {string} code - One of "invalid-document", "auth", "too-large", + * "rate-limited", "not-found", "network", "server". + * @param {string} message + */ + constructor(code, message) { + super(message); + this.name = "ZenShareError"; + this.code = code; + } +} + +class nsZenShareClient { + #validatorPromise = null; + + get #baseUrl() { + return lazy.gBaseUrl; + } + + #authHeaders() { + // A secret key authorizes on its own and makes the share permanent. Never + // send both keys. + if (lazy.gSecretKey) { + return { "x-secret-key": lazy.gSecretKey }; + } + return { "x-api-key": AppConstants.MOZ_MOZILLA_API_KEY }; + } + + #getValidator() { + if (!this.#validatorPromise) { + this.#validatorPromise = fetch(SCHEMA_URL, { credentials: "omit" }) + .then(response => response.json()) + .then(schema => new lazy.JsonSchema.Validator(schema)); + } + return this.#validatorPromise; + } + + /** + * Validates a share document against the schema. + * + * @param {object} doc + * @returns {Promise<{valid: boolean, errors: object[]}>} + */ + async validateDocument(doc) { + const validator = await this.#getValidator(); + return validator.validate(doc); + } + + async #assertValidDocument(doc) { + const result = await this.validateDocument(doc); + if (!result.valid) { + console.error("ZenShare: document fails the schema", result.errors); + throw new ZenShareError( + "invalid-document", + "share document fails the schema" + ); + } + } + + async #request(url, options, timeoutMs = READ_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = lazy.setTimeout(() => controller.abort(), timeoutMs); + let response; + try { + response = await fetch(url, { + credentials: "omit", + signal: controller.signal, + ...options, + }); + } catch (e) { + throw new ZenShareError( + "network", + `could not reach ${url}: ${e.message}` + ); + } finally { + lazy.clearTimeout(timer); + } + if (response.ok) { + return response; + } + let message = `${response.status}`; + try { + message = (await response.json()).error || message; + } catch (e) {} + switch (response.status) { + case 401: + case 403: + throw new ZenShareError("auth", message); + case 404: + throw new ZenShareError("not-found", message); + case 413: + throw new ZenShareError("too-large", message); + case 429: + throw new ZenShareError("rate-limited", message); + case 400: + case 422: + throw new ZenShareError("invalid-document", message); + default: + throw new ZenShareError("server", message); + } + } + + /** + * Uploads a share document. + * + * @param {object} doc - A document conforming to share.schema.json. + * @param {{name?: string}} options - Optional sharer display name, shown on + * the share page as "A Space from {name}". + * @returns {Promise} The create response, plus `link`: the absolute + * public page URL to hand to the user. + */ + async createShare(doc, { name } = {}) { + const base = this.#baseUrl; + const headers = this.#authHeaders(); + await this.#assertValidDocument(doc); + const body = JSON.stringify(doc); + if (new TextEncoder().encode(body).length > MAX_SHARE_BYTES) { + throw new ZenShareError("too-large", "share document is over 25 MiB"); + } + let url = `${base}/api/shares`; + if (name) { + url += `?name=${encodeURIComponent(name.slice(0, MAX_NAME_LENGTH))}`; + } + const response = await this.#request( + url, + { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body, + }, + CREATE_TIMEOUT_MS + ); + const created = await response.json(); + return { ...created, link: base + created.webUrl }; + } + + /** + * Recognizes public share page URLs on the configured server. + * + * @param {string} spec + * @returns {?{type: string, id: string}} null when the URL is not a share + * page (or no server is configured). + */ + parseShareUrl(spec) { + let uri; + let baseUri; + try { + uri = Services.io.newURI(spec); + baseUri = Services.io.newURI(this.#baseUrl); + } catch (e) { + return null; + } + if (uri.prePath !== baseUri.prePath) { + return null; + } + const match = uri.filePath.match(SHARE_PATH_RE); + if (!match) { + return null; + } + const slug = match[1]; + const type = slug === "split" ? "split-view" : slug; + return { type, id: match[2].toUpperCase() }; + } + + /** + * Fetches everything needed to preview and import a share: the validated + * document, plus the sharer's display name. + * + * @param {{id: string}} share - As returned by parseShareUrl. + * @returns {Promise<{doc: object, name: ?string}>} + */ + async fetchSharePreview(share) { + const meta = await this.getShare(share.id); + await this.#assertValidDocument(meta.data); + return { doc: meta.data, name: meta.name || null }; + } + + /** + * Fetches a share with its metadata (authed): + * { id, name, createdAt, expiresAt, size, data }. + * + * @param {string} id + */ + async getShare(id) { + const response = await this.#request(`${this.#baseUrl}/api/shares/${id}`, { + headers: this.#authHeaders(), + }); + return response.json(); + } +} + +export const ZenShareClient = new nsZenShareClient(); diff --git a/src/zen/share/ZenShareManager.mjs b/src/zen/share/ZenShareManager.mjs new file mode 100644 index 000000000..3a8f9a76d --- /dev/null +++ b/src/zen/share/ZenShareManager.mjs @@ -0,0 +1,832 @@ +// 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 { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs"; +import { nsZenThemePicker } from "resource:///modules/zen/ZenGradientGenerator.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", + ZenShareClient: "resource:///modules/zen/share/ZenShareClient.sys.mjs", + ZenShareError: "resource:///modules/zen/share/ZenShareClient.sys.mjs", +}); + +const SHARE_DOCUMENT_VERSION = "1"; + +const SHAREABLE_ICON_RE = /^(data:|chrome:)/; +const FOLDER_ICON_RE = /^chrome:\/\//; + +/** + * Shares spaces, folders and split views through the share server, and + * imports them back from share links. + */ +class nsZenShareManager extends nsZenDOMOperatedFeature { + init() { + this.#insertSplitViewMenuItem(); + delayedStartupPromise.then(() => { + gBrowser.addTabsProgressListener({ + onLocationChange: (browser, webProgress, request, aLocation) => { + this.#onLocationChange(browser, webProgress, aLocation); + }, + }); + }); + } + + // Mark: sharing + + async shareSpace(workspaceId) { + const workspace = gZenWorkspaces.getWorkspaceFromId(workspaceId); + const element = gZenWorkspaces.workspaceElement(workspaceId); + if (!workspace || !element) { + return; + } + const item = { + type: "space", + name: workspace.name || "Space", + items: [ + ...this.#serializeItems(element.pinnedTabsContainer.children, { + pinned: true, + }), + ...this.#serializeItems(element.tabsContainer.children, { + pinned: false, + }), + ], + }; + const theme = this.#serializeTheme(workspace.theme); + if (theme) { + item.theme = theme; + } + await this.#createAndCopyLink(item); + } + + async shareFolder(folder) { + if (!folder?.isZenFolder) { + return; + } + await this.#createAndCopyLink(this.#serializeFolder(folder)); + } + + async shareSplitView(group) { + if (!group?.hasAttribute("split-view-group")) { + return; + } + const item = this.#serializeSplitGroup(group); + if (!item) { + this.#showCreateError( + new lazy.ZenShareError("empty", "nothing to share") + ); + return; + } + await this.#createAndCopyLink(item); + } + + #serializeSplitGroup(group) { + const tabs = group.tabs + .filter(tab => !tab.hasAttribute("zen-empty-tab")) + .map(tab => this.#serializeTab(tab, { pinned: tab.pinned })) + .filter(Boolean); + return tabs.length >= 2 + ? { type: "splitView", tabs: tabs.slice(0, gZenViewSplitter.MAX_TABS) } + : null; + } + + async #createAndCopyLink(item) { + if (!(await this.#confirmShare())) { + return; + } + try { + const created = await lazy.ZenShareClient.createShare( + { version: SHARE_DOCUMENT_VERSION, shared: [item] }, + { name: this.#displayName() } + ); + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(created.link); + // The description's l10n args come from the whole options object. + gZenUIManager.showToast("zen-share-link-copied-toast", { + descriptionId: created.expiresAt + ? "zen-share-link-expires-description" + : "zen-share-link-permanent-description", + date: created.expiresAt + ? new Date(created.expiresAt).toLocaleDateString() + : "", + timeout: 6000, + }); + } catch (e) { + this.#showCreateError(e); + } + } + + #displayName() { + return ( + Services.prefs.getStringPref("zen.share.display-name", "") || undefined + ); + } + + async #confirmShare() { + if ( + Services.prefs.getBoolPref("zen.share.dont-ask-before-sharing", false) + ) { + return true; + } + const args = { + accepted: false, + name: Services.prefs.getStringPref("zen.share.display-name", ""), + }; + await gDialogBox.open( + "chrome://browser/content/zen-components/windows/zen-share-confirm.xhtml", + args + ); + if (args.accepted) { + Services.prefs.setStringPref("zen.share.display-name", args.name); + if (args.dontAsk) { + Services.prefs.setBoolPref("zen.share.dont-ask-before-sharing", true); + } + } + return args.accepted; + } + + #showCreateError(error) { + console.error("ZenShare: could not create share:", error); + const descriptions = { + auth: "zen-share-error-auth-description", + "too-large": "zen-share-error-too-large-description", + "rate-limited": "zen-share-error-rate-limited-description", + network: "zen-share-error-network-description", + empty: "zen-share-error-empty-description", + }; + gZenUIManager.showToast("zen-share-error-toast", { + descriptionId: + descriptions[error.code] || "zen-share-error-server-description", + timeout: 6000, + }); + } + + // Mark: serialization + + #serializeItems(children, { pinned }) { + const items = []; + for (const child of children) { + if (gBrowser.isTab(child)) { + if ( + child.hasAttribute("zen-empty-tab") || + child.hasAttribute("zen-glance-tab") + ) { + continue; + } + const tab = this.#serializeTab(child, { pinned }); + if (tab) { + items.push(tab); + } + } else if (child.isZenFolder) { + items.push(this.#serializeFolder(child)); + } else if (gBrowser.isTabGroup(child)) { + const split = child.hasAttribute("split-view-group") + ? this.#serializeSplitGroup(child) + : null; + if (split) { + items.push(split); + continue; + } + // Plain tab groups (and degenerate splits) keep their member tabs. + for (const tab of child.tabs) { + if (tab.hasAttribute("zen-empty-tab")) { + continue; + } + const serialized = this.#serializeTab(tab, { pinned }); + if (serialized) { + items.push(serialized); + } + } + } + } + return items; + } + + #serializeFolder(folder) { + const item = { + type: "folder", + name: folder.label || "Folder", + items: this.#serializeItems(folder.allItems, { pinned: false }), + }; + const icon = folder.iconURL; + if (icon) { + item.icon = icon; + } + return item; + } + + #serializeTab(tab, { pinned = false } = {}) { + const url = tab.linkedBrowser?.currentURI?.spec; + if (!url || !/^https?:\/\//.test(url)) { + return null; + } + const item = { type: "tab", url }; + const label = tab.label; + if (label) { + item.label = label; + } + const image = this.#serializeIcon(tab); + if (image) { + item.image = image; + } + if (pinned) { + item.isPinned = true; + } + return item; + } + + #serializeIcon(tab) { + let icon = tab.getAttribute("image") || ""; + if (icon.startsWith("moz-remote-image:")) { + try { + icon = + new URLSearchParams(Services.io.newURI(icon).query).get("url") || ""; + } catch (e) { + return ""; + } + } + return SHAREABLE_ICON_RE.test(icon) ? icon : ""; + } + + #serializeTheme(theme) { + if (theme?.type !== "gradient" || !Array.isArray(theme.gradientColors)) { + return null; + } + const gradientColors = theme.gradientColors + .map(color => this.#serializeGradientColor(color)) + .filter(Boolean); + if (!gradientColors.length) { + return null; + } + return { type: "gradient", gradientColors }; + } + + #serializeGradientColor(color) { + let c = color?.c; + if (typeof c === "string") { + c = this.#parseCssColor(c); + } else if (Array.isArray(c)) { + c = c.slice(0, 3).map(Number); + } + if ( + !Array.isArray(c) || + c.length !== 3 || + c.some(n => !Number.isFinite(n)) + ) { + return null; + } + const out = { c, type: typeof color.type === "string" ? color.type : "" }; + if (typeof color.isCustom === "boolean") { + out.isCustom = color.isCustom; + } + if (typeof color.isPrimary === "boolean") { + out.isPrimary = color.isPrimary; + } + if (typeof color.algorithm === "string") { + out.algorithm = color.algorithm; + } + if (color.lightness !== undefined && color.lightness !== null) { + out.lightness = String(color.lightness); + } + if ( + Number.isFinite(color.position?.x) && + Number.isFinite(color.position?.y) + ) { + out.position = { x: color.position.x, y: color.position.y }; + } + return out; + } + + #parseCssColor(value) { + const hex = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; + if (hex) { + const full = + hex.length === 3 ? [...hex].map(ch => ch + ch).join("") : hex; + return [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16)); + } + const numbers = value + .match(/\d+(\.\d+)?/g) + ?.slice(0, 3) + .map(Number); + return numbers?.length === 3 ? numbers : null; + } + + // Mark: importing + + #stylesLoaded = false; + + // The overlay styles are only needed once a share link is opened. + #ensureStyles() { + if (this.#stylesLoaded) { + return; + } + this.#stylesLoaded = true; + window.windowUtils.loadSheetUsingURIString( + "chrome://browser/content/zen-styles/zen-share.css", + window.windowUtils.AUTHOR_SHEET + ); + } + + #setHostTabSubtitle(browser, subtitle) { + const tab = gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + tab.setAttribute("zen-show-sublabel", subtitle); + document.l10n.setArgs(tab.querySelector(".zen-tab-sublabel"), { + tabSubtitle: subtitle, + }); + } + + #clearHostTabSubtitle(browser) { + const tab = gBrowser.getTabForBrowser(browser); + if (!tab?.hasAttribute("zen-show-sublabel")) { + return; + } + tab.removeAttribute("zen-show-sublabel"); + document.l10n.setArgs(tab.querySelector(".zen-tab-sublabel"), { + tabSubtitle: "zen-default-pinned", + }); + } + + #onLocationChange(browser, webProgress, aLocation) { + if (!webProgress.isTopLevel) { + return; + } + if (browser._zenShareOverlay) { + browser._zenShareOverlay.remove(); + delete browser._zenShareOverlay; + browser.removeAttribute("zen-share-overlay-showing"); + this.#clearHostTabSubtitle(browser); + } + const share = lazy.ZenShareClient.parseShareUrl(aLocation.spec); + if (!share) { + return; + } + if (share.type === "split-view") { + // Split views are light enough to open right away, no preview step. + this.#openSharedSplitView(browser, share); + } else { + this.#showImportOverlay(browser, share); + } + } + + async #openSharedSplitView(browser, share) { + let item; + const shareTab = gBrowser.getTabForBrowser(browser); + shareTab.style.display = "none"; + const failure = () => { + shareTab.style.removeProperty("display"); + }; + try { + const { doc } = await lazy.ZenShareClient.fetchSharePreview(share); + item = doc.shared.find(entry => entry.type === "splitView"); + } catch (e) { + console.error("ZenShare: could not load shared split view:", e); + const descriptions = { + "not-found": "zen-share-import-error-dead-description", + "invalid-document": "zen-share-import-error-invalid-description", + }; + gZenUIManager.showToast("zen-share-import-error-toast", { + descriptionId: + descriptions[e.code] || "zen-share-error-network-description", + timeout: 6000, + }); + failure(); + return; + } + if (!item) { + failure(); + return; + } + const workspaceId = gZenWorkspaces.activeWorkspace; + const pinned = item.tabs.every(child => child.isPinned); + const tabs = item.tabs.map(child => { + // Eager browsers so every pane renders as soon as the split shows. + const tab = this.#importTab(child, workspaceId, { lazyBrowser: false }); + if (pinned) { + gBrowser.pinTab(tab); + } + this.#placeTab(tab, workspaceId, pinned); + return tab; + }); + gZenViewSplitter.splitTabs(tabs, "grid", 0); + gBrowser.removeTab(shareTab, { closeWindowWithLastTab: false }); + // Shake the new split group in the sidebar. + const group = tabs[0]?.group; + if (group?.hasAttribute("split-view-group")) { + requestAnimationFrame(() => { + gZenUIManager.motion + .animate( + group, + { x: [-14, 0] }, + { type: "spring", bounce: 0.8, duration: 1.2 } + ) + .then(() => { + group.style.removeProperty("transform"); + }); + }); + } + } + + async #showImportOverlay(browser, share) { + const stack = browser.closest(".browserStack"); + if (!stack) { + return; + } + this.#ensureStyles(); + // Only the status text shows until the preview loads. + const overlay = window.MozXULElement.parseXULToFragment(` + + + + +