From 1f02dc7aca9298e406a277e3a12957e4f4dd0ea2 Mon Sep 17 00:00:00 2001 From: DeBaschdi Date: Mon, 23 Feb 2026 04:52:28 +0100 Subject: [PATCH 1/3] Fix calendarItemAction persistence in event editor toolbars Improves calendarItemAction toolbar persistence across restart and upgrade in calendar event editors (dialog + tab). Stop mutating xulStore in onShutdown(). Ensure button placement on ADDON_INSTALL and ADDON_UPGRADE. Cover both toolbars: event-toolbar and event-tab-toolbar. --- .../calendar/parent/ext-calendarItemAction.js | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/calendar/experiments/calendar/parent/ext-calendarItemAction.js b/calendar/experiments/calendar/parent/ext-calendarItemAction.js index 3dcaed2..0e3c7f9 100644 --- a/calendar/experiments/calendar/parent/ext-calendarItemAction.js +++ b/calendar/experiments/calendar/parent/ext-calendarItemAction.js @@ -2,8 +2,6 @@ * 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/. */ -var { ExtensionCommon: { makeWidgetId } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); - var { ExtensionParent } = ChromeUtils.importESModule("resource://gre/modules/ExtensionParent.sys.mjs"); var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/ExtensionSupport.sys.mjs"); var { ToolbarButtonAPI } = ChromeUtils.importESModule("resource:///modules/ExtensionToolbarButtons.sys.mjs"); @@ -57,9 +55,14 @@ this.calendarItemAction = class extends ToolbarButtonAPI { // Core code only works for one toolbox/toolbarId. Calendar uses different ones. When porting // you can leave all of this out by either using the same ids, or adapting parent class code to // deal with ids per window url. - if (this.extension.startupReason == "ADDON_INSTALL") { - // Add it to the messenger window, the other one is already covered by parent code. + if ( + this.extension.startupReason == "ADDON_INSTALL" || + this.extension.startupReason == "ADDON_UPGRADE" + ) { + // Ensure both editor variants have the button in persisted toolbar sets + // on fresh install and profile migrations during add-on upgrade. this.addToCurrentSet("chrome://messenger/content/messenger.xhtml", "event-tab-toolbar"); + this.addToCurrentSet("chrome://calendar/content/calendar-event-dialog.xhtml", "event-toolbar"); } } @@ -141,37 +144,15 @@ this.calendarItemAction = class extends ToolbarButtonAPI { } } - onShutdown() { + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + // TODO browserAction uses static onUninstall, this doesn't work in an experiment. + // Do not mutate xulStore during shutdown to preserve user toolbar customizations on upgrades. const extensionId = this.extension.id; ExtensionSupport.unregisterWindowListener("ext-calendar-itemAction-" + extensionId); - - const widgetId = makeWidgetId(extensionId); - const id = `${widgetId}-calendarItemAction-toolbarbutton`; - - const windowURLs = [ - "chrome://messenger/content/messenger.xhtml", - "chrome://calendar/content/calendar-event-dialog.xhtml" - ]; - - for (const windowURL of windowURLs) { - let currentSet = Services.xulStore.getValue( - windowURL, - "event-toolbar", - "currentset" - ); - currentSet = currentSet.split(","); - const index = currentSet.indexOf(id); - if (index >= 0) { - currentSet.splice(index, 1); - Services.xulStore.setValue( - windowURL, - "event-toolbar", - "currentset", - currentSet.join(",") - ); - } - } } }; From 87e856f795da116d8b7c7920ca41179f983ec39c Mon Sep 17 00:00:00 2001 From: DeBaschdi Date: Mon, 23 Feb 2026 04:56:18 +0100 Subject: [PATCH 2/3] calendar: provide deterministic editor context for item action clicks and editor item handling This proposal improves editor-context handling for calendar add-ons in dialog and tab editors: - include editorRef/editorType in calendarItemAction.onClicked info - support deterministic editor-targeted item access/update in calendar.items APIs - expose editor close lifecycle signaling for persisted/discarded/superseded flows Goal: enable add-ons to work with open editors (including unsaved items) without custom experiments. --- .../calendar/parent/ext-calendar-items.js | 610 +++++++++++++++++- .../calendar/parent/ext-calendarItemAction.js | 79 ++- .../calendar/schema/calendar-items.json | 69 ++ .../calendar/schema/calendarItemAction.json | 34 +- 4 files changed, 783 insertions(+), 9 deletions(-) diff --git a/calendar/experiments/calendar/parent/ext-calendar-items.js b/calendar/experiments/calendar/parent/ext-calendar-items.js index f36b5b0..ac762b8 100644 --- a/calendar/experiments/calendar/parent/ext-calendar-items.js +++ b/calendar/experiments/calendar/parent/ext-calendar-items.js @@ -5,10 +5,560 @@ var { ExtensionCommon: { ExtensionAPI, EventManager } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); var { ExtensionUtils: { ExtensionError } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); +var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/ExtensionSupport.sys.mjs"); var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); +const EVENT_DIALOG_URL = "chrome://calendar/content/calendar-event-dialog.xhtml"; +const MESSENGER_URL = "chrome://messenger/content/messenger.xhtml"; + this.calendar_items = class extends ExtensionAPI { + _ensureEditorClosedListenerSet() { + if (!this._editorClosedListeners) { + this._editorClosedListeners = new Set(); + } + return this._editorClosedListeners; + } + + _ensureLifecycleStateMap() { + if (!this._editorLifecycleByTarget) { + this._editorLifecycleByTarget = new WeakMap(); + } + return this._editorLifecycleByTarget; + } + + _addEditorClosedListener(listener) { + this._ensureEditorClosedListenerSet().add(listener); + } + + _removeEditorClosedListener(listener) { + this._ensureEditorClosedListenerSet().delete(listener); + } + + _emitEditorClosed(info) { + const listeners = this._ensureEditorClosedListenerSet(); + for (const listener of listeners) { + try { + listener(info); + } catch (e) { + console.error("[calendar.items] onEditorClosed listener failed", e); + } + } + } + + _makeEditorKey(editorRef) { + const ref = editorRef && typeof editorRef == "object" ? editorRef : {}; + if (typeof ref.dialogOuterId == "number") { + return `dialog:${ref.dialogOuterId}`; + } + if (typeof ref.tabId == "number") { + return `tab:${ref.tabId}`; + } + if (typeof ref.windowId == "number") { + return `window:${ref.windowId}`; + } + return ""; + } + + _isWindowManagerType(windowType) { + switch (windowType) { + case "mail:3pane": + case "msgcompose": + case "mail:messageWindow": + case "mail:extensionPopup": + return true; + default: + return false; + } + } + + _getManagedWindowId(context, window) { + try { + const manager = context?.extension?.windowManager; + if (!manager || typeof manager.getWrapper != "function") { + return null; + } + const windowType = window?.document?.documentElement?.getAttribute?.("windowtype") || ""; + if (!this._isWindowManagerType(windowType)) { + return null; + } + const wrapper = manager.getWrapper(window); + const id = wrapper?.id; + return typeof id == "number" ? id : null; + } catch (e) { + console.error("[calendar.items] get managed window id failed", e); + return null; + } + } + + _getManagedTabId(context, window) { + try { + if (!window || window.location?.href != MESSENGER_URL) { + return null; + } + const nativeTab = window.tabmail?.currentTabInfo?.nativeTab || null; + if (!nativeTab) { + return null; + } + const manager = context?.extension?.tabManager; + if (!manager || typeof manager.getWrapper != "function") { + return null; + } + const wrapper = manager.getWrapper(nativeTab); + const id = wrapper?.id; + return typeof id == "number" ? id : null; + } catch (e) { + console.error("[calendar.items] get managed tab id failed", e); + return null; + } + } + + _getDialogOuterId(window) { + try { + const windowType = window?.document?.documentElement?.getAttribute?.("windowtype") || ""; + if (windowType != "Calendar:EventDialog" && windowType != "Calendar:EventSummaryDialog") { + return null; + } + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } catch (e) { + console.error("[calendar.items] get dialog outer id failed", e); + return null; + } + } + + _buildEditorRef(context, window) { + const editorRef = {}; + + const tabId = this._getManagedTabId(context, window); + if (typeof tabId == "number") { + editorRef.tabId = tabId; + } + + const windowId = this._getManagedWindowId(context, window); + if (typeof windowId == "number") { + editorRef.windowId = windowId; + } + + const dialogOuterId = this._getDialogOuterId(window); + if (typeof dialogOuterId == "number") { + editorRef.dialogOuterId = dialogOuterId; + } + + return Object.keys(editorRef).length ? editorRef : null; + } + + _resolveEditorWindow(context, editorRef) { + const ref = editorRef && typeof editorRef == "object" ? editorRef : {}; + + if (typeof ref.dialogOuterId == "number") { + if (!Services?.wm?.getOuterWindowWithId) { + // continue with other ids + } else { + try { + const win = Services.wm.getOuterWindowWithId(ref.dialogOuterId); + if (win && !win.closed) { + return win; + } + } catch (e) { + console.error("[calendar.items] resolve dialog window failed", e); + } + } + } + + if (typeof ref.tabId == "number") { + const tabManager = context?.extension?.tabManager; + if (tabManager && typeof tabManager.get == "function") { + try { + const tabWrapper = tabManager.get(ref.tabId); + const nativeTab = tabWrapper?.nativeTab || null; + const win = nativeTab?.ownerGlobal || null; + if (win && !win.closed) { + return win; + } + } catch (e) { + console.error("[calendar.items] resolve tab id failed", e); + } + } + } + + if (typeof ref.windowId == "number") { + const manager = context?.extension?.windowManager; + if (manager && typeof manager.get == "function") { + try { + const winObj = manager.get(ref.windowId); + const win = winObj?.window || null; + if (win && !win.closed) { + return win; + } + } catch (e) { + console.error("[calendar.items] resolve window id failed", e); + } + } + } + + return null; + } + + _resolveSnapshotWindow(context, editorRef) { + const resolved = this._resolveEditorWindow(context, editorRef); + if (resolved) { + return resolved; + } + + try { + const browsingContextWindow = context?.browsingContext?.embedderElement?.ownerGlobal || null; + if (browsingContextWindow && !browsingContextWindow.closed) { + return browsingContextWindow; + } + } catch (e) { + console.error("[calendar.items] resolve browsing context window failed", e); + } + return null; + } + + _getEditedItemForWindow(window) { + if (!window || !window.location) { + return null; + } + + if (window.location.href.startsWith(EVENT_DIALOG_URL)) { + const fromWindow = win => { + if (!win) { + return null; + } + if (win.calendarItem) { + return win.calendarItem; + } + if (win.gEvent?.event) { + return win.gEvent.event; + } + const arg0 = Array.isArray(win.arguments) ? win.arguments[0] : null; + if (arg0?.calendarItem) { + return arg0.calendarItem; + } + if (arg0?.calendarEvent) { + return arg0.calendarEvent; + } + return null; + }; + + const direct = fromWindow(window); + if (direct) { + return direct; + } + + const panelIframe = window.document?.getElementById?.("calendar-item-panel-iframe") || null; + const panelWin = panelIframe?.contentWindow || panelIframe?.contentDocument?.defaultView || null; + return fromWindow(panelWin); + } + + if (window.location.href.startsWith(MESSENGER_URL)) { + const tabInfo = window.tabmail?.currentTabInfo || null; + if (tabInfo?.mode?.name != "calendarEvent") { + return null; + } + return tabInfo.iframe?.contentWindow?.calendarItem || null; + } + + return null; + } + + _getLifecycleTargetWindow(window) { + if (!window || !window.location) { + return null; + } + if (window.location.href.startsWith(EVENT_DIALOG_URL)) { + return window; + } + if (window.location.href.startsWith(MESSENGER_URL)) { + const tabInfo = window.tabmail?.currentTabInfo || null; + if (tabInfo?.mode?.name != "calendarEvent") { + return null; + } + return tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + } + return null; + } + + _cleanupLifecycleState(target) { + if (!target) { + return; + } + + const stateMap = this._ensureLifecycleStateMap(); + const state = stateMap.get(target); + if (!state) { + return; + } + stateMap.delete(target); + + const cleanup = Array.isArray(state.cleanup) ? state.cleanup : []; + while (cleanup.length) { + const fn = cleanup.pop(); + try { + fn(); + } catch (e) { + console.error("[calendar.items] cleanup lifecycle state failed", e); + } + } + } + + _cleanupLifecycleInWindow(window) { + if (!window || !window.location) { + return; + } + + if (window.location.href.startsWith(EVENT_DIALOG_URL)) { + this._cleanupLifecycleState(window); + return; + } + + if (!window.location.href.startsWith(MESSENGER_URL)) { + return; + } + + const tabmail = window.tabmail; + const tabInfoList = tabmail && Array.isArray(tabmail.tabInfo) ? tabmail.tabInfo : []; + for (const tabInfo of tabInfoList) { + if (tabInfo?.mode?.name != "calendarEvent") { + continue; + } + const target = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + this._cleanupLifecycleState(target); + } + } + + _ensureLifecycleWatch(context, window) { + const target = this._getLifecycleTargetWindow(window); + if (!target) { + return; + } + + const stateMap = this._ensureLifecycleStateMap(); + const nextEditorRef = this._buildEditorRef(context, window); + const nextEditorKey = this._makeEditorKey(nextEditorRef); + const previous = stateMap.get(target); + if (previous) { + if (previous.editorKey == nextEditorKey) { + return; + } + this._emitEditorClosed({ + editorRef: previous.editorRef || {}, + action: "superseded", + reason: "re-bound" + }); + this._cleanupLifecycleState(target); + } + + const state = { + editorRef: nextEditorRef || {}, + editorKey: nextEditorKey, + cleanup: [], + closed: false + }; + stateMap.set(target, state); + + const emitOnce = (action, reason) => { + if (state.closed) { + return; + } + state.closed = true; + this._emitEditorClosed({ + editorRef: state.editorRef || {}, + action, + reason: reason || "" + }); + this._cleanupLifecycleState(target); + }; + + const addListener = (type, handler, options) => { + target.addEventListener(type, handler, options); + state.cleanup.push(() => { + target.removeEventListener(type, handler, options); + }); + }; + + const isDialog = !!(target.location?.href || "").startsWith(EVENT_DIALOG_URL); + if (isDialog) { + addListener("dialogaccept", () => emitOnce("persisted", "dialogaccept"), true); + addListener("dialogextra1", () => emitOnce("persisted", "dialogextra1"), true); + addListener("dialogcancel", () => emitOnce("discarded", "dialogcancel"), true); + addListener("dialogextra2", () => emitOnce("discarded", "dialogextra2"), true); + } + + addListener("unload", () => emitOnce("discarded", "unload"), true); + } + + _collectEventDocs(window) { + const docs = []; + const pushDoc = doc => { + if (!doc || docs.includes(doc)) { + return; + } + docs.push(doc); + }; + + pushDoc(window?.document || null); + + if (window?.location?.href?.startsWith(EVENT_DIALOG_URL)) { + const iframe = window.document?.getElementById?.("calendar-item-panel-iframe") || null; + pushDoc(iframe?.contentDocument || null); + } + + if (window?.location?.href?.startsWith(MESSENGER_URL)) { + const tabInfo = window.tabmail?.currentTabInfo || null; + if (tabInfo?.mode?.name == "calendarEvent") { + pushDoc(tabInfo.iframe?.contentDocument || null); + } + } + + return docs; + } + + _findField(docs, selectors) { + for (const doc of docs) { + if (!doc || typeof doc.querySelector != "function") { + continue; + } + for (const selector of selectors) { + const element = doc.querySelector(selector); + if (element) { + return element; + } + } + } + return null; + } + + _findDescriptionFieldInDocs(docs) { + for (const doc of docs) { + const host = doc?.querySelector?.("editor#item-description") || null; + if (host) { + const target = host.inputField || host.contentDocument?.body || host; + if (target) { + return target; + } + } + const fallback = doc?.querySelector?.("textarea#item-description") || null; + if (fallback) { + return fallback; + } + } + return null; + } + + _dispatchInputEvent(field) { + if (!field) { + return; + } + const doc = field.ownerDocument || field.document; + const win = doc?.defaultView; + if (win) { + field.dispatchEvent(new win.Event("input", { bubbles: true })); + } + } + + _setFieldValue(field, value, opts = {}) { + if (!field) { + return; + } + + const doc = field.ownerDocument || field.document || field.contentDocument || null; + const preferExec = opts.preferExec === true; + const tryExecCommand = () => { + if (!doc || typeof doc.execCommand != "function") { + return false; + } + field.focus?.(); + doc.execCommand("selectAll", false, null); + doc.execCommand("insertText", false, value); + return true; + }; + + if (preferExec && tryExecCommand()) { + this._dispatchInputEvent(field); + return; + } + + if ("value" in field) { + field.focus?.(); + field.value = value; + this._dispatchInputEvent(field); + return; + } + + if ((field.isContentEditable || field.tagName?.toLowerCase?.() == "body") && tryExecCommand()) { + this._dispatchInputEvent(field); + return; + } + + if (field.textContent !== undefined) { + field.textContent = value; + this._dispatchInputEvent(field); + } + } + + _applyFieldUpdates(window, fields) { + const docs = this._collectEventDocs(window); + const titleField = this._findField(docs, ["#item-title"]); + const locationField = this._findField(docs, ["#item-location"]); + const descField = this._findDescriptionFieldInDocs(docs); + + const applied = { + title: false, + location: false, + description: false + }; + + if (typeof fields.title == "string" && titleField) { + this._setFieldValue(titleField, fields.title); + applied.title = true; + } + if (typeof fields.location == "string" && locationField) { + this._setFieldValue(locationField, fields.location); + applied.location = true; + } + if (typeof fields.description == "string" && descField) { + this._setFieldValue(descField, fields.description, { preferExec: true }); + applied.description = true; + } + + return applied; + } + + _applyPropertyUpdates(item, properties) { + for (const [name, value] of Object.entries(properties || {})) { + if (!name) { + continue; + } + if (value == null || value == "") { + if (typeof item.deleteProperty == "function") { + item.deleteProperty(name); + } else { + item.setProperty(name, ""); + } + } else { + item.setProperty(name, String(value)); + } + } + } + + onShutdown() { + for (const window of ExtensionSupport.openWindows) { + try { + this._cleanupLifecycleInWindow(window); + } catch (e) { + console.error("[calendar.items] shutdown cleanup failed", e); + } + } + + if (this._editorClosedListeners) { + this._editorClosedListeners.clear(); + } + } + getAPI(context) { + const api = this; const uuid = context.extension.uuid; const root = `experiments-calendar-${uuid}`; const query = context.extension.manifest.version; @@ -150,14 +700,48 @@ this.calendar_items = class extends ExtensionAPI { }, async getCurrent(options) { - try { - // TODO This seems risky, could be null depending on remoteness - const item = context.browsingContext.embedderElement.ownerGlobal.calendarItem; - return convertItem(item, options, context.extension); - } catch (e) { - console.error(e); + let win = api._resolveSnapshotWindow(context, options?.editorRef); + if (!win) { return null; } + let item = api._getEditedItemForWindow(win); + if (!item) { + return null; + } + api._ensureLifecycleWatch(context, win); + const converted = convertItem(item, options, context.extension); + if (converted) { + const editorRef = api._buildEditorRef(context, win); + if (editorRef) { + converted.editorRef = editorRef; + } + } + return converted; + }, + + async updateCurrent(updateOptions) { + let win = api._resolveSnapshotWindow(context, updateOptions?.editorRef); + if (!win) { + throw new ExtensionError("Could not resolve target editor window"); + } + let item = api._getEditedItemForWindow(win); + if (!item) { + throw new ExtensionError("Could not find current editor item"); + } + api._ensureLifecycleWatch(context, win); + + const fields = updateOptions?.fields && typeof updateOptions.fields == "object" ? updateOptions.fields : {}; + const properties = updateOptions?.properties && typeof updateOptions.properties == "object" ? updateOptions.properties : {}; + api._applyFieldUpdates(win, fields); + api._applyPropertyUpdates(item, properties); + const converted = convertItem(item, updateOptions, context.extension); + if (converted) { + const editorRef = api._buildEditorRef(context, win); + if (editorRef) { + converted.editorRef = editorRef; + } + } + return converted; }, onCreated: new EventManager({ @@ -237,6 +821,20 @@ this.calendar_items = class extends ExtensionAPI { }; }, }).api(), + + onEditorClosed: new EventManager({ + context, + name: "calendar.items.onEditorClosed", + register: fire => { + const listener = info => { + fire.sync(info); + }; + api._addEditorClosedListener(listener); + return () => { + api._removeEditorClosedListener(listener); + }; + }, + }).api(), }, }, }; diff --git a/calendar/experiments/calendar/parent/ext-calendarItemAction.js b/calendar/experiments/calendar/parent/ext-calendarItemAction.js index 0e3c7f9..af344c0 100644 --- a/calendar/experiments/calendar/parent/ext-calendarItemAction.js +++ b/calendar/experiments/calendar/parent/ext-calendarItemAction.js @@ -7,6 +7,8 @@ var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/Exten var { ToolbarButtonAPI } = ChromeUtils.importESModule("resource:///modules/ExtensionToolbarButtons.sys.mjs"); const calendarItemActionMap = new WeakMap(); +const CALITEM_EVENT_DIALOG_URL = "chrome://calendar/content/calendar-event-dialog.xhtml"; +const CALITEM_MESSENGER_URL = "chrome://messenger/content/messenger.xhtml"; this.calendarItemAction = class extends ToolbarButtonAPI { static for(extension) { @@ -109,7 +111,7 @@ this.calendarItemAction = class extends ToolbarButtonAPI { // This is only necessary as part of the experiment, refactor when moving to core. paint(window) { - if (window.location.href == "chrome://calendar/content/calendar-event-dialog.xhtml") { + if (window.location.href == CALITEM_EVENT_DIALOG_URL) { this.toolbarId = "event-toolbar"; } else { this.toolbarId = "event-tab-toolbar"; @@ -117,9 +119,82 @@ this.calendarItemAction = class extends ToolbarButtonAPI { return super.paint(window); } + _getDialogOuterId(window) { + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } + + _getEditorClickContext(window) { + const href = window?.location?.href || ""; + if (href == CALITEM_EVENT_DIALOG_URL) { + const dialogOuterId = this._getDialogOuterId(window); + const editorRef = {}; + if (typeof dialogOuterId == "number") { + editorRef.dialogOuterId = dialogOuterId; + } + return { + editorType: "dialog", + editorRef, + }; + } + + if (href == CALITEM_MESSENGER_URL) { + const tabInfo = window.tabmail?.currentTabInfo || null; + if (tabInfo?.mode?.name == "calendarEvent") { + const editorRef = {}; + const nativeTab = tabInfo.nativeTab || null; + const tabManager = this.extension?.tabManager; + if (tabManager && typeof tabManager.getWrapper == "function" && nativeTab) { + const tabWrapper = tabManager.getWrapper(nativeTab); + const tabId = tabWrapper?.id; + if (typeof tabId == "number") { + editorRef.tabId = tabId; + } + } + const windowManager = this.extension?.windowManager; + if (windowManager && typeof windowManager.getWrapper == "function") { + const windowWrapper = windowManager.getWrapper(window); + const windowId = windowWrapper?.id; + if (typeof windowId == "number") { + editorRef.windowId = windowId; + } + } + return { + editorType: "tab", + editorRef, + }; + } + } + + return { + editorType: "unknown", + editorRef: {}, + }; + } + handleEvent(event) { - super.handleEvent(event); const window = event.target.ownerGlobal; + if (event.type == "mousedown" && event.button == 0) { + if ( + event.target.tagName == "menu" || + event.target.tagName == "menuitem" || + event.target.getAttribute("type") == "menu" + ) { + return; + } + + const clickContext = this._getEditorClickContext(window); + this.lastClickInfo = { + button: 0, + modifiers: this.global.clickModifiersFromEvent(event), + editorType: clickContext.editorType, + editorRef: clickContext.editorRef, + }; + this.triggerAction(window); + return; + } + + super.handleEvent(event); switch (event.type) { case "popupshowing": { diff --git a/calendar/experiments/calendar/schema/calendar-items.json b/calendar/experiments/calendar/schema/calendar-items.json index bc44788..97368d1 100644 --- a/calendar/experiments/calendar/schema/calendar-items.json +++ b/calendar/experiments/calendar/schema/calendar-items.json @@ -13,6 +13,7 @@ "instance": { "type": "string", "optional": true }, "format": { "$ref": "CalendarItemFormats", "optional": true }, "item": { "$ref": "RawCalendarItem" }, + "editorRef": { "$ref": "EditorRef", "optional": true }, "metadata": { "type": "object", "additionalProperties": { "type": "any" }, "optional": true } } }, @@ -43,6 +44,49 @@ "offset": { "type": "string" }, "related": { "type": "string", "enum": ["absolute", "start", "end"] } } + }, + { + "id": "EditorRef", + "type": "object", + "description": "Reference to an open calendar editor context.", + "properties": { + "tabId": { "type": "integer", "optional": true }, + "windowId": { "type": "integer", "optional": true }, + "dialogOuterId": { "type": "integer", "optional": true } + } + }, + { + "id": "EditorFieldUpdates", + "type": "object", + "properties": { + "title": { "type": "string", "optional": true }, + "location": { "type": "string", "optional": true }, + "description": { "type": "string", "optional": true } + } + }, + { + "id": "EditorPropertyUpdates", + "type": "object", + "additionalProperties": { + "choices": [ + { "type": "string" }, + { "type": "null" } + ] + } + }, + { + "id": "EditorClosedAction", + "type": "string", + "enum": ["persisted", "discarded", "superseded"] + }, + { + "id": "EditorClosedInfo", + "type": "object", + "properties": { + "editorRef": { "$ref": "EditorRef" }, + "action": { "$ref": "EditorClosedAction" }, + "reason": { "type": "string", "optional": true } + } } ], "functions": [ @@ -157,6 +201,24 @@ "name": "getOptions", "optional": true, "properties": { + "returnFormat": { "$ref": "ReturnFormat", "optional": true }, + "editorRef": { "$ref": "EditorRef", "optional": true } + } + } + ] + }, + { + "name": "updateCurrent", + "async": true, + "type": "function", + "parameters": [ + { + "type": "object", + "name": "updateOptions", + "properties": { + "editorRef": { "$ref": "EditorRef", "optional": true }, + "fields": { "$ref": "EditorFieldUpdates", "optional": true }, + "properties": { "$ref": "EditorPropertyUpdates", "optional": true }, "returnFormat": { "$ref": "ReturnFormat", "optional": true } } } @@ -221,6 +283,13 @@ } } ] + }, + { + "name": "onEditorClosed", + "type": "function", + "parameters": [ + { "name": "info", "$ref": "EditorClosedInfo" } + ] } ] } diff --git a/calendar/experiments/calendar/schema/calendarItemAction.json b/calendar/experiments/calendar/schema/calendarItemAction.json index 52fd362..0631183 100644 --- a/calendar/experiments/calendar/schema/calendarItemAction.json +++ b/calendar/experiments/calendar/schema/calendarItemAction.json @@ -98,6 +98,28 @@ "^[1-9]\\d*$": {"$ref": "ImageDataType"} } }, + { + "id": "EditorRef", + "type": "object", + "description": "Reference to the currently active event editor context.", + "properties": { + "tabId": { + "type": "integer", + "optional": true, + "description": "The extension tab id for tab based event editors." + }, + "windowId": { + "type": "integer", + "optional": true, + "description": "The extension window id for tab based editors." + }, + "dialogOuterId": { + "type": "integer", + "optional": true, + "description": "The native outer window id for dialog based editors." + } + } + }, { "id": "OnClickData", "type": "object", @@ -115,6 +137,17 @@ "type": "integer", "optional": true, "description": "An integer value of button by which menu item was clicked." + }, + "editorRef": { + "$ref": "EditorRef", + "optional": true, + "description": "Reference to the editor context where the action was invoked." + }, + "editorType": { + "type": "string", + "enum": ["dialog", "tab", "unknown"], + "optional": true, + "description": "Editor variant where the action was invoked." } } } @@ -651,4 +684,3 @@ ] } ] - From b50661609c2d029a8165184d524b8755de652479 Mon Sep 17 00:00:00 2001 From: DeBaschdi Date: Tue, 24 Feb 2026 06:02:38 +0100 Subject: [PATCH 3/3] calendar: harden editorId contract and make updateCurrent transactional Follow-up hardening for editor-context APIs: - bind editorId to concrete editor instances (tabId + editor outerWindowID) - reject stale tab editor instances during editorId resolution - remove global bridge registries in favor of per-extension bridge state - make calendar.items.updateCurrent transactional across fields and properties: - rollback fields on field failure - rollback applied properties and fields on property failure - align schema docs with the implemented contract (editor instance + transactional updateCurrent) This tightens long-term API contract behavior for dialog/tab editors, including unsaved items. --- .../ext-calendar-editor-context.sys.mjs | 105 +++ .../calendar/parent/ext-calendar-items.js | 797 ++++++++++++------ .../calendar/parent/ext-calendarItemAction.js | 307 ++++++- .../calendar/schema/calendar-items.json | 56 +- .../calendar/schema/calendarItemAction.json | 32 +- 5 files changed, 956 insertions(+), 341 deletions(-) create mode 100644 calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs diff --git a/calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs b/calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs new file mode 100644 index 0000000..18872b3 --- /dev/null +++ b/calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs @@ -0,0 +1,105 @@ +/* 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/. */ + +const OPAQUE_EDITOR_ID_PATTERN = /^ed-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const BRIDGE_SYMBOL = Symbol("calendar-editor-context-bridge"); + +function createEditorId() { + const uuid = Services.uuid.generateUUID().toString().slice(1, -1).toLowerCase(); + return `ed-${uuid}`; +} + +export class EditorContextBridge { + constructor(extension) { + if (!extension) { + throw new Error("EditorContextBridge requires an extension"); + } + this.extension = extension; + this.targetToEditorId = new Map(); + this.editorIdToTarget = new Map(); + } + + normalizeEditorId(editorId) { + if (typeof editorId != "string") { + return ""; + } + const value = editorId.trim(); + if (!value) { + return ""; + } + return OPAQUE_EDITOR_ID_PATTERN.test(value) ? value : ""; + } + + _registerTarget(kind, id, instanceId = 0) { + if ((kind != "tab" && kind != "dialog") || !Number.isInteger(id) || !Number.isInteger(instanceId)) { + return ""; + } + + const key = `${kind}:${id}:${instanceId}`; + const existingId = this.targetToEditorId.get(key); + if (existingId) { + return existingId; + } + + const editorId = createEditorId(); + this.targetToEditorId.set(key, editorId); + this.editorIdToTarget.set(editorId, { kind, id, instanceId, key }); + return editorId; + } + + registerTabTarget(tabId, editorOuterId = 0) { + return this._registerTarget("tab", tabId, editorOuterId); + } + + registerDialogTarget(dialogOuterId) { + return this._registerTarget("dialog", dialogOuterId, dialogOuterId); + } + + resolveTarget(editorId) { + const normalizedEditorId = this.normalizeEditorId(editorId); + if (!normalizedEditorId) { + return null; + } + const target = this.editorIdToTarget.get(normalizedEditorId); + if (!target) { + return null; + } + return { kind: target.kind, id: target.id, instanceId: target.instanceId }; + } + + releaseEditorId(editorId) { + const normalizedEditorId = this.normalizeEditorId(editorId); + if (!normalizedEditorId) { + return; + } + + const target = this.editorIdToTarget.get(normalizedEditorId); + if (!target) { + return; + } + + this.editorIdToTarget.delete(normalizedEditorId); + this.targetToEditorId.delete(target.key); + } + + clear() { + this.targetToEditorId.clear(); + this.editorIdToTarget.clear(); + if (this.extension[BRIDGE_SYMBOL] == this) { + delete this.extension[BRIDGE_SYMBOL]; + } + } +} + +export function getEditorContextBridge(extension) { + if (!extension) { + throw new Error("Missing extension"); + } + let bridge = extension[BRIDGE_SYMBOL]; + if (!bridge || !(bridge instanceof EditorContextBridge)) { + bridge = new EditorContextBridge(extension); + extension[BRIDGE_SYMBOL] = bridge; + } + return bridge; +} diff --git a/calendar/experiments/calendar/parent/ext-calendar-items.js b/calendar/experiments/calendar/parent/ext-calendar-items.js index ac762b8..4fc186a 100644 --- a/calendar/experiments/calendar/parent/ext-calendar-items.js +++ b/calendar/experiments/calendar/parent/ext-calendar-items.js @@ -9,7 +9,21 @@ var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/Exten var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); const EVENT_DIALOG_URL = "chrome://calendar/content/calendar-event-dialog.xhtml"; +const EVENT_TAB_IFRAME_URL = "chrome://calendar/content/calendar-item-iframe.xhtml"; const MESSENGER_URL = "chrome://messenger/content/messenger.xhtml"; +const EVENT_PANEL_IFRAME_ID = "calendar-item-panel-iframe"; +const EVENT_TITLE_FIELD_ID = "item-title"; +const EVENT_LOCATION_FIELD_ID = "item-location"; +const EVENT_DESCRIPTION_FIELD_ID = "item-description"; +const EVENT_EDITOR_TAB_MODES = new Set(["calendarEvent", "calendarTask"]); +function getEditorContextBridgeForExtension(extension) { + const root = `experiments-calendar-${extension.uuid}`; + const query = extension.manifest.version; + const module = ChromeUtils.importESModule( + `resource://${root}/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs?${query}` + ); + return module.getEditorContextBridge(extension); +} this.calendar_items = class extends ExtensionAPI { _ensureEditorClosedListenerSet() { @@ -40,74 +54,111 @@ this.calendar_items = class extends ExtensionAPI { try { listener(info); } catch (e) { - console.error("[calendar.items] onEditorClosed listener failed", e); + console.error("[calendar.items] onTrackedEditorClosed listener failed", e); } } } - _makeEditorKey(editorRef) { - const ref = editorRef && typeof editorRef == "object" ? editorRef : {}; - if (typeof ref.dialogOuterId == "number") { - return `dialog:${ref.dialogOuterId}`; + _getEditorBridge(extension) { + if (!extension) { + throw new ExtensionError("Missing extension context"); } - if (typeof ref.tabId == "number") { - return `tab:${ref.tabId}`; + if (!this._editorBridgeByExtension) { + this._editorBridgeByExtension = new WeakMap(); } - if (typeof ref.windowId == "number") { - return `window:${ref.windowId}`; + let bridge = this._editorBridgeByExtension.get(extension); + if (!bridge) { + bridge = getEditorContextBridgeForExtension(extension); + this._editorBridgeByExtension.set(extension, bridge); } - return ""; + return bridge; } - _isWindowManagerType(windowType) { - switch (windowType) { - case "mail:3pane": - case "msgcompose": - case "mail:messageWindow": - case "mail:extensionPopup": - return true; - default: - return false; + _clearEditorBridge(extension) { + if (!extension || !this._editorBridgeByExtension) { + return; + } + const bridge = this._editorBridgeByExtension.get(extension); + if (!bridge) { + return; } + bridge.clear(); + this._editorBridgeByExtension.delete(extension); } - _getManagedWindowId(context, window) { - try { - const manager = context?.extension?.windowManager; - if (!manager || typeof manager.getWrapper != "function") { - return null; + _isCalendarEditorTabInfo(tabInfo) { + const modeName = tabInfo?.mode?.name || ""; + if (EVENT_EDITOR_TAB_MODES.has(modeName)) { + return true; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const href = editorWindow?.location?.href || ""; + return href.startsWith(EVENT_DIALOG_URL) || href.startsWith(EVENT_TAB_IFRAME_URL); + } + + _isCalendarEditorWindow(window) { + const href = window?.location?.href || ""; + return href.startsWith(EVENT_DIALOG_URL) || href.startsWith(EVENT_TAB_IFRAME_URL); + } + + _getCalendarTabInfoForEditorWindow(window) { + if (!window || !this._isCalendarEditorWindow(window)) { + return null; + } + + const ownerWindow = window.ownerGlobal || null; + if (!ownerWindow || ownerWindow.location?.href != MESSENGER_URL) { + return null; + } + + const tabInfoList = ownerWindow.tabmail && Array.isArray(ownerWindow.tabmail.tabInfo) + ? ownerWindow.tabmail.tabInfo + : []; + for (const tabInfo of tabInfoList) { + if (!this._isCalendarEditorTabInfo(tabInfo)) { + continue; } - const windowType = window?.document?.documentElement?.getAttribute?.("windowtype") || ""; - if (!this._isWindowManagerType(windowType)) { - return null; + const tabEditorWindow = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + if (tabEditorWindow == window) { + return tabInfo; } - const wrapper = manager.getWrapper(window); - const id = wrapper?.id; - return typeof id == "number" ? id : null; - } catch (e) { - console.error("[calendar.items] get managed window id failed", e); - return null; } + + return null; } _getManagedTabId(context, window) { + const manager = context?.extension?.tabManager; + if (!manager || typeof manager.getWrapper != "function" || !window) { + console.error("[calendar.items] managed tab id resolution failed: tabManager unavailable"); + return null; + } + + const tabInfo = this._getCalendarTabInfoForEditorWindow(window); + if (!tabInfo) { + console.error("[calendar.items] managed tab id resolution failed: could not map editor window to tabInfo", { + windowHref: window?.location?.href || "", + }); + return null; + } + try { - if (!window || window.location?.href != MESSENGER_URL) { - return null; - } - const nativeTab = window.tabmail?.currentTabInfo?.nativeTab || null; - if (!nativeTab) { - return null; - } - const manager = context?.extension?.tabManager; - if (!manager || typeof manager.getWrapper != "function") { - return null; - } - const wrapper = manager.getWrapper(nativeTab); + const wrapper = manager.getWrapper(tabInfo); const id = wrapper?.id; - return typeof id == "number" ? id : null; + if (typeof id == "number") { + return id; + } + console.error("[calendar.items] managed tab id resolution failed: tabManager.getWrapper(tabInfo) returned no numeric id", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + }); + return null; } catch (e) { - console.error("[calendar.items] get managed tab id failed", e); + console.error("[calendar.items] managed tab id resolution failed: tabManager.getWrapper(tabInfo) threw", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + error: String(e), + }); return null; } } @@ -126,93 +177,135 @@ this.calendar_items = class extends ExtensionAPI { } } - _buildEditorRef(context, window) { - const editorRef = {}; + _getEditorOuterId(window) { + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } + _getEditorIdForWindow(context, window) { + const bridge = this._getEditorBridge(context.extension); const tabId = this._getManagedTabId(context, window); if (typeof tabId == "number") { - editorRef.tabId = tabId; - } - - const windowId = this._getManagedWindowId(context, window); - if (typeof windowId == "number") { - editorRef.windowId = windowId; + const editorOuterId = this._getEditorOuterId(window); + if (typeof editorOuterId != "number") { + console.error("[calendar.items] editor id resolution failed: missing tab editor outer window id", { tabId }); + return ""; + } + return bridge.registerTabTarget(tabId, editorOuterId); } const dialogOuterId = this._getDialogOuterId(window); if (typeof dialogOuterId == "number") { - editorRef.dialogOuterId = dialogOuterId; + return bridge.registerDialogTarget(dialogOuterId); } - return Object.keys(editorRef).length ? editorRef : null; + return ""; } - _resolveEditorWindow(context, editorRef) { - const ref = editorRef && typeof editorRef == "object" ? editorRef : {}; + _resolveTabEditorWindow(context, tabId, editorOuterId = 0) { + const tabManager = context?.extension?.tabManager; + if (!tabManager || typeof tabManager.get != "function") { + console.error("[calendar.items] tab editor resolution failed: tabManager unavailable", { tabId }); + return null; + } - if (typeof ref.dialogOuterId == "number") { - if (!Services?.wm?.getOuterWindowWithId) { - // continue with other ids - } else { - try { - const win = Services.wm.getOuterWindowWithId(ref.dialogOuterId); - if (win && !win.closed) { - return win; - } - } catch (e) { - console.error("[calendar.items] resolve dialog window failed", e); - } - } + let tabWrapper = null; + try { + tabWrapper = tabManager.get(tabId); + } catch (_e) { + console.error("[calendar.items] tab editor resolution failed: tab id not found", { tabId }); + return null; } - if (typeof ref.tabId == "number") { - const tabManager = context?.extension?.tabManager; - if (tabManager && typeof tabManager.get == "function") { - try { - const tabWrapper = tabManager.get(ref.tabId); - const nativeTab = tabWrapper?.nativeTab || null; - const win = nativeTab?.ownerGlobal || null; - if (win && !win.closed) { - return win; - } - } catch (e) { - console.error("[calendar.items] resolve tab id failed", e); - } - } + const tabInfo = tabWrapper?.nativeTab || null; + if (!this._isCalendarEditorTabInfo(tabInfo)) { + console.error("[calendar.items] tab editor resolution failed: tab wrapper nativeTab is not a calendar editor tab", { tabId }); + return null; } - if (typeof ref.windowId == "number") { - const manager = context?.extension?.windowManager; - if (manager && typeof manager.get == "function") { - try { - const winObj = manager.get(ref.windowId); - const win = winObj?.window || null; - if (win && !win.closed) { - return win; - } - } catch (e) { - console.error("[calendar.items] resolve window id failed", e); - } + const win = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + if (!win || win.closed || !this._isCalendarEditorWindow(win)) { + console.error("[calendar.items] tab editor resolution failed: iframe window unavailable or unexpected URL", { + tabId, + href: win?.location?.href || "", + }); + return null; + } + + if (Number.isInteger(editorOuterId) && editorOuterId > 0) { + const currentOuterId = this._getEditorOuterId(win); + if (currentOuterId != editorOuterId) { + console.error("[calendar.items] tab editor resolution failed: stale tab editor instance", { + tabId, + expectedOuterId: editorOuterId, + currentOuterId: currentOuterId ?? null, + }); + return null; } } - return null; + return win; } - _resolveSnapshotWindow(context, editorRef) { - const resolved = this._resolveEditorWindow(context, editorRef); - if (resolved) { - return resolved; + _resolveEditorWindow(context, editorId) { + const bridge = this._getEditorBridge(context.extension); + const normalizedEditorId = bridge.normalizeEditorId(editorId); + if (!normalizedEditorId) { + console.error("[calendar.items] editor resolution failed: invalid editorId format"); + return null; + } + const target = bridge.resolveTarget(normalizedEditorId); + if (!target) { + console.error("[calendar.items] editor resolution failed: unknown editorId", { editorId: normalizedEditorId }); + return null; } - try { - const browsingContextWindow = context?.browsingContext?.embedderElement?.ownerGlobal || null; - if (browsingContextWindow && !browsingContextWindow.closed) { - return browsingContextWindow; + if (target.kind == "dialog") { + if (!Services?.wm?.getOuterWindowWithId) { + console.error("[calendar.items] dialog editor resolution failed: Services.wm.getOuterWindowWithId unavailable", { editorId: normalizedEditorId }); + bridge.releaseEditorId(normalizedEditorId); + return null; } - } catch (e) { - console.error("[calendar.items] resolve browsing context window failed", e); + try { + const win = Services.wm.getOuterWindowWithId(target.id); + if (win && !win.closed && win.location?.href?.startsWith(EVENT_DIALOG_URL)) { + return win; + } + } catch (_e) { + console.error("[calendar.items] dialog editor resolution failed: getOuterWindowWithId threw", { + editorId: normalizedEditorId, + dialogOuterId: target.id, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + console.error("[calendar.items] dialog editor resolution failed: window unavailable or unexpected URL", { + editorId: normalizedEditorId, + dialogOuterId: target.id, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + + if (target.kind == "tab") { + const win = this._resolveTabEditorWindow(context, target.id, target.instanceId); + if (win) { + return win; + } + console.error("[calendar.items] tab editor resolution failed", { + editorId: normalizedEditorId, + tabId: target.id, + editorOuterId: target.instanceId ?? null, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; } + + console.error("[calendar.items] editor resolution failed: unsupported target kind", { + editorId: normalizedEditorId, + kind: target.kind, + }); + bridge.releaseEditorId(normalizedEditorId); return null; } @@ -221,7 +314,7 @@ this.calendar_items = class extends ExtensionAPI { return null; } - if (window.location.href.startsWith(EVENT_DIALOG_URL)) { + if (this._isCalendarEditorWindow(window)) { const fromWindow = win => { if (!win) { return null; @@ -247,36 +340,11 @@ this.calendar_items = class extends ExtensionAPI { return direct; } - const panelIframe = window.document?.getElementById?.("calendar-item-panel-iframe") || null; + const panelIframe = window.document?.getElementById?.(EVENT_PANEL_IFRAME_ID) || null; const panelWin = panelIframe?.contentWindow || panelIframe?.contentDocument?.defaultView || null; return fromWindow(panelWin); } - if (window.location.href.startsWith(MESSENGER_URL)) { - const tabInfo = window.tabmail?.currentTabInfo || null; - if (tabInfo?.mode?.name != "calendarEvent") { - return null; - } - return tabInfo.iframe?.contentWindow?.calendarItem || null; - } - - return null; - } - - _getLifecycleTargetWindow(window) { - if (!window || !window.location) { - return null; - } - if (window.location.href.startsWith(EVENT_DIALOG_URL)) { - return window; - } - if (window.location.href.startsWith(MESSENGER_URL)) { - const tabInfo = window.tabmail?.currentTabInfo || null; - if (tabInfo?.mode?.name != "calendarEvent") { - return null; - } - return tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; - } return null; } @@ -291,6 +359,9 @@ this.calendar_items = class extends ExtensionAPI { return; } stateMap.delete(target); + if (state.editorId && state.extension) { + this._getEditorBridge(state.extension).releaseEditorId(state.editorId); + } const cleanup = Array.isArray(state.cleanup) ? state.cleanup : []; while (cleanup.length) { @@ -320,7 +391,7 @@ this.calendar_items = class extends ExtensionAPI { const tabmail = window.tabmail; const tabInfoList = tabmail && Array.isArray(tabmail.tabInfo) ? tabmail.tabInfo : []; for (const tabInfo of tabInfoList) { - if (tabInfo?.mode?.name != "calendarEvent") { + if (!this._isCalendarEditorTabInfo(tabInfo)) { continue; } const target = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; @@ -328,22 +399,30 @@ this.calendar_items = class extends ExtensionAPI { } } - _ensureLifecycleWatch(context, window) { - const target = this._getLifecycleTargetWindow(window); + _ensureLifecycleWatch(context, window, editorId = "") { + const target = window && this._isCalendarEditorWindow(window) ? window : null; if (!target) { return; } const stateMap = this._ensureLifecycleStateMap(); - const nextEditorRef = this._buildEditorRef(context, window); - const nextEditorKey = this._makeEditorKey(nextEditorRef); + const bridge = this._getEditorBridge(context.extension); + const normalizedEditorId = bridge.normalizeEditorId(editorId); + const nextEditorId = normalizedEditorId || this._getEditorIdForWindow(context, target); + if (!nextEditorId) { + return; + } + const nextEditorKey = nextEditorId; + if (!nextEditorKey) { + return; + } const previous = stateMap.get(target); if (previous) { if (previous.editorKey == nextEditorKey) { return; } this._emitEditorClosed({ - editorRef: previous.editorRef || {}, + editorId: previous.editorId || "", action: "superseded", reason: "re-bound" }); @@ -351,7 +430,8 @@ this.calendar_items = class extends ExtensionAPI { } const state = { - editorRef: nextEditorRef || {}, + extension: context.extension, + editorId: nextEditorId, editorKey: nextEditorKey, cleanup: [], closed: false @@ -363,11 +443,14 @@ this.calendar_items = class extends ExtensionAPI { return; } state.closed = true; - this._emitEditorClosed({ - editorRef: state.editorRef || {}, + const info = { + editorId: state.editorId || "", action, - reason: reason || "" - }); + }; + if (reason) { + info.reason = reason; + } + this._emitEditorClosed(info); this._cleanupLifecycleState(target); }; @@ -389,120 +472,209 @@ this.calendar_items = class extends ExtensionAPI { addListener("unload", () => emitOnce("discarded", "unload"), true); } - _collectEventDocs(window) { - const docs = []; - const pushDoc = doc => { - if (!doc || docs.includes(doc)) { - return; - } - docs.push(doc); - }; - - pushDoc(window?.document || null); - - if (window?.location?.href?.startsWith(EVENT_DIALOG_URL)) { - const iframe = window.document?.getElementById?.("calendar-item-panel-iframe") || null; - pushDoc(iframe?.contentDocument || null); + _assertEditorWindowOpen(window, operation) { + if (!window || window.closed) { + console.error("[calendar.items] editor window closed", { operation: operation || "" }); + throw new ExtensionError(`Editor window closed during ${operation}`); } + } - if (window?.location?.href?.startsWith(MESSENGER_URL)) { - const tabInfo = window.tabmail?.currentTabInfo || null; - if (tabInfo?.mode?.name == "calendarEvent") { - pushDoc(tabInfo.iframe?.contentDocument || null); - } + _getMainEditorDocument(window) { + const doc = window?.document || null; + if (!doc) { + throw new ExtensionError("Could not resolve editor document"); } + return doc; + } + + _getPanelEditorDocument(window) { + const mainDoc = this._getMainEditorDocument(window); + const panelIframe = mainDoc.getElementById(EVENT_PANEL_IFRAME_ID); + return panelIframe?.contentDocument || null; + } + _getEditorDocuments(window) { + const docs = []; + const mainDoc = this._getMainEditorDocument(window); + docs.push(mainDoc); + const panelDoc = this._getPanelEditorDocument(window); + if (panelDoc) { + docs.push(panelDoc); + } return docs; } - _findField(docs, selectors) { + _resolveValueFieldById(window, elementId, label) { + const docs = this._getEditorDocuments(window); for (const doc of docs) { - if (!doc || typeof doc.querySelector != "function") { - continue; - } - for (const selector of selectors) { - const element = doc.querySelector(selector); - if (element) { - return element; - } + const field = doc.getElementById(elementId); + if (field && ("value" in field)) { + return { + kind: "value", + element: field, + }; } } - return null; + console.error("[calendar.items] field resolution failed", { field: label, elementId }); + throw new ExtensionError(`Could not resolve writable ${label} field`); } - _findDescriptionFieldInDocs(docs) { + _resolveTitleField(window) { + return this._resolveValueFieldById(window, EVENT_TITLE_FIELD_ID, "title"); + } + + _resolveLocationField(window) { + return this._resolveValueFieldById(window, EVENT_LOCATION_FIELD_ID, "location"); + } + + _resolveDescriptionField(window) { + const docs = this._getEditorDocuments(window); for (const doc of docs) { - const host = doc?.querySelector?.("editor#item-description") || null; - if (host) { - const target = host.inputField || host.contentDocument?.body || host; - if (target) { - return target; - } + const host = doc.getElementById(EVENT_DESCRIPTION_FIELD_ID); + const inputField = host?.inputField || null; + if (inputField && ("value" in inputField)) { + return { + kind: "value", + element: inputField, + }; } - const fallback = doc?.querySelector?.("textarea#item-description") || null; - if (fallback) { - return fallback; + + if (host && ("value" in host)) { + return { + kind: "value", + element: host, + }; + } + + const htmlBody = host?.contentDocument?.body || null; + if (htmlBody) { + return { + kind: "html-body", + element: htmlBody, + }; } } - return null; + console.error("[calendar.items] field resolution failed", { field: "description", elementId: EVENT_DESCRIPTION_FIELD_ID }); + throw new ExtensionError("Could not resolve writable description field"); } - _dispatchInputEvent(field) { - if (!field) { + _dispatchInputEvent(element) { + if (!element) { return; } - const doc = field.ownerDocument || field.document; + const doc = element.ownerDocument || element.document; const win = doc?.defaultView; if (win) { - field.dispatchEvent(new win.Event("input", { bubbles: true })); + element.dispatchEvent(new win.Event("input", { bubbles: true })); } } - _setFieldValue(field, value, opts = {}) { - if (!field) { + _setFieldValue(target, value) { + const element = target?.element || null; + if (!element) { + console.error("[calendar.items] field update failed: missing resolved field target"); + throw new ExtensionError("Resolved editor field is not writable"); + } + + if (target.kind == "value") { + if (!("value" in element)) { + console.error("[calendar.items] field update failed: resolved value target has no value property"); + throw new ExtensionError("Resolved editor field is not writable"); + } + + element.focus?.(); + element.value = value; + this._dispatchInputEvent(element); return; } - const doc = field.ownerDocument || field.document || field.contentDocument || null; - const preferExec = opts.preferExec === true; - const tryExecCommand = () => { + if (target.kind == "html-body") { + const doc = element.ownerDocument || null; if (!doc || typeof doc.execCommand != "function") { - return false; + console.error("[calendar.items] description update failed: execCommand unavailable on html-body editor"); + throw new ExtensionError("Could not write description field"); } - field.focus?.(); + element.focus?.(); doc.execCommand("selectAll", false, null); - doc.execCommand("insertText", false, value); - return true; + const insertOk = doc.execCommand("insertText", false, value); + const normalizedValue = String(value ?? ""); + const currentValue = String(element.textContent ?? ""); + if (!insertOk && currentValue != normalizedValue) { + console.error("[calendar.items] description update failed: execCommand returned false"); + throw new ExtensionError("Could not write description field"); + } + this._dispatchInputEvent(element); + return; + } + + console.error("[calendar.items] field update failed: unknown resolved field target kind", { + kind: target.kind, + }); + throw new ExtensionError("Resolved editor field is not writable"); + } + + _snapshotResolvedFieldValues(targets) { + const readValue = target => { + if (!target || !target.element) { + return null; + } + if (target.kind == "value") { + return String(target.element.value ?? ""); + } + if (target.kind == "html-body") { + return String(target.element.textContent ?? ""); + } + console.error("[calendar.items] field snapshot failed: unknown resolved field target kind", { + kind: target.kind, + }); + throw new ExtensionError("Resolved editor field is not readable"); + }; + + return { + title: readValue(targets.title), + location: readValue(targets.location), + description: readValue(targets.description), }; + } - if (preferExec && tryExecCommand()) { - this._dispatchInputEvent(field); + _rollbackFieldUpdates(window, targets, beforeValues, applied) { + if (!window || window.closed) { + console.error("[calendar.items] rollback skipped because editor window closed"); return; } - if ("value" in field) { - field.focus?.(); - field.value = value; - this._dispatchInputEvent(field); - return; + const rollbackOrder = ["description", "location", "title"]; + for (const key of rollbackOrder) { + if (!applied[key] || !targets[key]) { + continue; + } + this._setFieldValue(targets[key], beforeValues[key] ?? ""); } + } - if ((field.isContentEditable || field.tagName?.toLowerCase?.() == "body") && tryExecCommand()) { - this._dispatchInputEvent(field); - return; + _resolveRequestedFieldTargets(window, fields) { + this._assertEditorWindowOpen(window, "field target resolution"); + const targets = {}; + + if (typeof fields.title == "string") { + targets.title = this._resolveTitleField(window); + } + + if (typeof fields.location == "string") { + targets.location = this._resolveLocationField(window); } - if (field.textContent !== undefined) { - field.textContent = value; - this._dispatchInputEvent(field); + if (typeof fields.description == "string") { + targets.description = this._resolveDescriptionField(window); } + + return targets; } - _applyFieldUpdates(window, fields) { - const docs = this._collectEventDocs(window); - const titleField = this._findField(docs, ["#item-title"]); - const locationField = this._findField(docs, ["#item-location"]); - const descField = this._findDescriptionFieldInDocs(docs); + _applyFieldUpdates(window, fields, state = null) { + this._assertEditorWindowOpen(window, "field updates"); + const targets = state?.targets || this._resolveRequestedFieldTargets(window, fields); + const beforeValues = state?.beforeValues || this._snapshotResolvedFieldValues(targets); const applied = { title: false, @@ -510,35 +682,98 @@ this.calendar_items = class extends ExtensionAPI { description: false }; - if (typeof fields.title == "string" && titleField) { - this._setFieldValue(titleField, fields.title); - applied.title = true; - } - if (typeof fields.location == "string" && locationField) { - this._setFieldValue(locationField, fields.location); - applied.location = true; - } - if (typeof fields.description == "string" && descField) { - this._setFieldValue(descField, fields.description, { preferExec: true }); - applied.description = true; + try { + if (typeof fields.title == "string") { + this._assertEditorWindowOpen(window, "title update"); + this._setFieldValue(targets.title, fields.title); + applied.title = true; + } + if (typeof fields.location == "string") { + this._assertEditorWindowOpen(window, "location update"); + this._setFieldValue(targets.location, fields.location); + applied.location = true; + } + if (typeof fields.description == "string") { + this._assertEditorWindowOpen(window, "description update"); + this._setFieldValue(targets.description, fields.description); + applied.description = true; + } + } catch (e) { + try { + this._rollbackFieldUpdates(window, targets, beforeValues, applied); + } catch (rollbackError) { + console.error("[calendar.items] rollback failed", rollbackError); + } + throw e; } return applied; } + _validatePropertyUpdates(properties) { + for (const name of Object.keys(properties || {})) { + if (!name || typeof name != "string") { + throw new ExtensionError("Property names must be non-empty strings"); + } + } + } + + _snapshotPropertyValues(item, properties) { + this._validatePropertyUpdates(properties); + const snapshot = {}; + for (const name of Object.keys(properties || {})) { + try { + const current = item.getProperty(name); + snapshot[name] = current == null ? null : String(current); + } catch (e) { + console.error("[calendar.items] property snapshot failed", { property: name, error: String(e) }); + throw new ExtensionError(`Could not snapshot property ${name}`); + } + } + return snapshot; + } + _applyPropertyUpdates(item, properties) { + this._validatePropertyUpdates(properties); + const appliedNames = []; for (const [name, value] of Object.entries(properties || {})) { - if (!name) { - continue; + try { + if (value == null) { + if (typeof item.deleteProperty == "function") { + item.deleteProperty(name); + } else { + item.setProperty(name, ""); + } + } else { + item.setProperty(name, String(value)); + } + appliedNames.push(name); + } catch (e) { + console.error("[calendar.items] property update failed", { property: name, error: String(e) }); + throw new ExtensionError(`Could not update property ${name}`); } - if (value == null || value == "") { - if (typeof item.deleteProperty == "function") { - item.deleteProperty(name); + } + return appliedNames; + } + + _rollbackPropertyUpdates(item, snapshot, appliedNames) { + const names = Array.isArray(appliedNames) ? appliedNames : []; + for (let i = names.length - 1; i >= 0; i--) { + const name = names[i]; + const previous = Object.prototype.hasOwnProperty.call(snapshot, name) ? snapshot[name] : null; + try { + if (previous == null) { + if (typeof item.deleteProperty == "function") { + item.deleteProperty(name); + } else { + item.setProperty(name, ""); + } } else { - item.setProperty(name, ""); + item.setProperty(name, String(previous)); } - } else { - item.setProperty(name, String(value)); + } catch (e) { + console.error("[calendar.items] property rollback failed", { property: name, error: String(e) }); + throw new ExtensionError(`Could not rollback property ${name}`); } } } @@ -555,6 +790,8 @@ this.calendar_items = class extends ExtensionAPI { if (this._editorClosedListeners) { this._editorClosedListeners.clear(); } + + this._clearEditorBridge(this.extension); } getAPI(context) { @@ -658,7 +895,7 @@ this.calendar_items = class extends ExtensionAPI { newItem.calendar = calendar.superCalendar; if (updateProperties.metadata && isOwnCalendar(calendar, context.extension)) { - // TODO merge or replace? + // Metadata updates replace the cached payload for deterministic behavior. const cache = getCachedCalendar(calendar); cache.setMetaData(newItem.id, JSON.stringify(updateProperties.metadata)); } @@ -680,8 +917,7 @@ this.calendar_items = class extends ExtensionAPI { } if (isOwnCalendar(toCalendar, context.extension) && isOwnCalendar(fromCalendar, context.extension)) { - // TODO doing this first, the item may not be in the db and it will fail. Doing this - // after addItem, the metadata will not be available for the onCreated listener + // Copy metadata before addItem so onCreated listeners can read it immediately. const fromCache = getCachedCalendar(fromCalendar); const toCache = getCachedCalendar(toCalendar); toCache.setMetaData(item.id, fromCache.getMetaData(item.id)); @@ -700,46 +936,91 @@ this.calendar_items = class extends ExtensionAPI { }, async getCurrent(options) { - let win = api._resolveSnapshotWindow(context, options?.editorRef); + const editorId = api._getEditorBridge(context.extension).normalizeEditorId(options?.editorId); + if (!editorId) { + console.error("[calendar.items] getCurrent failed: invalid editorId"); + throw new ExtensionError("editorId must be a non-empty opaque editor identifier"); + } + + let win = api._resolveEditorWindow(context, editorId); if (!win) { + console.error("[calendar.items] getCurrent: editor window could not be resolved", { editorId }); return null; } let item = api._getEditedItemForWindow(win); if (!item) { + console.error("[calendar.items] getCurrent: no editable item found in resolved editor window", { editorId }); return null; } - api._ensureLifecycleWatch(context, win); + api._ensureLifecycleWatch(context, win, editorId); const converted = convertItem(item, options, context.extension); if (converted) { - const editorRef = api._buildEditorRef(context, win); - if (editorRef) { - converted.editorRef = editorRef; - } + converted.editorId = editorId; } return converted; }, async updateCurrent(updateOptions) { - let win = api._resolveSnapshotWindow(context, updateOptions?.editorRef); + const editorId = api._getEditorBridge(context.extension).normalizeEditorId(updateOptions?.editorId); + if (!editorId) { + console.error("[calendar.items] updateCurrent failed: invalid editorId"); + throw new ExtensionError("editorId must be a non-empty opaque editor identifier"); + } + + let win = api._resolveEditorWindow(context, editorId); if (!win) { + console.error("[calendar.items] updateCurrent failed: editor window could not be resolved", { editorId }); throw new ExtensionError("Could not resolve target editor window"); } let item = api._getEditedItemForWindow(win); if (!item) { + console.error("[calendar.items] updateCurrent failed: no editable item found in resolved editor window", { editorId }); throw new ExtensionError("Could not find current editor item"); } - api._ensureLifecycleWatch(context, win); + api._ensureLifecycleWatch(context, win, editorId); const fields = updateOptions?.fields && typeof updateOptions.fields == "object" ? updateOptions.fields : {}; const properties = updateOptions?.properties && typeof updateOptions.properties == "object" ? updateOptions.properties : {}; - api._applyFieldUpdates(win, fields); - api._applyPropertyUpdates(item, properties); + if (!Object.keys(fields).length && !Object.keys(properties).length) { + console.error("[calendar.items] updateCurrent failed: neither fields nor properties provided", { editorId }); + throw new ExtensionError("updateCurrent requires at least one field or property update"); + } + + const fieldTargets = api._resolveRequestedFieldTargets(win, fields); + const fieldBeforeValues = api._snapshotResolvedFieldValues(fieldTargets); + let fieldApplied = { + title: false, + location: false, + description: false + }; + api._assertEditorWindowOpen(win, "field updates"); + fieldApplied = api._applyFieldUpdates(win, fields, { + targets: fieldTargets, + beforeValues: fieldBeforeValues, + }); + + api._assertEditorWindowOpen(win, "property updates"); + const propertySnapshot = api._snapshotPropertyValues(item, properties); + let appliedProperties = []; + try { + appliedProperties = api._applyPropertyUpdates(item, properties); + } catch (propertyError) { + try { + api._rollbackPropertyUpdates(item, propertySnapshot, appliedProperties); + } catch (propertyRollbackError) { + console.error("[calendar.items] property rollback failed", propertyRollbackError); + } + try { + api._rollbackFieldUpdates(win, fieldTargets, fieldBeforeValues, fieldApplied); + } catch (fieldRollbackError) { + console.error("[calendar.items] field rollback after property failure failed", fieldRollbackError); + } + throw propertyError; + } + const converted = convertItem(item, updateOptions, context.extension); if (converted) { - const editorRef = api._buildEditorRef(context, win); - if (editorRef) { - converted.editorRef = editorRef; - } + converted.editorId = editorId; } return converted; }, @@ -767,8 +1048,8 @@ this.calendar_items = class extends ExtensionAPI { register: (fire, options) => { const observer = cal.createAdapter(Ci.calIObserver, { onModifyItem: (newItem, _oldItem) => { - // TODO calculate changeInfo - const changeInfo = {}; + // changeInfo currently signals a full item replacement. + const changeInfo = { changeType: "full" }; fire.sync(convertItem(newItem, options, context.extension), changeInfo); }, }); @@ -822,9 +1103,9 @@ this.calendar_items = class extends ExtensionAPI { }, }).api(), - onEditorClosed: new EventManager({ + onTrackedEditorClosed: new EventManager({ context, - name: "calendar.items.onEditorClosed", + name: "calendar.items.onTrackedEditorClosed", register: fire => { const listener = info => { fire.sync(info); diff --git a/calendar/experiments/calendar/parent/ext-calendarItemAction.js b/calendar/experiments/calendar/parent/ext-calendarItemAction.js index af344c0..78a791d 100644 --- a/calendar/experiments/calendar/parent/ext-calendarItemAction.js +++ b/calendar/experiments/calendar/parent/ext-calendarItemAction.js @@ -8,7 +8,18 @@ var { ToolbarButtonAPI } = ChromeUtils.importESModule("resource:///modules/Exten const calendarItemActionMap = new WeakMap(); const CALITEM_EVENT_DIALOG_URL = "chrome://calendar/content/calendar-event-dialog.xhtml"; +const CALITEM_EVENT_TAB_IFRAME_URL = "chrome://calendar/content/calendar-item-iframe.xhtml"; const CALITEM_MESSENGER_URL = "chrome://messenger/content/messenger.xhtml"; +const CALITEM_EDITOR_TAB_MODES = new Set(["calendarEvent", "calendarTask"]); + +function getEditorContextBridgeForExtension(extension) { + const root = `experiments-calendar-${extension.uuid}`; + const query = extension.manifest.version; + const module = ChromeUtils.importESModule( + `resource://${root}/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs?${query}` + ); + return module.getEditorContextBridge(extension); +} this.calendarItemAction = class extends ToolbarButtonAPI { static for(extension) { @@ -16,7 +27,7 @@ this.calendarItemAction = class extends ToolbarButtonAPI { } onStartup() { - // TODO this is only necessary in the experiment, can drop this when moving to core. + // Experiment compatibility path: localized calendar_item_action manifest wiring. const calendarItemAction = this.extension.manifest?.calendar_item_action; if (calendarItemAction) { const localize = this.extension.localize.bind(this.extension); @@ -34,7 +45,7 @@ this.calendarItemAction = class extends ToolbarButtonAPI { this.onManifestEntry("calendar_item_action"); } - // TODO this is only necessary in the experiment, can refactor this when moving to core. + // Experiment compatibility path: ensure popupset exists in the editor dialog. ExtensionSupport.registerWindowListener("ext-calendar-itemAction-" + this.extension.id, { chromeURLs: ["chrome://calendar/content/calendar-event-dialog.xhtml"], onLoadWindow(win) { @@ -43,7 +54,11 @@ this.calendarItemAction = class extends ToolbarButtonAPI { if (!document.getElementById("mainPopupSet")) { const mainPopupSet = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "popupset"); mainPopupSet.id = "mainPopupSet"; - const dialog = document.querySelector("dialog"); + const dialog = document.getElementsByTagName("dialog")[0] || null; + if (!dialog) { + console.error("[calendarItemAction] could not resolve dialog root for popupset injection"); + return; + } dialog.insertBefore(mainPopupSet, dialog.firstElementChild); } } @@ -92,6 +107,10 @@ this.calendarItemAction = class extends ToolbarButtonAPI { close() { super.close(); + if (this._editorBridge) { + this._editorBridge.clear(); + this._editorBridge = null; + } calendarItemActionMap.delete(this.extension); } @@ -124,52 +143,240 @@ this.calendarItemAction = class extends ToolbarButtonAPI { return typeof outerId == "number" ? outerId : null; } - _getEditorClickContext(window) { + _getEditorOuterId(window) { + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } + + _getEditorBridge() { + if (!this._editorBridge) { + this._editorBridge = getEditorContextBridgeForExtension(this.extension); + } + return this._editorBridge; + } + + _ensureDialogReleaseListener(window, editorId) { + if (!window || !editorId) { + return; + } + + if (!this._dialogReleaseByWindow) { + this._dialogReleaseByWindow = new WeakMap(); + } + + const previous = this._dialogReleaseByWindow.get(window); + if (previous?.editorId == editorId) { + return; + } + if (previous?.onUnload) { + window.removeEventListener("unload", previous.onUnload, true); + this._getEditorBridge().releaseEditorId(previous.editorId); + } + + const onUnload = () => { + this._getEditorBridge().releaseEditorId(editorId); + window.removeEventListener("unload", onUnload, true); + this._dialogReleaseByWindow.delete(window); + }; + + window.addEventListener("unload", onUnload, true); + this._dialogReleaseByWindow.set(window, { editorId, onUnload }); + } + + _isCalendarEditorTabInfo(tabInfo) { + const modeName = tabInfo?.mode?.name || ""; + if (CALITEM_EDITOR_TAB_MODES.has(modeName)) { + return true; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const href = editorWindow?.location?.href || ""; + return href.startsWith(CALITEM_EVENT_DIALOG_URL) || href.startsWith(CALITEM_EVENT_TAB_IFRAME_URL); + } + + _isCalendarEditorWindow(window) { const href = window?.location?.href || ""; - if (href == CALITEM_EVENT_DIALOG_URL) { - const dialogOuterId = this._getDialogOuterId(window); - const editorRef = {}; - if (typeof dialogOuterId == "number") { - editorRef.dialogOuterId = dialogOuterId; + return href.startsWith(CALITEM_EVENT_DIALOG_URL) || href.startsWith(CALITEM_EVENT_TAB_IFRAME_URL); + } + + _getSelectedTabInfo(window) { + const tabmail = window?.tabmail || null; + const tabInfoList = Array.isArray(tabmail?.tabInfo) ? tabmail.tabInfo : null; + if (!tabInfoList || !tabInfoList.length) { + return null; + } + + const selectedIndex = tabmail?.tabContainer?.selectedIndex; + if (Number.isInteger(selectedIndex) && selectedIndex >= 0 && selectedIndex < tabInfoList.length) { + return tabInfoList[selectedIndex]; + } + + return tabmail.currentTabInfo || null; + } + + _getManagedTabIdFromTabInfo(tabInfo) { + const tabManager = this.extension?.tabManager; + if (!tabManager || typeof tabManager.getWrapper != "function") { + console.error("[calendarItemAction] tabManager unavailable while resolving tab id"); + return null; + } + if (!this._isCalendarEditorTabInfo(tabInfo)) { + return null; + } + try { + const tabWrapper = tabManager.getWrapper(tabInfo); + const tabId = tabWrapper?.id; + if (typeof tabId == "number") { + return tabId; } - return { - editorType: "dialog", - editorRef, - }; + console.error("[calendarItemAction] tabManager.getWrapper(tabInfo) returned no numeric id", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + }); + return null; + } catch (e) { + console.error("[calendarItemAction] tabManager.getWrapper(tabInfo) failed", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + error: String(e), + }); + return null; + } + } + + _getCalendarTabInfoForEditorWindow(window) { + if (!window || !this._isCalendarEditorWindow(window)) { + return null; + } + const ownerWindow = window.ownerGlobal || null; + if (!ownerWindow || ownerWindow.location?.href != CALITEM_MESSENGER_URL) { + return null; } + const tabInfoList = ownerWindow.tabmail && Array.isArray(ownerWindow.tabmail.tabInfo) + ? ownerWindow.tabmail.tabInfo + : []; + for (const tabInfo of tabInfoList) { + if (!this._isCalendarEditorTabInfo(tabInfo)) { + continue; + } + const tabEditorWindow = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + if (tabEditorWindow == window) { + return tabInfo; + } + } + return null; + } + + _getTabEditorIdFromMessengerWindow(window) { + if (!window || window.location?.href != CALITEM_MESSENGER_URL) { + return null; + } + const tabInfo = this._getSelectedTabInfo(window); + if (!this._isCalendarEditorTabInfo(tabInfo)) { + console.error("[calendarItemAction] current tab is not a calendar editor tab", { + mode: tabInfo?.mode?.name || "", + iframeHref: tabInfo?.iframe?.contentWindow?.location?.href || "", + }); + return null; + } + const tabId = this._getManagedTabIdFromTabInfo(tabInfo); + if (typeof tabId != "number") { + console.error("[calendarItemAction] could not resolve managed tab id for calendar editor tab", { + mode: tabInfo?.mode?.name || "", + iframeHref: tabInfo?.iframe?.contentWindow?.location?.href || "", + iframeId: tabInfo?.iframe?.id || "", + }); + return null; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const editorOuterId = this._getEditorOuterId(editorWindow); + if (typeof editorOuterId != "number") { + console.error("[calendarItemAction] could not resolve tab editor outer window id from messenger window", { + tabId, + mode: tabInfo?.mode?.name || "", + }); + return null; + } + return this._getEditorBridge().registerTabTarget(tabId, editorOuterId); + } + _getTabEditorIdFromEditorWindow(window) { + const tabInfo = this._getCalendarTabInfoForEditorWindow(window); + if (!tabInfo) { + console.error("[calendarItemAction] could not map editor window to tabInfo", { + windowHref: window?.location?.href || "", + }); + return null; + } + const tabId = this._getManagedTabIdFromTabInfo(tabInfo); + if (typeof tabId != "number") { + console.error("[calendarItemAction] could not resolve managed tab id from editor window", { + mode: tabInfo?.mode?.name || "", + }); + return null; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const editorOuterId = this._getEditorOuterId(editorWindow); + if (typeof editorOuterId != "number") { + console.error("[calendarItemAction] could not resolve tab editor outer window id from editor window", { + tabId, + mode: tabInfo?.mode?.name || "", + }); + return null; + } + return this._getEditorBridge().registerTabTarget(tabId, editorOuterId); + } + + _getTriggerWindow(window, editorType) { + if (!window) { + return null; + } + if (editorType == "tab" && window.location?.href == CALITEM_EVENT_DIALOG_URL) { + const ownerWindow = window.ownerGlobal || null; + if (ownerWindow?.location?.href == CALITEM_MESSENGER_URL) { + return ownerWindow; + } + } + return window; + } + + _getEditorClickContext(window) { + const href = window?.location?.href || ""; if (href == CALITEM_MESSENGER_URL) { - const tabInfo = window.tabmail?.currentTabInfo || null; - if (tabInfo?.mode?.name == "calendarEvent") { - const editorRef = {}; - const nativeTab = tabInfo.nativeTab || null; - const tabManager = this.extension?.tabManager; - if (tabManager && typeof tabManager.getWrapper == "function" && nativeTab) { - const tabWrapper = tabManager.getWrapper(nativeTab); - const tabId = tabWrapper?.id; - if (typeof tabId == "number") { - editorRef.tabId = tabId; - } - } - const windowManager = this.extension?.windowManager; - if (windowManager && typeof windowManager.getWrapper == "function") { - const windowWrapper = windowManager.getWrapper(window); - const windowId = windowWrapper?.id; - if (typeof windowId == "number") { - editorRef.windowId = windowId; - } - } + const editorId = this._getTabEditorIdFromMessengerWindow(window); + if (editorId) { return { editorType: "tab", - editorRef, + editorId, }; } + return null; } - return { - editorType: "unknown", - editorRef: {}, - }; + if (href == CALITEM_EVENT_DIALOG_URL) { + const tabEditorId = this._getTabEditorIdFromEditorWindow(window); + if (tabEditorId) { + return { + editorType: "tab", + editorId: tabEditorId, + }; + } + + const dialogOuterId = this._getDialogOuterId(window); + if (typeof dialogOuterId != "number") { + return null; + } + const dialogEditorId = this._getEditorBridge().registerDialogTarget(dialogOuterId); + if (!dialogEditorId) { + return null; + } + this._ensureDialogReleaseListener(window, dialogEditorId); + return { + editorType: "dialog", + editorId: dialogEditorId, + }; + } + + return null; } handleEvent(event) { @@ -184,13 +391,28 @@ this.calendarItemAction = class extends ToolbarButtonAPI { } const clickContext = this._getEditorClickContext(window); + if (!clickContext) { + console.error("[calendarItemAction] click ignored: could not resolve editor context", { + windowHref: window?.location?.href || "", + targetTag: event.target?.tagName || "", + }); + return; + } + const triggerWindow = this._getTriggerWindow(window, clickContext.editorType); + if (!triggerWindow) { + console.error("[calendarItemAction] click ignored: could not resolve trigger window", { + editorType: clickContext.editorType, + windowHref: window?.location?.href || "", + }); + return; + } this.lastClickInfo = { button: 0, modifiers: this.global.clickModifiersFromEvent(event), editorType: clickContext.editorType, - editorRef: clickContext.editorRef, + editorId: clickContext.editorId, }; - this.triggerAction(window); + this.triggerAction(triggerWindow); return; } @@ -224,10 +446,13 @@ this.calendarItemAction = class extends ToolbarButtonAPI { return; } - // TODO browserAction uses static onUninstall, this doesn't work in an experiment. // Do not mutate xulStore during shutdown to preserve user toolbar customizations on upgrades. const extensionId = this.extension.id; ExtensionSupport.unregisterWindowListener("ext-calendar-itemAction-" + extensionId); + if (this._editorBridge) { + this._editorBridge.clear(); + this._editorBridge = null; + } } }; diff --git a/calendar/experiments/calendar/schema/calendar-items.json b/calendar/experiments/calendar/schema/calendar-items.json index 97368d1..60f21d3 100644 --- a/calendar/experiments/calendar/schema/calendar-items.json +++ b/calendar/experiments/calendar/schema/calendar-items.json @@ -5,7 +5,7 @@ { "id": "CalendarItem", "type": "object", - "description": "TODO split event/task. Add more jscalendar props. Use format: 'date'.", + "description": "Calendar item envelope used by this API. `item` contains the payload in the selected return format.", "properties": { "id": { "type": "string" }, "calendarId": { "type": "string" }, @@ -13,7 +13,7 @@ "instance": { "type": "string", "optional": true }, "format": { "$ref": "CalendarItemFormats", "optional": true }, "item": { "$ref": "RawCalendarItem" }, - "editorRef": { "$ref": "EditorRef", "optional": true }, + "editorId": { "$ref": "EditorId", "optional": true }, "metadata": { "type": "object", "additionalProperties": { "type": "any" }, "optional": true } } }, @@ -36,7 +36,7 @@ { "id": "CalendarItemAlarm", "type": "object", - "description": "TODO needs real structure", + "description": "Alarm payload emitted by onAlarm for the associated calendar item.", "properties": { "itemId": { "type": "string" }, "action": { "type": "string" }, @@ -46,18 +46,15 @@ } }, { - "id": "EditorRef", - "type": "object", - "description": "Reference to an open calendar editor context.", - "properties": { - "tabId": { "type": "integer", "optional": true }, - "windowId": { "type": "integer", "optional": true }, - "dialogOuterId": { "type": "integer", "optional": true } - } + "id": "EditorId", + "type": "string", + "description": "Opaque identifier for one specific open editor instance. Do not parse. Valid only while that exact editor instance remains open in the current Thunderbird session.", + "pattern": "^ed-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }, { "id": "EditorFieldUpdates", "type": "object", + "description": "UI field updates in the open editor. Uses dedicated field identifiers: title (`item-title`), location (`item-location`), description (`item-description`).", "properties": { "title": { "type": "string", "optional": true }, "location": { "type": "string", "optional": true }, @@ -67,6 +64,7 @@ { "id": "EditorPropertyUpdates", "type": "object", + "description": "Calendar item property updates. A string value sets/replaces a property value. A null value removes the property.", "additionalProperties": { "choices": [ { "type": "string" }, @@ -77,15 +75,35 @@ { "id": "EditorClosedAction", "type": "string", + "description": "persisted: emitted for dialogaccept or dialogextra1. discarded: emitted for dialogcancel, dialogextra2, or unload. superseded: tracked editorId was replaced by a new editorId for the same UI target.", "enum": ["persisted", "discarded", "superseded"] }, + { + "id": "EditorClosedReason", + "type": "string", + "description": "Underlying UI signal that produced the close action.", + "enum": ["dialogaccept", "dialogextra1", "dialogcancel", "dialogextra2", "unload", "re-bound"] + }, { "id": "EditorClosedInfo", "type": "object", + "description": "Lifecycle payload for one tracked editorId emitted by onTrackedEditorClosed.", "properties": { - "editorRef": { "$ref": "EditorRef" }, + "editorId": { "$ref": "EditorId" }, "action": { "$ref": "EditorClosedAction" }, - "reason": { "type": "string", "optional": true } + "reason": { "$ref": "EditorClosedReason", "optional": true } + } + }, + { + "id": "ItemChangeInfo", + "type": "object", + "description": "Change metadata for onUpdated. Currently `changeType` is always `full`.", + "properties": { + "changeType": { + "type": "string", + "enum": ["full"], + "optional": true + } } } ], @@ -195,14 +213,14 @@ "name": "getCurrent", "async": true, "type": "function", + "description": "Get the current item for the editor identified by editorId. Returns null if that editor is no longer open or if no editable item can be resolved in that editor.", "parameters": [ { "type": "object", "name": "getOptions", - "optional": true, "properties": { "returnFormat": { "$ref": "ReturnFormat", "optional": true }, - "editorRef": { "$ref": "EditorRef", "optional": true } + "editorId": { "$ref": "EditorId" } } } ] @@ -211,12 +229,13 @@ "name": "updateCurrent", "async": true, "type": "function", + "description": "Update the current item for the editor identified by editorId. Requires at least one update via fields or properties. All requested UI fields are resolved before writing starts. The operation is transactional across fields and properties: if a field write fails, field changes are rolled back; if a property write fails, applied properties are rolled back and previously applied field changes are rolled back as well. Throws if the editor cannot be resolved or if any requested field target is unavailable.", "parameters": [ { "type": "object", "name": "updateOptions", "properties": { - "editorRef": { "$ref": "EditorRef", "optional": true }, + "editorId": { "$ref": "EditorId" }, "fields": { "$ref": "EditorFieldUpdates", "optional": true }, "properties": { "$ref": "EditorPropertyUpdates", "optional": true }, "returnFormat": { "$ref": "ReturnFormat", "optional": true } @@ -247,7 +266,7 @@ "type": "function", "parameters": [ { "name": "item", "$ref": "CalendarItem" }, - { "name": "changeInfo", "type": "object", "properties": {}, "description": "TODO needs properties" } + { "name": "changeInfo", "$ref": "ItemChangeInfo" } ], "extraParameters": [ { @@ -285,8 +304,9 @@ ] }, { - "name": "onEditorClosed", + "name": "onTrackedEditorClosed", "type": "function", + "description": "Fired for tracked editors only, after getCurrent() or updateCurrent() has been called for that editorId. Emitted once per tracked editorId. action reflects the exact close signal defined by EditorClosedAction and reason. No ordering guarantee relative to onCreated/onUpdated/onRemoved.", "parameters": [ { "name": "info", "$ref": "EditorClosedInfo" } ] diff --git a/calendar/experiments/calendar/schema/calendarItemAction.json b/calendar/experiments/calendar/schema/calendarItemAction.json index 0631183..7fab306 100644 --- a/calendar/experiments/calendar/schema/calendarItemAction.json +++ b/calendar/experiments/calendar/schema/calendarItemAction.json @@ -99,26 +99,10 @@ } }, { - "id": "EditorRef", - "type": "object", - "description": "Reference to the currently active event editor context.", - "properties": { - "tabId": { - "type": "integer", - "optional": true, - "description": "The extension tab id for tab based event editors." - }, - "windowId": { - "type": "integer", - "optional": true, - "description": "The extension window id for tab based editors." - }, - "dialogOuterId": { - "type": "integer", - "optional": true, - "description": "The native outer window id for dialog based editors." - } - } + "id": "EditorId", + "type": "string", + "description": "Opaque identifier for one specific open editor instance. Do not parse. Valid only while that exact editor instance remains open in the current Thunderbird session.", + "pattern": "^ed-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }, { "id": "OnClickData", @@ -138,14 +122,14 @@ "optional": true, "description": "An integer value of button by which menu item was clicked." }, - "editorRef": { - "$ref": "EditorRef", + "editorId": { + "$ref": "EditorId", "optional": true, - "description": "Reference to the editor context where the action was invoked." + "description": "Opaque identifier of the concrete editor context where the action was invoked." }, "editorType": { "type": "string", - "enum": ["dialog", "tab", "unknown"], + "enum": ["dialog", "tab"], "optional": true, "description": "Editor variant where the action was invoked." }