diff options
Diffstat (limited to 'content')
63 files changed, 3804 insertions, 0 deletions
diff --git a/content/api/BootstrapLoader/CHANGELOG.md b/content/api/BootstrapLoader/CHANGELOG.md new file mode 100644 index 0000000..5006ecf --- /dev/null +++ b/content/api/BootstrapLoader/CHANGELOG.md @@ -0,0 +1,75 @@ +Version: 1.21 +------------- +- Explicitly set hasAddonManagerEventListeners flag to false on uninstall + +Version: 1.20 +------------- +- hard fork BootstrapLoader v1.19 implementation and continue to serve it for + Thunderbird 111 and older +- BootstrapLoader v1.20 has removed a lot of unnecessary code used for backward + compatibility + +Version: 1.19 +------------- +- fix race condition which could prevent the AOM tab to be monkey patched correctly + +Version: 1.18 +------------- +- be precise on which revision the wrench symbol should be displayed, instead of + the options button + +Version: 1.17 +------------- +- fix "ownerDoc.getElementById() is undefined" bug + +Version: 1.16 +------------- +- fix "tab.browser is undefined" bug + +Version 1.15 +------------ +- clear cache only if add-on is uninstalled/updated, not on app shutdown + +Version 1.14 +------------ +- fix for TB90 ("view-loaded" event) and TB78.10 (wrench icon for options) + +Version 1.13 +------------ +- removed notifyTools and move it into its own NotifyTools API + +Version 1.12 +------------ +- add support for notifyExperiment and onNotifyBackground + +Version 1.11 +------------ +- add openOptionsDialog() + +Version 1.10 +------------ +- fix for 68 + +Version 1.7 +----------- +- fix for beta 87 + +Version 1.6 +----------- +- add support for options button/menu in add-on manager and fix 68 double menu entry + +Version 1.5 +----------- +- fix for e10s + +Version 1.4 +----------- +- add registerOptionsPage + +Version 1.3 +----------- +- flush cache + +Version 1.2 +----------- +- add support for resource urls diff --git a/content/api/BootstrapLoader/README.md b/content/api/BootstrapLoader/README.md new file mode 100644 index 0000000..7e8fe2a --- /dev/null +++ b/content/api/BootstrapLoader/README.md @@ -0,0 +1 @@ +Usage description can be found in the [wiki](https://github.com/thundernest/addon-developer-support/wiki/Using-the-BootstrapLoader-API-to-convert-a-Legacy-Bootstrap-WebExtension-into-a-MailExtension-for-Thunderbird-78). diff --git a/content/api/BootstrapLoader/implementation.js b/content/api/BootstrapLoader/implementation.js new file mode 100644 index 0000000..03e6b76 --- /dev/null +++ b/content/api/BootstrapLoader/implementation.js @@ -0,0 +1,917 @@ +/* + * This file is provided by the addon-developer-support repository at + * https://github.com/thundernest/addon-developer-support + * + * Version: 1.21 + * + * Author: John Bieling (john@thunderbird.net) + * + * 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/. + */ + +// Get various parts of the WebExtension framework that we need. +var { ExtensionCommon } = ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm"); +var { ExtensionSupport } = ChromeUtils.import("resource:///modules/ExtensionSupport.jsm"); +var { AddonManager } = ChromeUtils.import("resource://gre/modules/AddonManager.jsm"); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +function getThunderbirdVersion() { + let parts = Services.appinfo.version.split("."); + return { + major: parseInt(parts[0]), + minor: parseInt(parts[1]), + revision: parts.length > 2 ? parseInt(parts[2]) : 0, + } +} + +function getMessenger(context) { + let apis = ["storage", "runtime", "extension", "i18n"]; + + function getStorage() { + let localstorage = null; + try { + localstorage = context.apiCan.findAPIPath("storage"); + localstorage.local.get = (...args) => + localstorage.local.callMethodInParentProcess("get", args); + localstorage.local.set = (...args) => + localstorage.local.callMethodInParentProcess("set", args); + localstorage.local.remove = (...args) => + localstorage.local.callMethodInParentProcess("remove", args); + localstorage.local.clear = (...args) => + localstorage.local.callMethodInParentProcess("clear", args); + } catch (e) { + console.info("Storage permission is missing"); + } + return localstorage; + } + + let messenger = {}; + for (let api of apis) { + switch (api) { + case "storage": + XPCOMUtils.defineLazyGetter(messenger, "storage", () => + getStorage() + ); + break; + + default: + XPCOMUtils.defineLazyGetter(messenger, api, () => + context.apiCan.findAPIPath(api) + ); + } + } + return messenger; +} + +var BootstrapLoader_102 = class extends ExtensionCommon.ExtensionAPI { + getCards(e) { + // This gets triggered by real events but also manually by providing the outer window. + // The event is attached to the outer browser, get the inner one. + let doc; + + // 78,86, and 87+ need special handholding. *Yeah*. + if (getThunderbirdVersion().major < 86) { + let ownerDoc = e.document || e.target.ownerDocument; + doc = ownerDoc.getElementById("html-view-browser").contentDocument; + } else if (getThunderbirdVersion().major < 87) { + let ownerDoc = e.document || e.target; + doc = ownerDoc.getElementById("html-view-browser").contentDocument; + } else { + doc = e.document || e.target; + } + return doc.querySelectorAll("addon-card"); + } + + // Add pref entry to 68 + add68PrefsEntry(event) { + let id = this.menu_addonPrefs_id + "_" + this.uniqueRandomID; + + // Get the best size of the icon (16px or bigger) + let iconSizes = this.extension.manifest.icons + ? Object.keys(this.extension.manifest.icons) + : []; + iconSizes.sort((a, b) => a - b); + let bestSize = iconSizes.filter(e => parseInt(e) >= 16).shift(); + let icon = bestSize ? this.extension.manifest.icons[bestSize] : ""; + + let name = this.extension.manifest.name; + let entry = icon + ? event.target.ownerGlobal.MozXULElement.parseXULToFragment( + `<menuitem class="menuitem-iconic" id="${id}" image="${icon}" label="${name}" />`) + : event.target.ownerGlobal.MozXULElement.parseXULToFragment( + `<menuitem id="${id}" label="${name}" />`); + + event.target.appendChild(entry); + let noPrefsElem = event.target.querySelector('[disabled="true"]'); + // using collapse could be undone by core, so we use display none + // noPrefsElem.setAttribute("collapsed", "true"); + noPrefsElem.style.display = "none"; + event.target.ownerGlobal.document.getElementById(id).addEventListener("command", this); + } + + // Event handler for the addon manager, to update the state of the options button. + handleEvent(e) { + switch (e.type) { + // 68 add-on options menu showing + case "popupshowing": { + this.add68PrefsEntry(e); + } + break; + + // 78/88 add-on options menu/button click + case "click": { + e.preventDefault(); + e.stopPropagation(); + let BL = {} + BL.extension = this.extension; + BL.messenger = getMessenger(this.context); + let w = Services.wm.getMostRecentWindow("mail:3pane"); + w.openDialog(this.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + } + break; + + // 68 add-on options menu command + case "command": { + let BL = {} + BL.extension = this.extension; + BL.messenger = getMessenger(this.context); + e.target.ownerGlobal.openDialog(this.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + } + break; + + // update, ViewChanged and manual call for add-on manager options overlay + default: { + let cards = this.getCards(e); + for (let card of cards) { + // Setup either the options entry in the menu or the button + if (card.addon.id == this.extension.id) { + let optionsMenu = + (getThunderbirdVersion().major > 78 && getThunderbirdVersion().major < 88) || + (getThunderbirdVersion().major == 78 && getThunderbirdVersion().minor < 10) || + (getThunderbirdVersion().major == 78 && getThunderbirdVersion().minor == 10 && getThunderbirdVersion().revision < 2); + if (optionsMenu) { + // Options menu in 78.0-78.10 and 79-87 + let addonOptionsLegacyEntry = card.querySelector(".extension-options-legacy"); + if (card.addon.isActive && !addonOptionsLegacyEntry) { + let addonOptionsEntry = card.querySelector("addon-options panel-list panel-item[action='preferences']"); + addonOptionsLegacyEntry = card.ownerDocument.createElement("panel-item"); + addonOptionsLegacyEntry.setAttribute("data-l10n-id", "preferences-addon-button"); + addonOptionsLegacyEntry.classList.add("extension-options-legacy"); + addonOptionsEntry.parentNode.insertBefore( + addonOptionsLegacyEntry, + addonOptionsEntry + ); + card.querySelector(".extension-options-legacy").addEventListener("click", this); + } else if (!card.addon.isActive && addonOptionsLegacyEntry) { + addonOptionsLegacyEntry.remove(); + } + } else { + // Add-on button in 88 + let addonOptionsButton = card.querySelector(".extension-options-button2"); + if (card.addon.isActive && !addonOptionsButton) { + addonOptionsButton = card.ownerDocument.createElement("button"); + addonOptionsButton.classList.add("extension-options-button2"); + addonOptionsButton.style["min-width"] = "auto"; + addonOptionsButton.style["min-height"] = "auto"; + addonOptionsButton.style["width"] = "24px"; + addonOptionsButton.style["height"] = "24px"; + addonOptionsButton.style["margin"] = "0"; + addonOptionsButton.style["margin-inline-start"] = "8px"; + addonOptionsButton.style["-moz-context-properties"] = "fill"; + addonOptionsButton.style["fill"] = "currentColor"; + addonOptionsButton.style["background-image"] = "url('chrome://messenger/skin/icons/developer.svg')"; + addonOptionsButton.style["background-repeat"] = "no-repeat"; + addonOptionsButton.style["background-position"] = "center center"; + addonOptionsButton.style["padding"] = "1px"; + addonOptionsButton.style["display"] = "flex"; + addonOptionsButton.style["justify-content"] = "flex-end"; + card.optionsButton.parentNode.insertBefore( + addonOptionsButton, + card.optionsButton + ); + card.querySelector(".extension-options-button2").addEventListener("click", this); + } else if (!card.addon.isActive && addonOptionsButton) { + addonOptionsButton.remove(); + } + } + } + } + } + } + } + + // Some tab/add-on-manager related functions + getTabMail(window) { + return window.document.getElementById("tabmail"); + } + + // returns the outer browser, not the nested browser of the add-on manager + // events must be attached to the outer browser + getAddonManagerFromTab(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let win = tab.browser.contentWindow; + if (win && win.location.href == "about:addons") { + return win; + } + } + } + + getAddonManagerFromWindow(window) { + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + + async getAddonManagerFromWindowWaitForLoad(window) { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + if (tab.browser && tab.mode.name == "contentTab") { + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + } + + setupAddonManager(managerWindow, forceLoad = false) { + if (!managerWindow) { + return; + } + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + return; + } + managerWindow.document.addEventListener("ViewChanged", this); + managerWindow.document.addEventListener("update", this); + managerWindow.document.addEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID] = {}; + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = true; + if (forceLoad) { + this.handleEvent(managerWindow); + } + } + + getAPI(context) { + this.uniqueRandomID = "AddOnNS" + context.extension.instanceId; + this.menu_addonPrefs_id = "addonPrefs"; + + + this.pathToBootstrapScript = null; + this.pathToOptionsPage = null; + this.chromeHandle = null; + this.chromeData = null; + this.resourceData = null; + this.bootstrappedObj = {}; + + // make the extension object and the messenger object available inside + // the bootstrapped scope + this.bootstrappedObj.extension = context.extension; + this.bootstrappedObj.messenger = getMessenger(this.context); + + this.BOOTSTRAP_REASONS = { + APP_STARTUP: 1, + APP_SHUTDOWN: 2, + ADDON_ENABLE: 3, + ADDON_DISABLE: 4, + ADDON_INSTALL: 5, + ADDON_UNINSTALL: 6, // not supported + ADDON_UPGRADE: 7, + ADDON_DOWNGRADE: 8, + }; + + const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(Ci.amIAddonManagerStartup); + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + + let self = this; + + // TabMonitor to detect opening of tabs, to setup the options button in the add-on manager. + this.tabMonitor = { + onTabTitleChanged(tab) { }, + onTabClosing(tab) { }, + onTabPersist(tab) { }, + onTabRestored(tab) { }, + onTabSwitched(aNewTab, aOldTab) { }, + async onTabOpened(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + self.setupAddonManager(self.getAddonManagerFromTab(tab)); + } + }, + }; + + return { + BootstrapLoader: { + + registerOptionsPage(optionsUrl) { + self.pathToOptionsPage = optionsUrl.startsWith("chrome://") + ? optionsUrl + : context.extension.rootURI.resolve(optionsUrl); + }, + + openOptionsDialog(windowId) { + let window = context.extension.windowManager.get(windowId, context).window + let BL = {} + BL.extension = self.extension; + BL.messenger = getMessenger(self.context); + window.openDialog(self.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + }, + + registerChromeUrl(data) { + let chromeData = []; + let resourceData = []; + for (let entry of data) { + if (entry[0] == "resource") resourceData.push(entry); + else chromeData.push(entry) + } + + if (chromeData.length > 0) { + const manifestURI = Services.io.newURI( + "manifest.json", + null, + context.extension.rootURI + ); + self.chromeHandle = aomStartup.registerChrome(manifestURI, chromeData); + } + + for (let res of resourceData) { + // [ "resource", "shortname" , "path" ] + let uri = Services.io.newURI( + res[2], + null, + context.extension.rootURI + ); + resProto.setSubstitutionWithFlags( + res[1], + uri, + resProto.ALLOW_CONTENT_ACCESS + ); + } + + self.chromeData = chromeData; + self.resourceData = resourceData; + }, + + registerBootstrapScript: async function (aPath) { + self.pathToBootstrapScript = aPath.startsWith("chrome://") + ? aPath + : context.extension.rootURI.resolve(aPath); + + // Get the addon object belonging to this extension. + let addon = await AddonManager.getAddonByID(context.extension.id); + //make the addon globally available in the bootstrapped scope + self.bootstrappedObj.addon = addon; + + // add BOOTSTRAP_REASONS to scope + for (let reason of Object.keys(self.BOOTSTRAP_REASONS)) { + self.bootstrappedObj[reason] = self.BOOTSTRAP_REASONS[reason]; + } + + // Load registered bootstrap scripts and execute its startup() function. + try { + if (self.pathToBootstrapScript) Services.scriptloader.loadSubScript(self.pathToBootstrapScript, self.bootstrappedObj, "UTF-8"); + if (self.bootstrappedObj.startup) self.bootstrappedObj.startup.call(self.bootstrappedObj, self.extension.addonData, self.BOOTSTRAP_REASONS[self.extension.startupReason]); + } catch (e) { + Components.utils.reportError(e) + } + + // Register window listener for main TB window + if (self.pathToOptionsPage) { + ExtensionSupport.registerWindowListener("injectListener_" + self.uniqueRandomID, { + chromeURLs: [ + "chrome://messenger/content/messenger.xul", + "chrome://messenger/content/messenger.xhtml", + ], + async onLoadWindow(window) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(self.menu_addonPrefs_id); + element_addonPrefs.addEventListener("popupshowing", self); + } else { + // Add a tabmonitor, to be able to setup the options button/menu in the add-on manager. + self.getTabMail(window).registerTabMonitor(self.tabMonitor); + window[self.uniqueRandomID] = {}; + window[self.uniqueRandomID].hasTabMonitor = true; + // Setup the options button/menu in the add-on manager, if it is already open. + let managerWindow = await self.getAddonManagerFromWindowWaitForLoad(window); + self.setupAddonManager(managerWindow, true); + } + }, + + onUnloadWindow(window) { + } + }); + } + } + } + }; + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; // the application gets unloaded anyway + } + + //remove our entry in the add-on options menu + if (this.pathToOptionsPage) { + for (let window of Services.wm.getEnumerator("mail:3pane")) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(this.menu_addonPrefs_id); + element_addonPrefs.removeEventListener("popupshowing", this); + // Remove our entry. + let entry = window.document.getElementById(this.menu_addonPrefs_id + "_" + this.uniqueRandomID); + if (entry) entry.remove(); + // Do we have to unhide the noPrefsElement? + if (element_addonPrefs.children.length == 1) { + let noPrefsElem = element_addonPrefs.querySelector('[disabled="true"]'); + noPrefsElem.style.display = "inline"; + } + } else { + // Remove event listener for addon manager view changes + let managerWindow = this.getAddonManagerFromWindow(window); + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + managerWindow.document.removeEventListener("ViewChanged", this); + managerWindow.document.removeEventListener("update", this); + managerWindow.document.removeEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = false; + + let cards = this.getCards(managerWindow); + if (getThunderbirdVersion().major < 88) { + // Remove options menu in 78-87 + for (let card of cards) { + let addonOptionsLegacyEntry = card.querySelector(".extension-options-legacy"); + if (addonOptionsLegacyEntry) addonOptionsLegacyEntry.remove(); + } + } else { + // Remove options button in 88 + for (let card of cards) { + if (card.addon.id == this.extension.id) { + let addonOptionsButton = card.querySelector(".extension-options-button2"); + if (addonOptionsButton) addonOptionsButton.remove(); + break; + } + } + } + } + + // Remove tabmonitor + if (window[this.uniqueRandomID].hasTabMonitor) { + this.getTabMail(window).unregisterTabMonitor(this.tabMonitor); + window[this.uniqueRandomID].hasTabMonitor = false; + } + + } + } + // Stop listening for new windows. + ExtensionSupport.unregisterWindowListener("injectListener_" + this.uniqueRandomID); + } + + // Execute registered shutdown() + try { + if (this.bootstrappedObj.shutdown) { + this.bootstrappedObj.shutdown( + this.extension.addonData, + isAppShutdown + ? this.BOOTSTRAP_REASONS.APP_SHUTDOWN + : this.BOOTSTRAP_REASONS.ADDON_DISABLE); + } + } catch (e) { + Components.utils.reportError(e) + } + + if (this.resourceData) { + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + for (let res of this.resourceData) { + // [ "resource", "shortname" , "path" ] + resProto.setSubstitution( + res[1], + null, + ); + } + } + + if (this.chromeHandle) { + this.chromeHandle.destruct(); + this.chromeHandle = null; + } + // Flush all caches + Services.obs.notifyObservers(null, "startupcache-invalidate"); + console.log("BootstrapLoader for " + this.extension.id + " unloaded!"); + } +}; + +// Removed all extra code for backward compatibility for better maintainability. +var BootstrapLoader_115 = class extends ExtensionCommon.ExtensionAPI { + getCards(e) { + // This gets triggered by real events but also manually by providing the outer window. + // The event is attached to the outer browser, get the inner one. + let doc = e.document || e.target; + return doc.querySelectorAll("addon-card"); + } + + // Event handler for the addon manager, to update the state of the options button. + handleEvent(e) { + switch (e.type) { + case "click": { + e.preventDefault(); + e.stopPropagation(); + let BL = {} + BL.extension = this.extension; + BL.messenger = getMessenger(this.context); + let w = Services.wm.getMostRecentWindow("mail:3pane"); + w.openDialog( + this.pathToOptionsPage, + "AddonOptions", + "chrome,resizable,centerscreen", + BL + ); + } + break; + + + // update, ViewChanged and manual call for add-on manager options overlay + default: { + let cards = this.getCards(e); + for (let card of cards) { + // Setup either the options entry in the menu or the button + if (card.addon.id == this.extension.id) { + // Add-on button + let addonOptionsButton = card.querySelector( + ".windowlistener-options-button" + ); + if (card.addon.isActive && !addonOptionsButton) { + let origAddonOptionsButton = card.querySelector(".extension-options-button") + origAddonOptionsButton.setAttribute("hidden", "true"); + + addonOptionsButton = card.ownerDocument.createElement("button"); + addonOptionsButton.classList.add("windowlistener-options-button"); + addonOptionsButton.classList.add("extension-options-button"); + card.optionsButton.parentNode.insertBefore( + addonOptionsButton, + card.optionsButton + ); + card + .querySelector(".windowlistener-options-button") + .addEventListener("click", this); + } else if (!card.addon.isActive && addonOptionsButton) { + addonOptionsButton.remove(); + } + } + } + } + } + } + + // Some tab/add-on-manager related functions + getTabMail(window) { + return window.document.getElementById("tabmail"); + } + + // returns the outer browser, not the nested browser of the add-on manager + // events must be attached to the outer browser + getAddonManagerFromTab(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let win = tab.browser.contentWindow; + if (win && win.location.href == "about:addons") { + return win; + } + } + } + + getAddonManagerFromWindow(window) { + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + + async getAddonManagerFromWindowWaitForLoad(window) { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + if (tab.browser && tab.mode.name == "contentTab") { + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + } + + setupAddonManager(managerWindow, forceLoad = false) { + if (!managerWindow) { + return; + } + if (!this.pathToOptionsPage) { + return; + } + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + return; + } + + managerWindow.document.addEventListener("ViewChanged", this); + managerWindow.document.addEventListener("update", this); + managerWindow.document.addEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID] = {}; + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = true; + if (forceLoad) { + this.handleEvent(managerWindow); + } + } + + getAPI(context) { + this.uniqueRandomID = "AddOnNS" + context.extension.instanceId; + this.menu_addonPrefs_id = "addonPrefs"; + + + this.pathToBootstrapScript = null; + this.pathToOptionsPage = null; + this.chromeHandle = null; + this.chromeData = null; + this.resourceData = null; + this.bootstrappedObj = {}; + + // make the extension object and the messenger object available inside + // the bootstrapped scope + this.bootstrappedObj.extension = context.extension; + this.bootstrappedObj.messenger = getMessenger(this.context); + + this.BOOTSTRAP_REASONS = { + APP_STARTUP: 1, + APP_SHUTDOWN: 2, + ADDON_ENABLE: 3, + ADDON_DISABLE: 4, + ADDON_INSTALL: 5, + ADDON_UNINSTALL: 6, // not supported + ADDON_UPGRADE: 7, + ADDON_DOWNGRADE: 8, + }; + + const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(Ci.amIAddonManagerStartup); + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + + let self = this; + + // TabMonitor to detect opening of tabs, to setup the options button in the add-on manager. + this.tabMonitor = { + onTabTitleChanged(tab) { }, + onTabClosing(tab) { }, + onTabPersist(tab) { }, + onTabRestored(tab) { }, + onTabSwitched(aNewTab, aOldTab) { }, + async onTabOpened(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + self.setupAddonManager(self.getAddonManagerFromTab(tab)); + } + }, + }; + + return { + BootstrapLoader: { + + registerOptionsPage(optionsUrl) { + self.pathToOptionsPage = optionsUrl.startsWith("chrome://") + ? optionsUrl + : context.extension.rootURI.resolve(optionsUrl); + }, + + openOptionsDialog(windowId) { + let window = context.extension.windowManager.get(windowId, context).window + let BL = {} + BL.extension = self.extension; + BL.messenger = getMessenger(self.context); + window.openDialog(self.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + }, + + registerChromeUrl(data) { + let chromeData = []; + let resourceData = []; + for (let entry of data) { + if (entry[0] == "resource") resourceData.push(entry); + else chromeData.push(entry) + } + + if (chromeData.length > 0) { + const manifestURI = Services.io.newURI( + "manifest.json", + null, + context.extension.rootURI + ); + self.chromeHandle = aomStartup.registerChrome(manifestURI, chromeData); + } + + for (let res of resourceData) { + // [ "resource", "shortname" , "path" ] + let uri = Services.io.newURI( + res[2], + null, + context.extension.rootURI + ); + resProto.setSubstitutionWithFlags( + res[1], + uri, + resProto.ALLOW_CONTENT_ACCESS + ); + } + + self.chromeData = chromeData; + self.resourceData = resourceData; + }, + + registerBootstrapScript: async function (aPath) { + self.pathToBootstrapScript = aPath.startsWith("chrome://") + ? aPath + : context.extension.rootURI.resolve(aPath); + + // Get the addon object belonging to this extension. + let addon = await AddonManager.getAddonByID(context.extension.id); + //make the addon globally available in the bootstrapped scope + self.bootstrappedObj.addon = addon; + + // add BOOTSTRAP_REASONS to scope + for (let reason of Object.keys(self.BOOTSTRAP_REASONS)) { + self.bootstrappedObj[reason] = self.BOOTSTRAP_REASONS[reason]; + } + + // Load registered bootstrap scripts and execute its startup() function. + try { + if (self.pathToBootstrapScript) Services.scriptloader.loadSubScript(self.pathToBootstrapScript, self.bootstrappedObj, "UTF-8"); + if (self.bootstrappedObj.startup) self.bootstrappedObj.startup.call(self.bootstrappedObj, self.extension.addonData, self.BOOTSTRAP_REASONS[self.extension.startupReason]); + } catch (e) { + Components.utils.reportError(e) + } + + // Register window listener for main TB window + if (self.pathToOptionsPage) { + ExtensionSupport.registerWindowListener("injectListener_" + self.uniqueRandomID, { + chromeURLs: [ + "chrome://messenger/content/messenger.xul", + "chrome://messenger/content/messenger.xhtml", + ], + async onLoadWindow(window) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(self.menu_addonPrefs_id); + element_addonPrefs.addEventListener("popupshowing", self); + } else { + // Add a tabmonitor, to be able to setup the options button/menu in the add-on manager. + self.getTabMail(window).registerTabMonitor(self.tabMonitor); + window[self.uniqueRandomID] = {}; + window[self.uniqueRandomID].hasTabMonitor = true; + // Setup the options button/menu in the add-on manager, if it is already open. + let managerWindow = await self.getAddonManagerFromWindowWaitForLoad(window); + self.setupAddonManager(managerWindow, true); + } + }, + + onUnloadWindow(window) { + } + }); + } + } + } + }; + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; // the application gets unloaded anyway + } + + //remove our entry in the add-on options menu + if (this.pathToOptionsPage) { + for (let window of Services.wm.getEnumerator("mail:3pane")) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(this.menu_addonPrefs_id); + element_addonPrefs.removeEventListener("popupshowing", this); + // Remove our entry. + let entry = window.document.getElementById(this.menu_addonPrefs_id + "_" + this.uniqueRandomID); + if (entry) entry.remove(); + // Do we have to unhide the noPrefsElement? + if (element_addonPrefs.children.length == 1) { + let noPrefsElem = element_addonPrefs.querySelector('[disabled="true"]'); + noPrefsElem.style.display = "inline"; + } + } else { + // Remove event listener for addon manager view changes + let managerWindow = this.getAddonManagerFromWindow(window); + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + managerWindow.document.removeEventListener("ViewChanged", this); + managerWindow.document.removeEventListener("update", this); + managerWindow.document.removeEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = false; + + let cards = this.getCards(managerWindow); + if (getThunderbirdVersion().major < 88) { + // Remove options menu in 78-87 + for (let card of cards) { + let addonOptionsLegacyEntry = card.querySelector(".extension-options-legacy"); + if (addonOptionsLegacyEntry) addonOptionsLegacyEntry.remove(); + } + } else { + // Remove options button in 88 + for (let card of cards) { + if (card.addon.id == this.extension.id) { + let addonOptionsButton = card.querySelector(".extension-options-button2"); + if (addonOptionsButton) addonOptionsButton.remove(); + break; + } + } + } + } + + // Remove tabmonitor + if (window[this.uniqueRandomID].hasTabMonitor) { + this.getTabMail(window).unregisterTabMonitor(this.tabMonitor); + window[this.uniqueRandomID].hasTabMonitor = false; + } + + } + } + // Stop listening for new windows. + ExtensionSupport.unregisterWindowListener("injectListener_" + this.uniqueRandomID); + } + + // Execute registered shutdown() + try { + if (this.bootstrappedObj.shutdown) { + this.bootstrappedObj.shutdown( + this.extension.addonData, + isAppShutdown + ? this.BOOTSTRAP_REASONS.APP_SHUTDOWN + : this.BOOTSTRAP_REASONS.ADDON_DISABLE); + } + } catch (e) { + Components.utils.reportError(e) + } + + if (this.resourceData) { + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + for (let res of this.resourceData) { + // [ "resource", "shortname" , "path" ] + resProto.setSubstitution( + res[1], + null, + ); + } + } + + if (this.chromeHandle) { + this.chromeHandle.destruct(); + this.chromeHandle = null; + } + // Flush all caches + Services.obs.notifyObservers(null, "startupcache-invalidate"); + console.log("BootstrapLoader for " + this.extension.id + " unloaded!"); + } +}; + +var BootstrapLoader = getThunderbirdVersion().major < 111 + ? BootstrapLoader_102 + : BootstrapLoader_115; diff --git a/content/api/BootstrapLoader/schema.json b/content/api/BootstrapLoader/schema.json new file mode 100644 index 0000000..fe48fb6 --- /dev/null +++ b/content/api/BootstrapLoader/schema.json @@ -0,0 +1,61 @@ +[ + { + "namespace": "BootstrapLoader", + "functions": [ + { + "name": "registerOptionsPage", + "type": "function", + "parameters": [ + { + "name": "aPath", + "type": "string", + "description": "Path to the options page, which should be made accessible in the (legacy) Add-On Options menu." + } + ] + }, + { + "name": "openOptionsDialog", + "type": "function", + "parameters": [ + { + "name": "windowId", + "type": "integer", + "description": "Id of the window the dialog should be opened from." + } + ] + }, + { + "name": "registerChromeUrl", + "type": "function", + "description": "Register folders which should be available as chrome:// urls (as defined in the legacy chrome.manifest)", + "async": true, + "parameters": [ + { + "name": "chromeData", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Array of ChromeData Arrays." + } + ] + }, + { + "name": "registerBootstrapScript", + "type": "function", + "description": "Register a bootstrap.js style script", + "async": true, + "parameters": [ + { + "name": "aPath", + "type": "string", + "description": "Either the chrome:// path to the script or its relative location from the root of the extension," + } + ] + } + ] + } +]
\ No newline at end of file diff --git a/content/bootstrap.js b/content/bootstrap.js new file mode 100644 index 0000000..d4e4d7c --- /dev/null +++ b/content/bootstrap.js @@ -0,0 +1,62 @@ +/* + * This file is part of DAV-4-TbSync. + * + * 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/. + */ + +// no need to create namespace, we are in a sandbox + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +let component = {}; + +let onInitDoneObserver = { + observe: async function (aSubject, aTopic, aData) { + let valid = false; + try { + var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + valid = TbSync.enabled; + } catch (e) { + // If this fails, TbSync is not loaded yet and we will get the notification later again. + } + + //load this provider add-on into TbSync + if (valid) { + await TbSync.providers.loadProvider(extension, "dav", "chrome://dav4tbsync/content/provider.js"); + } + } +} + + +function startup(data, reason) { + // Possible reasons: APP_STARTUP, ADDON_ENABLE, ADDON_INSTALL, ADDON_UPGRADE, or ADDON_DOWNGRADE. + + Services.obs.addObserver(onInitDoneObserver, "tbsync.observer.initialized", false); + + // Did we miss the observer? + onInitDoneObserver.observe(); +} + +function shutdown(data, reason) { + // Possible reasons: APP_STARTUP, ADDON_ENABLE, ADDON_INSTALL, ADDON_UPGRADE, or ADDON_DOWNGRADE. + + // When the application is shutting down we normally don't have to clean up. + if (reason == APP_SHUTDOWN) { + return; + } + + + Services.obs.removeObserver(onInitDoneObserver, "tbsync.observer.initialized"); + //unload this provider add-on from TbSync + try { + var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + TbSync.providers.unloadProvider("dav"); + } catch (e) { + //if this fails, TbSync has been unloaded already and has unloaded this addon as well + } + Services.obs.notifyObservers(null, "startupcache-invalidate"); + Services.obs.notifyObservers(null, "chrome-flush-caches"); +} diff --git a/content/includes/network.js b/content/includes/network.js new file mode 100644 index 0000000..3f94c30 --- /dev/null +++ b/content/includes/network.js @@ -0,0 +1,431 @@ +/* + * This file is part of DAV-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +var { HttpRequest } = ChromeUtils.import("chrome://tbsync/content/HttpRequest.jsm"); +var { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm"); +const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); + +var network = { + + getAuthData: function(accountData) { + let connection = { + get host() { + return "TbSync#" + accountData.accountID; + }, + + get username() { + return accountData.getAccountProperty("user"); + }, + + get password() { + // try new host first + let pw = TbSync.passwordManager.getLoginInfo(this.host, "TbSync/DAV", this.username); + if (pw) { + return pw; + } + + // try old host as fallback + let oldHost = accountData.getAccountProperty("calDavHost") ? accountData.getAccountProperty("calDavHost") : accountData.getAccountProperty("cardDavHost"); + if (oldHost.startsWith("http://")) oldHost = oldHost.substr(7); + if (oldHost.startsWith("https://")) oldHost = oldHost.substr(8); + pw = TbSync.passwordManager.getLoginInfo(oldHost, "TbSync/DAV", this.username); + if (pw) { + //migrate + this.updateLoginData(this.username, pw); + } + return pw; + }, + + updateLoginData: function(newUsername, newPassword) { + let oldUsername = this.username; + TbSync.passwordManager.updateLoginInfo(this.host, "TbSync/DAV", oldUsername, newUsername, newPassword); + // Also update the username of this account. + accountData.setAccountProperty("user", newUsername); + }, + + removeLoginData: function() { + TbSync.passwordManager.removeLoginInfos(this.host, "TbSync/DAV"); + } + }; + return connection; + }, + + ConnectionData: class { + constructor(data) { + this._password = ""; + this._username = ""; + this._https = ""; + this._type = ""; + this._fqdn = ""; + this._timeout = dav.Base.getConnectionTimeout(); + + //for error logging + this._eventLogInfo = null; + + //typof syncdata? + let folderData = null; + let accountData = null; + + if (data instanceof TbSync.SyncData) { + folderData = data.currentFolderData; + accountData = data.accountData; + this._eventLogInfo = data.eventLogInfo; + } else if (data instanceof TbSync.FolderData) { + folderData = data; + accountData = data.accountData; + this._eventLogInfo = new TbSync.EventLogInfo( + accountData.getAccountProperty("provider"), + accountData.getAccountProperty("accountname"), + accountData.accountID, + folderData.getFolderProperty("foldername")); + } else if (data instanceof TbSync.AccountData) { + accountData = data; + this._eventLogInfo = new TbSync.EventLogInfo( + accountData.getAccountProperty("provider"), + accountData.getAccountProperty("accountname"), + accountData.accountID, + ""); + } + + if (accountData) { + let authData = dav.network.getAuthData(accountData); + this._password = authData.password; + this._username = authData.username; + + this._accountname = accountData.getAccountProperty("accountname"); + if (folderData) { + this._fqdn = folderData.getFolderProperty("fqdn"); + this._https = folderData.getFolderProperty("https"); + } + this.accountData = accountData; + } + } + + + set password(v) {this._password = v;} + set username(v) {this._username = v;} + set timeout(v) {this._timeout = v;} + set https(v) {this._https = v;} + set fqdn(v) {this._fqdn = v;} + set eventLogInfo(v) {this._eventLogInfo = v;} + + get password() {return this._password;} + get username() {return this._username;} + get timeout() {return this._timeout;} + get https() {return this._https;} + get fqdn() {return this._fqdn;} + get eventLogInfo() {return this._eventLogInfo;} + }, + + + checkForRFC6764Request: async function (path, eventLogInfo) { + function checkDefaultSecPort (sec) { + return sec ? "443" : "80"; + } + + if (!this.isRFC6764Request(path)) { + return path; + } + + let parts = path.toLowerCase().split("6764://"); + let type = parts[0].endsWith("caldav") ? "caldav" : "carddav"; + + // obey preselected security level for DNS lookup + // and only use insecure option if specified + let scheme = parts[0].startsWith("httpca") ? "http" : "https"; //httpcaldav or httpcarddav = httpca = http + let sec = (scheme == "https"); + + let hostPath = parts[1]; + while (hostPath.endsWith("/")) { hostPath = hostPath.slice(0,-1); } + let host = hostPath.split("/")[0]; + + let result = {}; + + //only perform dns lookup, if the provided path does not contain any path information + if (host == hostPath) { + let request = "_" + type + (sec ? "s" : "") + "._tcp." + host; + + // get host from SRV record + let rv = await DNS.srv(request); + if (rv && Array.isArray(rv) && rv.length>0 && rv[0].host) { + result.secure = sec; + result.host = rv[0].host + ((checkDefaultSecPort(sec) == rv[0].port) ? "" : ":" + rv[0].port); + TbSync.eventlog.add("info", eventLogInfo, "RFC6764 DNS request succeeded", "SRV record @ " + request + "\n" + JSON.stringify(rv[0])); + + // Now try to get path from TXT + rv = await DNS.txt(request); + if (rv && Array.isArray(rv) && rv.length>0 && rv[0].data && rv[0].data.toLowerCase().startsWith("path=")) { + result.path = rv[0].data.substring(5); + TbSync.eventlog.add("info", eventLogInfo, "RFC6764 DNS request succeeded", "TXT record @ " + request + "\n" + JSON.stringify(rv[0])); + } else { + result.path = "/.well-known/" + type; + } + + result.url = "http" + (result.secure ? "s" : "") + "://" + result.host + result.path; + return result.url; + } else { + TbSync.eventlog.add("warning", eventLogInfo, "RFC6764 DNS request failed", "SRV record @ " + request); + } + } + + // use the provided hostPath and build standard well-known url + return scheme + "://" + hostPath + "/.well-known/" + type; + }, + + startsWithScheme: function (url) { + return (url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith("https://") || this.isRFC6764Request(url)); + }, + + isRFC6764Request: function (url) { + let parts = url.split("6764://"); + return (parts.length == 2 && parts[0].endsWith("dav")); + }, + + sendRequest: async function (requestData, path, method, connectionData, headers = {}, options = {}) { + let url = await this.checkForRFC6764Request(path, connectionData.eventLogInfo); + let enforcedPermanentlyRedirectedUrl = (url != path) ? url : null; + + // path could be absolute or relative, we may need to rebuild the full url. + if (url.startsWith("http://") || url.startsWith("https://")) { + // extract segments from url + let uri = Services.io.newURI(url); + connectionData.https = (uri.scheme == "https"); + connectionData.fqdn = uri.hostPort; + } else { + url = "http" + (connectionData.https ? "s" : "") + "://" + connectionData.fqdn + url; + } + + let currentSyncState = connectionData.accountData ? connectionData.accountData.syncData.getSyncState().state : ""; + let accountID = connectionData.accountData ? connectionData.accountData.accountID : ""; + + // Loop: Prompt user for password and retry + const MAX_RETRIES = options.hasOwnProperty("passwordRetries") ? options.passwordRetries+1 : 5; + for (let i=1; i <= MAX_RETRIES; i++) { + TbSync.dump("URL Request #" + i, url); + + connectionData.url = url; + + // Restore original syncstate before open the connection + if (connectionData.accountData && currentSyncState != connectionData.accountData.syncData.getSyncState().state) { + connectionData.accountData.syncData.setSyncState(currentSyncState); + } + + let r = await dav.network.promisifiedHttpRequest(requestData, method, connectionData, headers, options); + if (r && enforcedPermanentlyRedirectedUrl && !r.permanentlyRedirectedUrl) { + r.permanentlyRedirectedUrl = enforcedPermanentlyRedirectedUrl; + } + + if (r && r.passwordPrompt && r.passwordPrompt === true) { + if (i == MAX_RETRIES) { + // If this is the final retry, abort with error. + throw r.passwordError; + } else { + let credentials = null; + let retry = false; + + // Prompt, if connection belongs to an account (and not from the create wizard) + if (connectionData.accountData) { + let promptData = { + windowID: "auth:" + connectionData.accountData.accountID, + accountname: connectionData.accountData.getAccountProperty("accountname"), + usernameLocked: connectionData.accountData.isConnected(), + username: connectionData.username, + } + connectionData.accountData.syncData.setSyncState("passwordprompt"); + + credentials = await TbSync.passwordManager.asyncPasswordPrompt(promptData, dav.openWindows); + if (credentials) { + // update login data + dav.network.getAuthData(connectionData.accountData).updateLoginData(credentials.username, credentials.password); + // update connection data + connectionData.username = credentials.username; + connectionData.password = credentials.password; + retry = true; + } + } + + if (!retry) { + throw r.passwordError; + } + + } + } else { + return r; + } + } + }, + + // Promisified implementation of TbSync's HttpRequest (with XHR interface) + promisifiedHttpRequest: function (requestData, method, connectionData, headers, options) { + let responseData = ""; + + //do not log HEADERS, as it could contain an Authorization header + //TbSync.dump("HEADERS", JSON.stringify(headers)); + if (TbSync.prefs.getIntPref("log.userdatalevel") > 1) TbSync.dump("REQUEST", method + " : " + requestData); + + if (!options.hasOwnProperty("softfail")) { + options.softfail = []; + } + + if (!options.hasOwnProperty("responseType")) { + options.responseType = "xml"; + } + + return new Promise(function(resolve, reject) { + let req = new HttpRequest(); + + req.timeout = connectionData.timeout; + req.mozBackgroundRequest = true; + + req.open(method, connectionData.url, true, connectionData.username, connectionData.password); + + if (options.hasOwnProperty("containerRealm")) req.setContainerRealm(options.containerRealm); + if (options.hasOwnProperty("containerReset") && options.containerReset == true) req.clearContainerCache(); + + if (headers) { + for (let header in headers) { + req.setRequestHeader(header, headers[header]); + } + } + + if (options.responseType == "base64") { + req.responseAsBase64 = true; + } + + req.setRequestHeader("User-Agent", dav.sync.prefSettings.getCharPref("clientID.useragent")); + + req.realmCallback = function(username, realm, host) { + // Store realm, needed later to setup lightning passwords. + TbSync.dump("Found CalDAV authRealm for <"+host+">", realm); + connectionData.realm = realm; + }; + + req.onerror = function () { + let error = TbSync.network.createTCPErrorFromFailedXHR(req); + if (!error) { + return reject(dav.sync.finish("error", "networkerror", "URL:\n" + connectionData.url + " ("+method+")")); //reject/resolve do not terminate control flow + } else { + return reject(dav.sync.finish("error", error, "URL:\n" + connectionData.url + " ("+method+")")); + } + }; + + req.ontimeout = req.onerror; + + req.onredirect = function(flags, uri) { + console.log("Redirect ("+ flags.toString(2) +"): " + uri.spec); + // Update connection settings from current URL + let newHttps = (uri.scheme == "https"); + if (connectionData.https != newHttps) { + TbSync.dump("Updating HTTPS", connectionData.https + " -> " + newHttps); + connectionData.https = newHttps; + } + if (connectionData.fqdn !=uri.hostPort) { + TbSync.dump("Updating FQDN", connectionData.fqdn + " -> " + uri.hostPort); + connectionData.fqdn = uri.hostPort; + } + }; + + req.onload = function() { + if (TbSync.prefs.getIntPref("log.userdatalevel") > 1) TbSync.dump("RESPONSE", req.status + " ("+req.statusText+")" + " : " + req.responseText); + responseData = req.responseText.split("><").join(">\n<"); + + let commLog = "URL:\n" + connectionData.url + " ("+method+")" + "\n\nRequest:\n" + requestData + "\n\nResponse:\n" + responseData; + let aResult = req.responseText; + let responseStatus = req.status; + + switch(responseStatus) { + case 401: //AuthError + { + let response = {}; + response.passwordPrompt = true; + response.passwordError = dav.sync.finish("error", responseStatus, commLog); + return resolve(response); + } + break; + + case 207: //preprocess multiresponse + { + let xml = dav.tools.convertToXML(aResult); + if (xml === null) return reject(dav.sync.finish("warning", "malformed-xml", commLog)); + + let response = {}; + response.davOptions = req.getResponseHeader("dav"); + response.responseURL = req.responseURL; + response.permanentlyRedirectedUrl = req.permanentlyRedirectedUrl; + response.commLog = commLog; + response.node = xml.documentElement; + + let multi = xml.documentElement.getElementsByTagNameNS(dav.sync.ns.d, "response"); + response.multi = []; + for (let i=0; i < multi.length; i++) { + let hrefNode = dav.tools.evaluateNode(multi[i], [["d","href"]]); + let responseStatusNode = dav.tools.evaluateNode(multi[i], [["d", "status"]]); + let propstats = multi[i].getElementsByTagNameNS(dav.sync.ns.d, "propstat"); + if (propstats.length > 0) { + //response contains propstats, push each as single entry + for (let p=0; p < propstats.length; p++) { + let statusNode = dav.tools.evaluateNode(propstats[p], [["d", "status"]]); + + let resp = {}; + resp.node = propstats[p]; + resp.status = statusNode === null ? null : statusNode.textContent.split(" ")[1]; + resp.responsestatus = responseStatusNode === null ? null : responseStatusNode.textContent.split(" ")[1]; + resp.href = hrefNode === null ? null : hrefNode.textContent; + response.multi.push(resp); + } + } else { + //response does not contain any propstats, push raw response + let resp = {}; + resp.node = multi[i]; + resp.status = responseStatusNode === null ? null : responseStatusNode.textContent.split(" ")[1]; + resp.responsestatus = responseStatusNode === null ? null : responseStatusNode.textContent.split(" ")[1]; + resp.href = hrefNode === null ? null : hrefNode.textContent; + response.multi.push(resp); + } + } + + return resolve(response); + } + + + case 200: //returned by DELETE by radicale - watch this !!! + return resolve(aResult); + + case 204: //is returned by DELETE - no data + case 201: //is returned by CREATE - no data + return resolve(null); + break; + + default: + if (options.softfail.includes(responseStatus)) { + let noresponse = {}; + noresponse.softerror = responseStatus; + let xml = dav.tools.convertToXML(aResult); + if (xml !== null) { + let exceptionNode = dav.tools.evaluateNode(xml.documentElement, [["s","exception"]]); + if (exceptionNode !== null) { + noresponse.exception = exceptionNode.textContent; + } + } + //manually log this non-fatal error + TbSync.eventlog.add("info", connectionData.eventLogInfo, "softerror::"+responseStatus, commLog); + return resolve(noresponse); + } else { + return reject(dav.sync.finish("warning", responseStatus, commLog)); + } + break; + + } + }; + + req.send(requestData); + }); + } +} diff --git a/content/includes/sync.js b/content/includes/sync.js new file mode 100644 index 0000000..5a73bb3 --- /dev/null +++ b/content/includes/sync.js @@ -0,0 +1,462 @@ +/* +/* + * This file is part of DAV-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +const { CardDAVDirectory } = ChromeUtils.import( + "resource:///modules/CardDAVDirectory.jsm" +); + +var sync = { + + finish: function (aStatus = "", msg = "", details = "") { + let status = TbSync.StatusData.SUCCESS + switch (aStatus) { + + case "": + case "ok": + status = TbSync.StatusData.SUCCESS; + break; + + case "info": + status = TbSync.StatusData.INFO; + break; + + case "resyncAccount": + status = TbSync.StatusData.ACCOUNT_RERUN; + break; + + case "resyncFolder": + status = TbSync.StatusData.FOLDER_RERUN; + break; + + case "warning": + status = TbSync.StatusData.WARNING; + break; + + case "error": + status = TbSync.StatusData.ERROR; + break; + + default: + console.log("TbSync/DAV: Unknown status <"+aStatus+">"); + status = TbSync.StatusData.ERROR; + break; + } + + let e = new Error(); + e.name = "dav4tbsync"; + e.message = status.toUpperCase() + ": " + msg.toString() + " (" + details.toString() + ")"; + e.statusData = new TbSync.StatusData(status, msg.toString(), details.toString()); + return e; + }, + + prefSettings: Services.prefs.getBranch("extensions.dav4tbsync."), + + ns: { + d: "DAV:", + cal: "urn:ietf:params:xml:ns:caldav" , + card: "urn:ietf:params:xml:ns:carddav" , + cs: "http://calendarserver.org/ns/", + s: "http://sabredav.org/ns", + apple: "http://apple.com/ns/ical/" + }, + + serviceproviders: { + "fruux" : {revision: 1, icon: "fruux", caldav: "https://dav.fruux.com", carddav: "https://dav.fruux.com"}, + "mbo" : {revision: 1, icon: "mbo", caldav: "caldav6764://mailbox.org", carddav: "carddav6764://mailbox.org"}, + "icloud" : {revision: 1, icon: "icloud", caldav: "https://caldav.icloud.com", carddav: "https://contacts.icloud.com"}, + "gmx.net" : {revision: 1, icon: "gmx", caldav: "caldav6764://gmx.net", carddav: "carddav6764://gmx.net"}, + "gmx.com" : {revision: 1, icon: "gmx", caldav: "caldav6764://gmx.com", carddav: "carddav6764://gmx.com"}, + "posteo" : {revision: 1, icon: "posteo", caldav: "https://posteo.de:8443", carddav: "posteo.de:8843"}, + "web.de" : {revision: 1, icon: "web", caldav: "caldav6764://web.de", carddav: "carddav6764://web.de"}, + "yahoo" : {revision: 1, icon: "yahoo", caldav: "caldav6764://yahoo.com", carddav: "carddav6764://yahoo.com"}, + }, + + resetFolderSyncInfo : function (folderData) { + folderData.resetFolderProperty("ctag"); + folderData.resetFolderProperty("token"); + folderData.setFolderProperty("createdWithProviderVersion", folderData.accountData.providerData.getVersion()); + }, + + folderList: async function (syncData) { + //Method description: http://sabre.io/dav/building-a-caldav-client/ + //get all folders currently known + let folderTypes = ["caldav", "carddav", "ics"]; + let unhandledFolders = {}; + for (let type of folderTypes) { + unhandledFolders[type] = []; + } + + + let folders = syncData.accountData.getAllFolders(); + for (let folder of folders) { + //just in case + if (!unhandledFolders.hasOwnProperty(folder.getFolderProperty("type"))) { + unhandledFolders[folder.getFolderProperty("type")] = []; + } + unhandledFolders[folder.getFolderProperty("type")].push(folder); + } + + // refresh urls of service provider, if they have been updated + let serviceprovider = syncData.accountData.getAccountProperty("serviceprovider"); + let serviceproviderRevision = syncData.accountData.getAccountProperty("serviceproviderRevision"); + if (dav.sync.serviceproviders.hasOwnProperty(serviceprovider) && serviceproviderRevision != dav.sync.serviceproviders[serviceprovider].revision) { + TbSync.eventlog.add("info", syncData.eventLogInfo, "updatingServiceProvider", serviceprovider); + syncData.accountData.setAccountProperty("serviceproviderRevision", dav.sync.serviceproviders[serviceprovider].revision); + syncData.accountData.resetAccountProperty("calDavPrincipal"); + syncData.accountData.resetAccountProperty("cardDavPrincipal"); + syncData.accountData.setAccountProperty("calDavHost", dav.sync.serviceproviders[serviceprovider].caldav); + syncData.accountData.setAccountProperty("cardDavHost", dav.sync.serviceproviders[serviceprovider].carddav); + } + + let davjobs = { + cal : {server: syncData.accountData.getAccountProperty("calDavHost")}, + card : {server: syncData.accountData.getAccountProperty("cardDavHost")}, + }; + + for (let job in davjobs) { + if (!davjobs[job].server) continue; + + // SOGo needs some special handling for shared addressbooks. We detect + // it by having SOGo/dav in the url. + let isSogo = davjobs[job].server.includes("/SOGo/dav"); + + // sync states are only printed while the account state is "syncing" + // to inform user about sync process (it is not stored in DB, just in + // syncData) + // example state "getfolders" to get folder information from server + // if you send a request to a server and thus have to wait for answer, + // use a "send." syncstate, which will give visual feedback to the user, + // that we are waiting for an answer with timeout countdown + + let home = []; + let own = []; + + // migration code for http setting, we might keep it as a fallback, if user removed the http:// scheme from the url in the settings + if (!dav.network.startsWithScheme(davjobs[job].server)) { + davjobs[job].server = "http" + (syncData.accountData.getAccountProperty("https") ? "s" : "") + "://" + davjobs[job].server; + syncData.accountData.setAccountProperty(job + "DavHost", davjobs[job].server); + } + + //add connection to syncData + syncData.connectionData = new dav.network.ConnectionData(syncData); + + //only do that, if a new calendar has been enabled + TbSync.network.resetContainerForUser(syncData.connectionData.username); + + syncData.setSyncState("send.getfolders"); + let principal = syncData.accountData.getAccountProperty(job + "DavPrincipal"); // defaults to null + if (principal === null) { + + let response = await dav.network.sendRequest("<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:current-user-principal /></d:prop></d:propfind>", davjobs[job].server , "PROPFIND", syncData.connectionData, {"Depth": "0", "Prefer": "return=minimal"}); + syncData.setSyncState("eval.folders"); + + // keep track of permanent redirects for the server URL + if (response && response.permanentlyRedirectedUrl) { + syncData.accountData.setAccountProperty(job + "DavHost", response.permanentlyRedirectedUrl) + } + + // store dav options send by server + if (response && response.davOptions) { + syncData.accountData.setAccountProperty(job + "DavOptions", response.davOptions.split(",").map(e => e.trim())); + } + + // allow 404 because iCloud sends it on valid answer (yeah!) + if (response && response.multi) { + principal = dav.tools.getNodeTextContentFromMultiResponse(response, [["d","prop"], ["d","current-user-principal"], ["d","href"]], null, ["200","404"]); + } + } + + //principal now contains something like "/remote.php/carddav/principals/john.bieling/" + //principal can also be an absolute url + // -> get home/root of storage + if (principal !== null) { + syncData.setSyncState("send.getfolders"); + + let options = syncData.accountData.getAccountProperty(job + "DavOptions"); + + let homeset = (job == "cal") + ? "calendar-home-set" + : "addressbook-home-set"; + + let request = "<d:propfind "+dav.tools.xmlns(["d", job, "cs"])+"><d:prop><"+job+":" + homeset + " />" + + (job == "cal" && options.includes("calendar-proxy") ? "<cs:calendar-proxy-write-for /><cs:calendar-proxy-read-for />" : "") + + "<d:group-membership />" + + "</d:prop></d:propfind>"; + + let response = await dav.network.sendRequest(request, principal, "PROPFIND", syncData.connectionData, {"Depth": "0", "Prefer": "return=minimal"}); + syncData.setSyncState("eval.folders"); + + // keep track of permanent redirects for the principal URL + if (response && response.permanentlyRedirectedUrl) { + principal = response.permanentlyRedirectedUrl; + } + + own = dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], [job, homeset ], ["d","href"]], principal); + home = own.concat(dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], ["cs", "calendar-proxy-read-for" ], ["d","href"]], principal)); + home = home.concat(dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], ["cs", "calendar-proxy-write-for" ], ["d","href"]], principal)); + + //Any groups we need to find? Only diving one level at the moment, + let g = dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], ["d", "group-membership" ], ["d","href"]], principal); + for (let gc=0; gc < g.length; gc++) { + //SOGo reports a 403 if I request the provided resource, also since we do not dive, remove the request for group-membership + response = await dav.network.sendRequest(request.replace("<d:group-membership />",""), g[gc], "PROPFIND", syncData.connectionData, {"Depth": "0", "Prefer": "return=minimal"}, {softfail: [403, 404]}); + if (response && response.softerror) { + continue; + } + home = home.concat(dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], [job, homeset ], ["d","href"]], g[gc])); + } + + //calendar-proxy and group-membership could have returned the same values, make the homeset unique + home = home.filter((v,i,a) => a.indexOf(v) == i); + } else { + // do not throw here, but log the error and skip this server + TbSync.eventlog.add("error", syncData.eventLogInfo, job+"davservernotfound", davjobs[job].server); + } + + //home now contains something like /remote.php/caldav/calendars/john.bieling/ + // -> get all resources + if (home.length > 0) { + // the used principal returned valid resources, store/update it + // as the principal is being used as a starting point, it must be stored as absolute url + syncData.accountData.setAccountProperty(job + "DavPrincipal", dav.network.startsWithScheme(principal) + ? principal + : "http" + (syncData.connectionData.https ? "s" : "") + "://" + syncData.connectionData.fqdn + principal); + + for (let h=0; h < home.length; h++) { + syncData.setSyncState("send.getfolders"); + let request = (job == "cal") + ? "<d:propfind "+dav.tools.xmlns(["d","apple","cs","cal"])+"><d:prop><d:current-user-privilege-set/><d:resourcetype /><d:displayname /><apple:calendar-color/><cs:source/><cal:supported-calendar-component-set/></d:prop></d:propfind>" + : "<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:current-user-privilege-set/><d:resourcetype /><d:displayname /></d:prop></d:propfind>"; + + //some servers report to have calendar-proxy-read but return a 404 when that gets actually queried + let response = await dav.network.sendRequest(request, home[h], "PROPFIND", syncData.connectionData, {"Depth": "1", "Prefer": "return=minimal"}, {softfail: [403, 404]}); + if (response && response.softerror) { + continue; + } + + for (let r=0; r < response.multi.length; r++) { + if (response.multi[r].status != "200") continue; + + let resourcetype = null; + //is this a result with a valid recourcetype? (the node must be present) + switch (job) { + case "card": + if (dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","resourcetype"], ["card", "addressbook"]]) !== null) resourcetype = "carddav"; + break; + + case "cal": + if (dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","resourcetype"], ["cal", "calendar"]]) !== null) resourcetype = "caldav"; + else if (dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","resourcetype"], ["cs", "subscribed"]]) !== null) resourcetype = "ics"; + break; + } + if (resourcetype === null) continue; + + //get ACL (grant read rights per default, if it is SOGo, as they do not send that permission) + let acl = isSogo ? 0x1 : 0; + + let privilegNode = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","current-user-privilege-set"]]); + if (privilegNode) { + if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "all").length > 0) { + acl = 0xF; //read=1, mod=2, create=4, delete=8 + } else { + // check for individual write permissions + if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "write").length > 0) { + acl = 0xF; + } else { + if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "write-content").length > 0) acl |= 0x2; + if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "bind").length > 0) acl |= 0x4; + if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "unbind").length > 0) acl |= 0x8; + } + + // check for read permission (implying read if any write is given) + if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "read").length > 0 || acl != 0) acl |= 0x1; + } + } + + //ignore this resource, if no read access + if ((acl & 0x1) == 0) continue; + + let href = response.multi[r].href; + if (resourcetype == "ics") href = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["cs","source"], ["d","href"]]).textContent; + + let name_node = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","displayname"]]); + let name = TbSync.getString("defaultname." + ((job == "cal") ? "calendar" : "contacts") , "dav"); + if (name_node != null) { + name = name_node.textContent; + } + let color = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["apple","calendar-color"]]); + let supportedCalComponent = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["cal","supported-calendar-component-set"]]); + if (supportedCalComponent) { + supportedCalComponent = Array.from(supportedCalComponent.children, e => e.getAttribute("name")); + } else { + supportedCalComponent = []; + } + if (job == "cal" && supportedCalComponent.length > 0 && !supportedCalComponent.includes("VTODO") && !supportedCalComponent.includes("VEVENT")) { + // This does not seem to be a valid resource. + continue; + } + + //remove found folder from list of unhandled folders + unhandledFolders[resourcetype] = unhandledFolders[resourcetype].filter(item => item.getFolderProperty("href") !== href); + + + // interaction with TbSync + // do we have a folder for that href? + let folderData = syncData.accountData.getFolder("href", href); + if (!folderData) { + // create a new folder entry + folderData = syncData.accountData.createNewFolder(); + // this MUST be set to either "addressbook" or "calendar" to use the standard target support, or any other value, which + // requires a corresponding targets implementation by this provider + folderData.setFolderProperty("targetType", (job == "card") ? "addressbook" : "calendar"); + + folderData.setFolderProperty("href", href); + folderData.setFolderProperty("foldername", name); + folderData.setFolderProperty("type", resourcetype); + folderData.setFolderProperty("supportedCalComponent", supportedCalComponent); + folderData.setFolderProperty("shared", !own.includes(home[h])); + folderData.setFolderProperty("acl", acl.toString()); + folderData.setFolderProperty("downloadonly", (acl == 0x1)); //if any write access is granted, setup as writeable + + //we assume the folder has the same fqdn as the homeset, otherwise href must contain the full URL and the fqdn is ignored + folderData.setFolderProperty("fqdn", syncData.connectionData.fqdn); + folderData.setFolderProperty("https", syncData.connectionData.https); + + //do we have a cached folder? + let cachedFolderData = syncData.accountData.getFolderFromCache("href", href); + if (cachedFolderData) { + // copy fields from cache which we want to re-use + folderData.setFolderProperty("targetColor", cachedFolderData.getFolderProperty("targetColor")); + folderData.setFolderProperty("targetName", cachedFolderData.getFolderProperty("targetName")); + //if we have only READ access, do not restore cached value for downloadonly + if (acl > 0x1) folderData.setFolderProperty("downloadonly", cachedFolderData.getFolderProperty("downloadonly")); + } + } else { + //Update name & color + folderData.setFolderProperty("foldername", name); + folderData.setFolderProperty("fqdn", syncData.connectionData.fqdn); + folderData.setFolderProperty("https", syncData.connectionData.https); + folderData.setFolderProperty("acl", acl); + //if the acl changed from RW to RO we need to update the downloadonly setting + if (acl == 0x1) { + folderData.setFolderProperty("downloadonly", true); + } + } + + // Update color from server. + if (color && job == "cal") { + color = color.textContent.substring(0,7); + folderData.setFolderProperty("targetColor", color); + + // Do we have to update the calendar? + if (folderData.targetData && folderData.targetData.hasTarget()) { + try { + let targetCal = await folderData.targetData.getTarget(); + targetCal.calendar.setProperty("color", color); + } catch (e) { + Components.utils.reportError(e) + } + } + } + } + } + } else { + //home was not found - connection error? - do not delete unhandled folders + switch (job) { + case "card": + unhandledFolders.carddav = []; + break; + + case "cal": + unhandledFolders.caldav = []; + unhandledFolders.ics = []; + break; + } + //reset stored principal + syncData.accountData.resetAccountProperty(job + "DavPrincipal"); + } + } + + // Remove unhandled old folders, (because they no longer exist on the server). + // Do not delete the targets, but keep them as stale/unconnected elements. + for (let type of folderTypes) { + for (let folder of unhandledFolders[type]) { + folder.remove("[deleted on server]"); + } + } + }, + + + + + + + folder: async function (syncData) { + // add connection data to syncData + syncData.connectionData = new dav.network.ConnectionData(syncData); + + // add target to syncData + let hadTarget; + try { + // accessing the target for the first time will check if it is avail and if not will create it (if possible) + hadTarget = syncData.currentFolderData.targetData.hasTarget(); + syncData.target = await syncData.currentFolderData.targetData.getTarget(); + } catch (e) { + Components.utils.reportError(e); + throw dav.sync.finish("warning", e.message); + } + + switch (syncData.currentFolderData.getFolderProperty("type")) { + case "carddav": + { + // update downloadonly - we do not use AbDirectory (syncData.target) but the underlying thunderbird addressbook obj + if (syncData.currentFolderData.getFolderProperty("downloadonly")) syncData.target.directory.setBoolValue("readOnly", true); + + try { + let davDirectory = CardDAVDirectory.forFile(syncData.target.directory.fileName); + if (!hadTarget) { + davDirectory.fetchAllFromServer(); + } else { + davDirectory.syncWithServer(); + } + } catch (ex) { + throw dav.sync.finish("error", "non-carddav-addrbook"); + } + + throw dav.sync.finish("ok", "managed-by-thunderbird"); + } + break; + + case "caldav": + case "ics": + { + // update downloadonly - we do not use TbCalendar (syncData.target) but the underlying lightning calendar obj + if (syncData.currentFolderData.getFolderProperty("downloadonly")) syncData.target.calendar.setProperty("readOnly", true); + + // update username of calendar + syncData.target.calendar.setProperty("username", syncData.connectionData.username); + + //init sync via lightning + if (hadTarget) syncData.target.calendar.refresh(); + + throw dav.sync.finish("ok", "managed-by-thunderbird"); + } + break; + + default: + { + throw dav.sync.finish("warning", "notsupported"); + } + break; + } + }, + +} diff --git a/content/includes/tools.js b/content/includes/tools.js new file mode 100644 index 0000000..df51d79 --- /dev/null +++ b/content/includes/tools.js @@ -0,0 +1,198 @@ +/* + * This file is part of DAV-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +var tools = { + + //* * * * * * * * * * * * * + //* UTILS + //* * * * * * * * * * * * * + + /** + * Removes XML-invalid characters from a string. + * @param {string} string - a string potentially containing XML-invalid characters, such as non-UTF8 characters, STX, EOX and so on. + * @param {boolean} removeDiscouragedChars - a string potentially containing XML-invalid characters, such as non-UTF8 characters, STX, EOX and so on. + * @returns : a sanitized string without all the XML-invalid characters. + * + * Source: https://www.ryadel.com/en/javascript-remove-xml-invalid-chars-characters-string-utf8-unicode-regex/ + */ + removeXMLInvalidChars: function (string, removeDiscouragedChars = true) + { + // remove everything forbidden by XML 1.0 specifications, plus the unicode replacement character U+FFFD + var regex = /((?:[\0-\x08\x0B\f\x0E-\x1F\uFFFD\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]))/g; + string = string.replace(regex, ""); + + if (removeDiscouragedChars) { + // remove everything not suggested by XML 1.0 specifications + regex = new RegExp( + "([\\x7F-\\x84]|[\\x86-\\x9F]|[\\uFDD0-\\uFDEF]|(?:\\uD83F[\\uDFFE\\uDFFF])|(?:\\uD87F[\\uDF"+ + "FE\\uDFFF])|(?:\\uD8BF[\\uDFFE\\uDFFF])|(?:\\uD8FF[\\uDFFE\\uDFFF])|(?:\\uD93F[\\uDFFE\\uD"+ + "FFF])|(?:\\uD97F[\\uDFFE\\uDFFF])|(?:\\uD9BF[\\uDFFE\\uDFFF])|(?:\\uD9FF[\\uDFFE\\uDFFF])"+ + "|(?:\\uDA3F[\\uDFFE\\uDFFF])|(?:\\uDA7F[\\uDFFE\\uDFFF])|(?:\\uDABF[\\uDFFE\\uDFFF])|(?:\\"+ + "uDAFF[\\uDFFE\\uDFFF])|(?:\\uDB3F[\\uDFFE\\uDFFF])|(?:\\uDB7F[\\uDFFE\\uDFFF])|(?:\\uDBBF"+ + "[\\uDFFE\\uDFFF])|(?:\\uDBFF[\\uDFFE\\uDFFF])(?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\"+ + "uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|"+ + "(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))", "g"); + string = string.replace(regex, ""); + } + + return string; + }, + + xmlns: function (ns) { + let _xmlns = []; + for (let i=0; i < ns.length; i++) { + _xmlns.push('xmlns:'+ns[i]+'="'+dav.sync.ns[ns[i]]+'"'); + } + return _xmlns.join(" "); + }, + + parseUri: function (aUri) { + let uri; + try { + // Test if the entered uri can be parsed. + uri = Services.io.newURI(aUri, null, null); + } catch (ex) { + throw new Error("invalid-calendar-url"); + } + return uri; + }, + + parseVcardDateTime: function ( newServerValue, metadata ) { + if (!newServerValue) { + return false; + } + + /* + ** This accepts RFC2426 BDAY values (with/without hyphens), + ** though TB doesn't handle the time part of date-times, so we discard it. + */ + let bday = newServerValue.match( /^(\d{4})-?(\d{2})-?(\d{2})/ ); + if (!bday) { + return false; + } + + /* + ** Apple Contacts shoehorns date with missing year into vcard3 thus: BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15 + ** Later in vcard4, it will be represented as BDAY:--0315 + */ + if (metadata + && metadata['x-apple-omit-year'] + && metadata['x-apple-omit-year'] == bday[1]) { + bday[1] = ''; + } + return bday; + }, + + //* * * * * * * * * * * * * * + //* EVALUATE XML RESPONSES * + //* * * * * * * * * * * * * * + + convertToXML: function(text) { + //try to convert response body to xml + let xml = null; + let oParser = new DOMParser(); + try { + xml = oParser.parseFromString(dav.tools.removeXMLInvalidChars(text), "application/xml"); + } catch (e) { + //however, domparser does not throw an error, it returns an error document + //https://developer.mozilla.org/de/docs/Web/API/DOMParser + xml = null; + } + //check if xml is error document + if (xml && xml.documentElement.nodeName == "parsererror") { + xml = null; + } + + return xml; + }, + + evaluateNode: function (_node, path) { + let node = _node; + let valid = false; + + for (let i=0; i < path.length; i++) { + + let children = node.children; + valid = false; + + for (let c=0; c < children.length; c++) { + if (children[c].localName == path[i][1] && children[c].namespaceURI == dav.sync.ns[path[i][0]]) { + node = children[c]; + valid = true; + break; + } + } + + if (!valid) { + //none of the children matched the path abort + return null; + } + } + + if (valid) return node; + return null; + }, + + hrefMatch:function (_requestHref, _responseHref) { + if (_requestHref === null) + return true; + + let requestHref = _requestHref; + let responseHref = _responseHref; + while (requestHref.endsWith("/")) { requestHref = requestHref.slice(0,-1); } + while (responseHref.endsWith("/")) { responseHref = responseHref.slice(0,-1); } + if (requestHref.endsWith(responseHref) || decodeURIComponent(requestHref).endsWith(responseHref) || requestHref.endsWith(decodeURIComponent(responseHref))) + return true; + + return false; + }, + + getNodeTextContentFromMultiResponse: function (response, path, href = null, status = ["200"]) { + for (let i=0; i < response.multi.length; i++) { + let node = dav.tools.evaluateNode(response.multi[i].node, path); + if (node !== null && dav.tools.hrefMatch(href, response.multi[i].href) && status.includes(response.multi[i].status)) { + return node.textContent; + } + } + return null; + }, + + getNodesTextContentFromMultiResponse: function (response, path, href = null, status = "200") { + //remove last element from path + let lastPathElement = path.pop(); + let rv = []; + + for (let i=0; i < response.multi.length; i++) { + let node = dav.tools.evaluateNode(response.multi[i].node, path); + if (node !== null && dav.tools.hrefMatch(href, response.multi[i].href) && response.multi[i].status == status) { + + //get all children + let children = node.getElementsByTagNameNS(dav.sync.ns[lastPathElement[0]], lastPathElement[1]); + for (let c=0; c < children.length; c++) { + if (children[c].textContent) rv.push(children[c].textContent); + } + } + } + return rv; + }, + + getMultiGetRequest: function(hrefs) { + let request = "<card:addressbook-multiget "+dav.tools.xmlns(["d", "card"])+"><d:prop><d:getetag /><card:address-data /></d:prop>"; + let counts = 0; + for (let i=0; i < hrefs.length; i++) { + request += "<d:href>"+hrefs[i]+"</d:href>"; + counts++; + } + request += "</card:addressbook-multiget>"; + + if (counts > 0) return request; + else return null; + }, +} diff --git a/content/locales.js b/content/locales.js new file mode 100644 index 0000000..a8beca0 --- /dev/null +++ b/content/locales.js @@ -0,0 +1,3 @@ +var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + +TbSync.localizeOnLoad(window, "dav"); diff --git a/content/manager/createAccount.js b/content/manager/createAccount.js new file mode 100644 index 0000000..3e331fe --- /dev/null +++ b/content/manager/createAccount.js @@ -0,0 +1,524 @@ +/* + * This file is part of DAV-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + +const dav = TbSync.providers.dav; + +var tbSyncDavNewAccount = { + + // standard data fields + get elementName() { return document.getElementById('tbsync.newaccount.name'); }, + get elementUser() { return document.getElementById('tbsync.newaccount.user'); }, + get elementPass() { return document.getElementById('tbsync.newaccount.password'); }, + get elementServer() { return document.getElementById('tbsync.newaccount.server'); }, + get elementCalDavServer() { return document.getElementById('tbsync.newaccount.caldavserver'); }, + get elementCardDavServer() { return document.getElementById('tbsync.newaccount.carddavserver'); }, + get serviceproviderlist() { return document.getElementById('tbsync.newaccount.serviceproviderlist'); }, + + get accountname() { return this.elementName.value.trim(); }, + get username() { return this.elementUser.value.trim(); }, + get password() { return this.elementPass.value.trim(); }, + get server() { return this.elementServer.value.trim(); }, + get calDavServer() { return this.elementCalDavServer.value.trim(); }, + get cardDavServer() { return this.elementCardDavServer.value.trim(); }, + get serviceprovider() { return this.serviceproviderlist.value; }, + get userdomain() { + let parts = this.username.split("@"); + if (parts.length == 2) { + let subparts = parts[1].split("."); + if (subparts.length > 1 && subparts[subparts.length-1].length > 1) return parts[1]; + } + return null; + }, + + set accountname(v) { this.elementName.value = v; }, + set username(v) { this.elementUser.value = v; }, + set password(v) { this.elementPass.value = v; }, + set server(v) { this.elementServer.value = v; }, + set calDavServer(v) { this.elementCalDavServer.value = v; }, + set cardDavServer(v) { this.elementCardDavServer.value = v; }, + + + + // final data fields on final page + get elementFinalName() { return document.getElementById('tbsync.finalaccount.name'); }, + get elementFinalUser() { return document.getElementById('tbsync.finalaccount.user'); }, + get elementFinalCalDavServer() { return document.getElementById('tbsync.finalaccount.caldavserver'); }, + get elementFinalCardDavServer() { return document.getElementById('tbsync.finalaccount.carddavserver'); }, + + get finalAccountname() { return this.elementFinalName.value.trim(); }, + get finalUsername() { return this.elementFinalUser.value.trim(); }, + get finalCalDavServer() { return this.elementFinalCalDavServer.value.trim(); }, + get finalCardDavServer() { return this.elementFinalCardDavServer.value.trim(); }, + + set finalAccountname(v) { this.elementFinalName.value = v;}, + set finalUsername(v) { + this.elementFinalUser.value = v; + this.elementFinalUser.setAttribute("tooltiptext", v); + }, + set finalCalDavServer(v) { + this.elementFinalCalDavServer.value = v; + this.elementFinalCalDavServer.setAttribute("tooltiptext", v); + document.getElementById("tbsyncfinalaccount.caldavserver.row").hidden = (v.trim() == ""); + }, + set finalCardDavServer(v) { + this.elementFinalCardDavServer.value = v; + this.elementFinalCardDavServer.setAttribute("tooltiptext", v); + document.getElementById("tbsyncfinalaccount.carddavserver.row").hidden = (v.trim() == ""); + }, + + get validated() { return this._validated || false; }, + set validated(v) { + this._validated = v; + if (v) { + this.finalAccountname = this.accountname; + } else { + this.finalAccountname = ""; + this.finalUsername = ""; + this.finalCalDavServer = ""; + this.finalCardDavServer = ""; + } + }, + + + showSpinner: function(spinnerText) { + document.getElementById("tbsync.spinner").hidden = false; + document.getElementById("tbsync.spinner.label").value = TbSync.getString("add.spinner." + spinnerText, "dav"); + }, + + hideSpinner: function() { + document.getElementById("tbsync.spinner").hidden = true; + }, + + onLoad: function () { + this.providerData = new TbSync.ProviderData("dav"); + + //init list + this.serviceproviderlist.appendChild(this.addProviderEntry("sabredav32.png", "discovery")); + this.serviceproviderlist.appendChild(this.addProviderEntry("sabredav32.png", "custom")); + for (let p in dav.sync.serviceproviders) { + this.serviceproviderlist.appendChild(this.addProviderEntry(dav.sync.serviceproviders[p].icon +"32.png", p)); + } + + document.addEventListener("wizardfinish", tbSyncDavNewAccount.onFinish.bind(this)); + document.addEventListener("wizardnext", tbSyncDavNewAccount.onAdvance.bind(this)); + document.addEventListener("wizardcancel", tbSyncDavNewAccount.onCancel.bind(this)); + document.getElementById("firstPage").addEventListener("pageshow", tbSyncDavNewAccount.resetFirstPage.bind(this)); + document.getElementById("secondPage").addEventListener("pageshow", tbSyncDavNewAccount.resetSecondPage.bind(this)); + document.getElementById("thirdPage").addEventListener("pageshow", tbSyncDavNewAccount.resetThirdPage.bind(this)); + + this.serviceproviderlist.selectedIndex = 0; + tbSyncDavNewAccount.resetFirstPage(); + }, + + onUnload: function () { + }, + + onClose: function () { + //disallow closing of wizard while isLocked + return !this.isLocked; + }, + + onCancel: function (event) { + //disallow closing of wizard while isLocked + if (this.isLocked) { + event.preventDefault(); + } + }, + + onFinish () { + let newAccountEntry = this.providerData.getDefaultAccountEntries(); + newAccountEntry.createdWithProviderVersion = this.providerData.getVersion(); + newAccountEntry.serviceprovider = this.serviceprovider == "discovery" ? "custom" : this.serviceprovider; + newAccountEntry.serviceproviderRevision = dav.sync.serviceproviders.hasOwnProperty(this.serviceprovider) ? dav.sync.serviceproviders[this.serviceprovider].revision : 0 + newAccountEntry.calDavHost = this.finalCalDavServer; + newAccountEntry.cardDavHost = this.finalCardDavServer; + + // Add the new account. + let newAccountData = this.providerData.addAccount(this.finalAccountname, newAccountEntry); + dav.network.getAuthData(newAccountData).updateLoginData(this.finalUsername, this.password); + }, + + + + + + // HELPER FUNCTIONS + addProviderEntry: function (icon, serviceprovider) { + let name = TbSync.getString("add.serverprofile."+serviceprovider, "dav"); + let description = TbSync.getString("add.serverprofile."+serviceprovider+".description", "dav"); + + //left column + let image = document.createXULElement("image"); + image.setAttribute("src", "chrome://dav4tbsync/content/skin/" + icon); + image.setAttribute("style", "margin:1ex;"); + + let leftColumn = document.createXULElement("vbox"); + leftColumn.appendChild(image); + + //right column + let label = document.createXULElement("label"); + label.setAttribute("class", "header"); + label.setAttribute("value", name); + + let desc = document.createXULElement("description"); + desc.setAttribute("style", "width: 300px"); + desc.textContent = description; + + let rightColumn = document.createXULElement("vbox"); + rightColumn.appendChild(label); + rightColumn.appendChild(desc); + + //columns + let columns = document.createXULElement("hbox"); + columns.appendChild(leftColumn); + columns.appendChild(rightColumn); + + //richlistitem + let richlistitem = document.createXULElement("richlistitem"); + richlistitem.setAttribute("style", "padding:4px"); + richlistitem.setAttribute("value", serviceprovider); + richlistitem.appendChild(columns); + + return richlistitem; + }, + + checkUrlForPrincipal: async function (job) { + // according to RFC6764, we must also try the username with cut-off domain part + // Note: This is never called for OAUTH serves (see onAdvance) + let users = []; + users.push(this.username); + if (this.userdomain) users.push(this.username.split("@")[0]); + + for (let user of users) { + let connectionData = new dav.network.ConnectionData(); + connectionData.password = this.password; + connectionData.username = user; + connectionData.timeout = 5000; + + //only needed for proper error reporting - that dav needs this is beyond API - connectionData is not used by TbSync + //connectionData is a structure which contains all the information needed to establish and evaluate a network connection + connectionData.eventLogInfo = new TbSync.EventLogInfo("dav", this.accountname); + + job.valid = false; + job.error = ""; + + try { + let response = await dav.network.sendRequest("<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:current-user-principal /></d:prop></d:propfind>", job.server , "PROPFIND", connectionData, {"Depth": "0", "Prefer": "return=minimal"}, {containerRealm: "setup", containerReset: true, passwordRetries: 0}); + // allow 404 because iCloud sends it on valid answer (yeah!) + let principal = (response && response.multi) ? dav.tools.getNodeTextContentFromMultiResponse(response, [["d","prop"], ["d","current-user-principal"], ["d","href"]], null, ["200","404"]) : null; + job.valid = (principal !== null); + if (!job.valid) { + job.error = job.type + "servernotfound"; + TbSync.eventlog.add("warning", connectionData.eventLogInfo, job.error, response ? response.commLog : ""); + } else { + job.validUser = user; + job.validUrl = (response ? response.permanentlyRedirectedUrl : null) || job.server; + return; + } + } catch (e) { + job.valid = false; + job.error = (e.statusData ? e.statusData.message : e.message); + + if (e.name == "dav4tbsync") { + TbSync.eventlog.add("warning", connectionData.eventLogInfo, e.statusData.message, e.statusData.details); + } else { + Components.utils.reportError(e); + } + } + + // only retry with other user, if 401 + if (!job.error.startsWith("401")) { + break; + } + } + + return; + }, + + advance: function () { + document.getElementById("tbsync.newaccount.wizard").advance(null); + }, + + + + + + // RESET AND INIT FUNCTIONS + clearValues: function () { + //clear fields + this.username = ""; + this.password = ""; + this.server = ""; + this.calDavServer = ""; + this.cardDavServer = ""; + + if (this.serviceprovider == "discovery" || this.serviceprovider == "custom") { + this.accountname = ""; + } else { + this.accountname = TbSync.getString("add.serverprofile." + this.serviceprovider, "dav"); + } + }, + + resetFirstPage: function () { + // RESET / INIT first page + document.getElementById("tbsync.newaccount.wizard").canRewind = false; + document.getElementById("tbsync.newaccount.wizard").canAdvance = true; + // bug https://bugzilla.mozilla.org/show_bug.cgi?id=1618252 + document.getElementById('tbsync.newaccount.wizard')._adjustWizardHeader(); + this.isLocked = false; + this.validated = false; + }, + + resetSecondPage: function () { + // RESET / INIT second page + this.isLocked = false; + this.validated = false; + + document.getElementById("tbsync.newaccount.wizard").canRewind = true; + document.getElementById("tbsync.newaccount.wizard").canAdvance = false; + this.hideSpinner(); + document.getElementById("tbsync.error").hidden = true; + + this.checkUI(); + }, + + resetThirdPage: function () { + // RESET / INIT third page + document.getElementById("tbsync.newaccount.wizard").canRewind = true; + document.getElementById("tbsync.newaccount.wizard").canAdvance = true; + this.isLocked = false; + }, + + + + + + // UI FUNCTIONS + lockUI: function(spinnerText) { + this.showSpinner(spinnerText); + document.getElementById("tbsync.error").hidden = true; + document.getElementById("tbsync.newaccount.wizard").canAdvance = false; + document.getElementById("tbsync.newaccount.wizard").canRewind = false; + this.isLocked = true; + }, + + unlockUI: function() { + this.hideSpinner(); + document.getElementById("tbsync.newaccount.wizard").canRewind = true; + this.isLocked = false; + this.checkUI(); + }, + + checkUI: function (hideError) { + if (hideError) { + document.getElementById("tbsync.error").hidden = true; + } + + // determine, if we can advance or not + if (this.serviceprovider == "discovery") { + document.getElementById("tbsync.newaccount.wizard").canAdvance = !( + (this.accountname == "") || + (this.server == "" && !this.userdomain) || + (this.server == "" && this.username == "")); + } else if (this.serviceprovider == "custom") { + // custom does not need username or password (allow annonymous access) + document.getElementById("tbsync.newaccount.wizard").canAdvance = !( + (this.accountname == "") || + (this.calDavServer + this.cardDavServer == "")); + } else { + // build in service providers do need a username and password + document.getElementById("tbsync.newaccount.wizard").canAdvance = !( + (this.accountname == "") || + (this.password == "") || + (this.username == "")); + } + + // update placeholder attribute of server + this.elementServer.setAttribute("placeholder", this.userdomain ? TbSync.getString("add.serverprofile.discovery.server-optional", "dav") : ""); + + + //show/hide additional descriptions (if avail) + let dFound = 0; + for (let i=1; i < 4; i++) { + let dElement = document.getElementById("tbsync.newaccount.details" + i); + let dLocaleString = "add.serverprofile." + this.serviceprovider + ".details" + i; + let dLocaleValue = TbSync.getString(dLocaleString, "dav"); + + let hide = (dLocaleValue == dLocaleString); + if (this.serviceprovider == "discovery") { + // show them according to UI state + switch (i) { + case 1: + hide = false; + break; + case 2: + hide = !this.userdomain; + break; + } + } + + if (hide) { + dElement.textContent = ""; + dElement.hidden = true; + } else { + dFound++; + dElement.textContent = dLocaleValue + dElement.hidden =false; + } + } + + //hide Notes header, if no descriptions avail + let dLabel = document.getElementById("tbsync.newaccount.details.header"); + dLabel.hidden = (dFound == 0); + + + //which server fields to show? + document.getElementById("tbsync.newaccount.finaluser.row").hidden = false; + document.getElementById("tbsync.newaccount.user.row").hidden = false; + document.getElementById("tbsync.newaccount.password.row").hidden = false; + + if (this.serviceprovider == "discovery") { + document.getElementById("tbsync.newaccount.caldavserver.row").hidden = true; + document.getElementById("tbsync.newaccount.carddavserver.row").hidden = true; + document.getElementById("tbsync.newaccount.server.row").hidden = false; + //this.elementCalDavServer.disabled = false; + //this.elementCardDavServer.disabled = false; + } else if (this.serviceprovider == "custom") { + // custom + document.getElementById("tbsync.newaccount.caldavserver.row").hidden = false; + document.getElementById("tbsync.newaccount.carddavserver.row").hidden = false; + document.getElementById("tbsync.newaccount.server.row").hidden = true; + //this.elementCalDavServer.disabled = false; + //this.elementCardDavServer.disabled = false; + } else { + // build in service provider + document.getElementById("tbsync.newaccount.caldavserver.row").hidden = true; + document.getElementById("tbsync.newaccount.carddavserver.row").hidden = true; + document.getElementById("tbsync.newaccount.server.row").hidden = true; + //this.elementCalDavServer.disabled = true; + //this.elementCardDavServer.disabled = true; + this.calDavServer = dav.sync.serviceproviders[this.serviceprovider].caldav; + this.cardDavServer = dav.sync.serviceproviders[this.serviceprovider].carddav; + } + }, + + + + + + // SETUP LOGIC FUNCTION + onAdvance: function (event) { + // do not prevent advancing if we go from page 1 to page 2, or if validation succeeded + if (document.getElementById("tbsync.newaccount.wizard").currentPage.id == "firstPage" || this.validated) { + return; + } + + // if we reach this, we are on page 2 but may not advance but + // go through the setup steps + + if (this.serviceprovider == "discovery") { + while (this.server.endsWith("/")) { this.server = this.server.slice(0,-1); } + // the user may either specify a server or he could have entered an email with domain + let parts = (this.server || this.userdomain).split("://"); + let scheme = (parts.length > 1) ? parts[0].toLowerCase() : ""; + let host = parts[parts.length-1]; + + this.calDavServer = scheme + "caldav6764://" + host; + this.cardDavServer = scheme + "carddav6764://" + host; + this.validateDavServers(); + } else { + // custom or service provider + this.validateDavServers(); + } + + event.preventDefault(); + }, + + validateDavServers: async function() { + this.lockUI("validating"); + + // Do not manipulate input here. + //while (this.calDavServer.endsWith("/")) { this.calDavServer = this.calDavServer.slice(0,-1); } + //while (this.cardDavServer.endsWith("/")) { this.cardDavServer = this.cardDavServer.slice(0,-1); } + + // Default to https, if http is not explicitly specified + if (this.calDavServer && !dav.network.startsWithScheme(this.calDavServer)) { + this.calDavServer = "https://" + this.calDavServer; + } + if (this.cardDavServer && !dav.network.startsWithScheme(this.cardDavServer)) { + this.cardDavServer = "https://" + this.cardDavServer; + } + + let davJobs = [ + {type: "caldav", server: this.calDavServer}, + {type: "carddav", server: this.cardDavServer}, + ]; + + let failedDavJobs = []; + let validUserFound = ""; + + for (let job = 0; job < davJobs.length; job++) { + if (!davJobs[job].server) { + continue; + } + await this.checkUrlForPrincipal(davJobs[job]); + if (!davJobs[job].valid) { + failedDavJobs.push(job); + } else if (!validUserFound) { + // set the found user + validUserFound = davJobs[job].validUser; + } else if (validUserFound != davJobs[job].validUser) { + // users do not match + failedDavJobs.push(job); + } + } + + if (failedDavJobs.length == 0) { + // boom, setup completed + this.finalCalDavServer = davJobs[0].validUrl || ""; + this.finalCardDavServer = davJobs[1].validUrl || ""; + this.finalUsername = validUserFound; + this.validated = true; + this.unlockUI(); + this.advance(); + return; + } else { + //only display one error + let failedJob = failedDavJobs[0]; + console.log("ERROR ("+davJobs[failedJob].type+"): " + davJobs[failedJob].error.toString()); + switch (davJobs[failedJob].error.toString().split("::")[0]) { + case "401": + case "403": + case "503": + case "security": + document.getElementById("tbsync.error.message").textContent = TbSync.getString("status."+davJobs[failedJob].error, "dav"); + break; + default: + if (this.serviceprovider == "discovery" && this.userdomain && !this.server) { + // the discovery mode has a special error msg, in case a userdomain was used as server, but failed and we need the user to provide the server + document.getElementById("tbsync.error.message").textContent = TbSync.getString("status.rfc6764-lookup-failed::" +this.userdomain, "dav"); + } else if (this.serviceprovider != "discovery" && this.serviceprovider != "custom") { + // error msg, that the serviceprovider setup seems wrong + document.getElementById("tbsync.error.message").textContent = TbSync.getString("status.service-provider-setup-failed", "dav"); + } else if (dav.network.isRFC6764Request(davJobs[failedJob].server)) { + // error msg, that discovery mode failed + document.getElementById("tbsync.error.message").textContent = TbSync.getString("status.service-discovery-failed::" +davJobs[failedJob].server.split("://")[1], "dav"); + } else { + document.getElementById("tbsync.error.message").textContent = TbSync.getString("status." + davJobs[failedJob].type + "servernotfound", "dav"); + } + } + document.getElementById("tbsync.error").hidden = false; + this.unlockUI(); + } + }, +}; diff --git a/content/manager/createAccount.xhtml b/content/manager/createAccount.xhtml new file mode 100644 index 0000000..7bdc84a --- /dev/null +++ b/content/manager/createAccount.xhtml @@ -0,0 +1,158 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<window onload="tbSyncDavNewAccount.onLoad();" onunload="tbSyncDavNewAccount.onUnload();" + onclose="return tbSyncDavNewAccount.onClose()" xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <linkset> + <html:link rel="localization" href="toolkit/global/wizard.ftl" /> + </linkset> + + <script type="application/javascript" src="chrome://dav4tbsync/content/manager/createAccount.js" /> + <script type="application/javascript" src="chrome://dav4tbsync/content/locales.js" /> + + <wizard title="__DAV4TBSYNCMSG_add.title__" id="tbsync.newaccount.wizard" > + + <wizardpage id="firstPage" onFirstPage="true" label="__DAV4TBSYNCMSG_add.serverprofile.title__"> + <vbox flex="1"> + <description style="width: auto; margin-top:1em;">__DAV4TBSYNCMSG_add.serverprofile.description__ + </description> + <richlistbox id="tbsync.newaccount.serviceproviderlist" seltype="single" + style="width: auto; height: 400px; margin-top:1ex" onselect="tbSyncDavNewAccount.clearValues();" + ondblclick="tbSyncDavNewAccount.advance()" /> + </vbox> + </wizardpage> + + <wizardpage id="secondPage" label="__DAV4TBSYNCMSG_add.data.title__"> + <vbox flex="1"> + <description style="width: auto; margin-top:1em;">__DAV4TBSYNCMSG_add.data.description__</description> + <html:table style="margin-top:1ex"> + <html:tr id="tbsync.newaccount.name.row" width="100%"> + <html:td width="33%"> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.name__" /></vbox> + </html:td> + <html:td width="67%"> + <html:input style="width: 90%" id="tbsync.newaccount.name" + oninput="tbSyncDavNewAccount.checkUI();" /> + </html:td> + </html:tr> + <html:tr id="tbsync.newaccount.user.row"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.user__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%" id="tbsync.newaccount.user" + oninput="tbSyncDavNewAccount.checkUI(true);" /> + </html:td> + </html:tr> + <html:tr id="tbsync.newaccount.password.row"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.password__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%" id="tbsync.newaccount.password" type="password" + oninput="tbSyncDavNewAccount.checkUI(true);" /> + </html:td> + </html:tr> + <html:tr id="tbsync.newaccount.server.row" style="margin-top:2em;"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.server__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%" id="tbsync.newaccount.server" + oninput="tbSyncDavNewAccount.checkUI(true);" /> + </html:td> + </html:tr> + <html:tr id="tbsync.newaccount.caldavserver.row" style="margin-top:2em;"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.caldavserver__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%" id="tbsync.newaccount.caldavserver" + oninput="tbSyncDavNewAccount.checkUI(true);" /> + </html:td> + </html:tr> + <html:tr id="tbsync.newaccount.carddavserver.row"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.carddavserver__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%" id="tbsync.newaccount.carddavserver" + oninput="tbSyncDavNewAccount.checkUI(true);" /> + </html:td> + </html:tr> + </html:table> + <label class="header" id="tbsync.newaccount.details.header" value="__DAV4TBSYNCMSG_add.data.notes__" + style="margin-top:2em" /> + <description id="tbsync.newaccount.details1" style="width: auto; margin-top:1ex;"></description> + <description id="tbsync.newaccount.details2" style="width: auto; margin-top:1ex;"></description> + <description id="tbsync.newaccount.details3" style="width: auto; margin-top:1ex;"></description> + <vbox flex="1"> + </vbox> + <hbox id="tbsync.spinner"> + <label id="tbsync.spinner.label" value="" /> + <image src="chrome://tbsync/content/skin/spinner.gif" style="margin-left:1em" width="16" + height="16" /> + </hbox> + <vbox id="tbsync.error" style="width: auto;"> + <description id="tbsync.error.message" flex="1" style="font-weight: bold;"></description> + <vbox> + <button id="tbsync.error.link" label="__DAV4TBSYNCMSG_manager.ShowEventLog__" + oncommand="TbSync.eventlog.open();" /> + </vbox> + </vbox> + </vbox> + </wizardpage> + + <wizardpage id="thirdPage" label="__DAV4TBSYNCMSG_add.finish.title__"> + <vbox flex="1"> + <description style="width: auto; margin-top:1em;">__DAV4TBSYNCMSG_add.finish.description__ + </description> + <html:table style="margin-top:1ex"> + <html:tr flex="1"> + <html:td width="33%"> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.name__" /></vbox> + </html:td> + <html:td width="67%"> + <html:input style="width: 90%; margin:2px" id="tbsync.finalaccount.name" /> + </html:td> + </html:tr> + <html:tr id="tbsync.newaccount.finaluser.row"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.user__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%; margin:2px; background-color:silver;" type="text" + id="tbsync.finalaccount.user" readonly="true" /> + </html:td> + </html:tr> + <html:tr style="margin-bottom:2em;"> + </html:tr> + <html:tr id="tbsyncfinalaccount.caldavserver.row"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.caldavserver__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%; margin:2px; background-color:silver;" + id="tbsync.finalaccount.caldavserver" readonly="true" /> + </html:td> + </html:tr> + <html:tr id="tbsyncfinalaccount.carddavserver.row"> + <html:td> + <vbox pack="center"><label value="__DAV4TBSYNCMSG_add.carddavserver__" /></vbox> + </html:td> + <html:td> + <html:input style="width: 90%; margin:2px; background-color:silver;" + id="tbsync.finalaccount.carddavserver" readonly="true" /> + </html:td> + </html:tr> + </html:table> + <description id="tbsync.finalaccount.details1" style="width: auto; margin-top:2em;"> + __DAV4TBSYNCMSG_add.finish.details__</description> + </vbox> + </wizardpage> + + </wizard> + +</window> diff --git a/content/manager/editAccountOverlay.js b/content/manager/editAccountOverlay.js new file mode 100644 index 0000000..8f32af8 --- /dev/null +++ b/content/manager/editAccountOverlay.js @@ -0,0 +1,46 @@ +/* + * This file is part of DAV-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +const dav = TbSync.providers.dav; + +var tbSyncEditAccountOverlay = { + + onload: function (window, accountData) { + this.accountData = accountData; + + let serviceprovider = this.accountData.getAccountProperty("serviceprovider"); + let isServiceProvider = dav.sync.serviceproviders.hasOwnProperty(serviceprovider); + + // special treatment for configuration label, which is a permanent setting and will not change by switching modes + let configlabel = window.document.getElementById("tbsync.accountsettings.label.config"); + if (configlabel) { + let extra = ""; + if (isServiceProvider) { + extra = " [" + TbSync.getString("add.serverprofile." + serviceprovider, "dav") + "]"; + } + configlabel.setAttribute("value", TbSync.getString("config.custom", "dav") + extra); + } + + //set certain elements as "alwaysDisable", if locked by service provider + if (isServiceProvider) { + let items = window.document.getElementsByClassName("lockIfServiceProvider"); + for (let i=0; i < items.length; i++) { + items[i].setAttribute("alwaysDisabled", "true"); + } + } + }, + + stripHost: function (document, field) { + let host = document.getElementById('tbsync.accountsettings.pref.' + field).value; + while (host.endsWith("/")) { host = host.slice(0,-1); } + document.getElementById('tbsync.accountsettings.pref.' + field).value = host + this.accountData.setAccountProperty(field, host); + } +}; diff --git a/content/manager/editAccountOverlay.xhtml b/content/manager/editAccountOverlay.xhtml new file mode 100644 index 0000000..bee8d72 --- /dev/null +++ b/content/manager/editAccountOverlay.xhtml @@ -0,0 +1,93 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://tbsync/content/skin/fix_dropdown_1534697.css" type="text/css"?> +<overlay + id="tbSyncAccountConfig" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script type="application/javascript" src="chrome://dav4tbsync/content/manager/editAccountOverlay.js"/> + <script type="application/javascript" src="chrome://dav4tbsync/content/locales.js"/> + + <tab id="manager.tabs.accountsettings" label="__DAV4TBSYNCMSG_manager.tabs.accountsettings__" appendto="manager.tabs" /> + <tab id="manager.tabs.syncsettings" label="__DAV4TBSYNCMSG_manager.tabs.syncsettings__" appendto="manager.tabs" /> + + <tabpanel id="manager.tabpanels.accountsettings" appendto="manager.tabpanels" flex="1" orient="vertical"><!-- ACCOUNT SETTINGS --> + <vbox flex="1"> + <label class="header lockIfConnected" style="margin-left:0; margin-bottom:1ex;" value="" id="tbsync.accountsettings.label.config" /> + <html:table> + + <html:tr> + <html:td> + <vbox pack="center"> + <label style="text-align:left" control="tbsync.accountsettings.pref.accountname" value="__DAV4TBSYNCMSG_pref.AccountName__" /> + </vbox> + </html:td> + <html:td width="100%"> + <html:input id="tbsync.accountsettings.pref.accountname" /> + </html:td> + </html:tr> + + <html:tr> + <html:td> + <vbox pack="center"> + <label class="lockIfConnected" style="text-align:left" control="tbsync.accountsettings.pref.user" value="__DAV4TBSYNCMSG_pref.UserName__" /> + </vbox> + </html:td> + <html:td> + <html:input class="lockIfConnected" id="tbsync.accountsettings.pref.user" /> + </html:td> + </html:tr> + + <html:tr> + <html:td> + <vbox pack="center"> + <label class="lockIfConnected lockIfServiceProvider" style="text-align:left" control="tbsync.accountsettings.pref.calDavHost" value="__DAV4TBSYNCMSG_pref.CalDavServer__" /> + </vbox> + </html:td> + <html:td> + <html:input class="lockIfConnected lockIfServiceProvider" id="tbsync.accountsettings.pref.calDavHost" onblur="tbSyncEditAccountOverlay.stripHost(document, 'calDavHost');"/> + </html:td> + </html:tr> + <html:tr> + <html:td> + <vbox pack="center"> + <label class="lockIfConnected lockIfServiceProvider" style="text-align:left" control="tbsync.accountsettings.pref.cardDavHost" value="__DAV4TBSYNCMSG_pref.CardDavServer__" /> + </vbox> + </html:td> + <html:td> + <html:input class="lockIfConnected lockIfServiceProvider" id="tbsync.accountsettings.pref.cardDavHost" onblur="tbSyncEditAccountOverlay.stripHost(document, 'cardDavHost');" /> + </html:td> + </html:tr> + + </html:table> + + <vbox flex="1" /> + <vbox class="showIfConnected"> + <hbox> + <vbox pack="center"><image src="chrome://tbsync/content/skin/info16.png" /></vbox> + <description flex="1">__TBSYNCMSG_manager.lockedsettings.description__</description> + </hbox> + </vbox> + + </vbox> + </tabpanel> + + <tabpanel id="manager.tabpanels.syncsettings" appendto="manager.tabpanels" flex="1" orient="vertical"><!-- SYNC SETTINGS --> + <vbox flex="1"> + <!--label style="margin-left:0; margin-bottom: 1ex;" class="header lockIfConnected" value="__DAV4TBSYNCMSG_pref.generaloptions__"/--> + + <label style="margin-left:0; margin-bottom: 1ex; margin-top: 3ex" class="header lockIfConnected" value="__DAV4TBSYNCMSG_pref.calendaroptions__"/> + <vbox> + <checkbox class="lockIfConnected" id="tbsync.accountsettings.pref.useCalendarCache" label="__DAV4TBSYNCMSG_pref.useCalendarCache__" /> + </vbox> + + <vbox flex="1" /> + <vbox class="showIfConnected"> + <hbox> + <vbox pack="center"><image src="chrome://tbsync/content/skin/info16.png" /></vbox> + <description flex="1">__TBSYNCMSG_manager.lockedsettings.description__</description> + </hbox> + </vbox> + </vbox> + </tabpanel> + +</overlay> diff --git a/content/provider.js b/content/provider.js new file mode 100644 index 0000000..dfd8906 --- /dev/null +++ b/content/provider.js @@ -0,0 +1,691 @@ +/* + * This file is part of DAV-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; +// check if getItem returns an array because of recursions! + +// Every object in here will be loaded into TbSync.providers.<providername>. +const dav = TbSync.providers.dav; + +/** + * Implementing the TbSync interface for external provider extensions. + */ + +var Base = class { + /** + * Called during load of external provider extension to init provider. + */ + static async load() { + // Set default prefs + let branch = Services.prefs.getDefaultBranch("extensions.dav4tbsync."); + branch.setIntPref("maxitems", 50); + branch.setIntPref("timeout", 90000); + branch.setCharPref("clientID.type", "TbSync"); + branch.setCharPref("clientID.useragent", "Thunderbird CalDAV/CardDAV"); + branch.setBoolPref("enforceUniqueCalendarUrls", false); + + dav.openWindows = {}; + } + + + /** + * Called during unload of external provider extension to unload provider. + */ + static async unload() { + // Close all open windows of this provider. + for (let id in dav.openWindows) { + if (dav.openWindows.hasOwnProperty(id)) { + try { + dav.openWindows[id].close(); + } catch (e) { + //NOOP + } + } + } + } + + + /** + * Returns string for the name of provider for the add account menu. + */ + static getProviderName() { + return TbSync.getString("menu.name", "dav"); + } + + + /** + * Returns version of the TbSync API this provider is using + */ + static getApiVersion() { return "2.5"; } + + + + /** + * Returns location of a provider icon. + */ + static getProviderIcon(size, accountData = null) { + let root = "sabredav"; + if (accountData) { + let serviceprovider = accountData.getAccountProperty("serviceprovider"); + if (dav.sync.serviceproviders.hasOwnProperty(serviceprovider)) { + root = dav.sync.serviceproviders[serviceprovider].icon; + } + } + + switch (size) { + case 16: + return "chrome://dav4tbsync/content/skin/"+root+"16.png"; + case 32: + return "chrome://dav4tbsync/content/skin/"+root+"32.png"; + default : + return "chrome://dav4tbsync/content/skin/"+root+"48.png"; + } + } + + + /** + * Returns a list of sponsors, they will be sorted by the index + */ + static getSponsors() { + return { + "Thoben, Marc" : {name: "Marc Thoben", description: "Zimbra", icon: "", link: "" }, + "Biebl, Michael" : {name: "Michael Biebl", description: "Nextcloud", icon: "", link: "" }, + "László, Kovács" : {name: "Kovács László", description : "Radicale", icon: "", link: "" }, + "Lütticke, David" : {name: "David Lütticke", description : "", icon: "", link: "" }, + }; + } + + + /** + * Returns the url of a page with details about contributors (used in the manager UI) + */ + static getContributorsUrl() { + return "https://github.com/jobisoft/DAV-4-TbSync/blob/master/CONTRIBUTORS.md"; + } + + + /** + * Returns the email address of the maintainer (used for bug reports). + */ + static getMaintainerEmail() { + return "john.bieling@gmx.de"; + } + + + /** + * Returns URL of the new account window. + * + * The URL will be opened via openDialog(), when the user wants to create a + * new account of this provider. + */ + static getCreateAccountWindowUrl() { + return "chrome://dav4tbsync/content/manager/createAccount.xhtml"; + } + + + /** + * Returns overlay XUL URL of the edit account dialog + * (chrome://tbsync/content/manager/editAccount.xhtml) + */ + static getEditAccountOverlayUrl() { + return "chrome://dav4tbsync/content/manager/editAccountOverlay.xhtml"; + } + + + /** + * Return object which contains all possible fields of a row in the + * accounts database with the default value if not yet stored in the + * database. + */ + static getDefaultAccountEntries() { + let row = { + "useCalendarCache" : true, + "calDavHost" : "", + "cardDavHost" : "", + // these must return null if not defined + "calDavPrincipal" : null, + "cardDavPrincipal" : null, + + "calDavOptions" : [], + "cardDavOptions" : [], + + "serviceprovider" : "", + "serviceproviderRevision" : 0, + + "user" : "", + "https" : true, //deprecated, because this is part of the URL now + "createdWithProviderVersion" : "0", + }; + return row; + } + + + /** + * Return object which contains all possible fields of a row in the folder + * database with the default value if not yet stored in the database. + */ + static getDefaultFolderEntries() { + let folder = { + // different folders (caldav/carddav) can be stored on different + // servers (as with yahoo, icloud, gmx, ...), so we need to store + // the fqdn information per folders + "href" : "", + "https" : true, + "fqdn" : "", + + "url" : "", // used by calendar to store the full url of this cal + + "type" : "", //caldav, carddav or ics + "shared": false, //identify shared resources + "acl": "", //acl send from server + "target" : "", + "targetColor" : "", + "targetName" : "", + "ctag" : "", + "token" : "", + "createdWithProviderVersion" : "0", + "supportedCalComponent" : [] + }; + return folder; + } + + + /** + * Is called everytime an account of this provider is enabled in the + * manager UI. + */ + static onEnableAccount(accountData) { + accountData.resetAccountProperty("calDavPrincipal"); + accountData.resetAccountProperty("cardDavPrincipal"); + } + + + /** + * Is called everytime an account of this provider is disabled in the + * manager UI. + */ + static onDisableAccount(accountData) { + } + + + /** + * Is called everytime an account of this provider is deleted in the + * manager UI. + */ + static onDeleteAccount(accountData) { + dav.network.getAuthData(accountData).removeLoginData(); + } + + + /** + * Returns all folders of the account, sorted in the desired order. + * The most simple implementation is to return accountData.getAllFolders(); + */ + static getSortedFolders(accountData) { + let folders = accountData.getAllFolders(); + + // we can only sort arrays, so we create an array of objects which must + // contain the sort key and the associated folder + let toBeSorted = []; + for (let folder of folders) { + let t = 100; + let comp = folder.getFolderProperty("supportedCalComponent"); + switch (folder.getFolderProperty("type")) { + case "carddav": + t+=0; + break; + case "caldav": + t+=10; + if (comp.length > 0 && !comp.includes("VEVENT") && comp.includes("VTODO")) t+=5; + break; + case "ics": + t+=20; + break; + default: + t+=90; + break; + } + + if (folder.getFolderProperty("shared")) { + t+=100; + } + + toBeSorted.push({"key": t.toString() + folder.getFolderProperty("foldername"), "folder": folder}); + } + + //sort + toBeSorted.sort(function(a,b) { + return a.key > b.key; + }); + + let sortedFolders = []; + for (let sortObj of toBeSorted) { + sortedFolders.push(sortObj.folder); + } + return sortedFolders; + } + + + /** + * Return the connection timeout for an active sync, so TbSync can append + * a countdown to the connection timeout, while waiting for an answer from + * the server. Only syncstates which start with "send." will trigger this. + */ + static getConnectionTimeout(accountData) { + return dav.sync.prefSettings.getIntPref("timeout"); + } + + + /** + * Is called if TbSync needs to synchronize the folder list. + */ + static async syncFolderList(syncData, syncJob, syncRunNr) { + // Recommendation: Put the actual function call inside a try catch, to + // ensure returning a proper StatusData object, regardless of what + // happens inside that function. You may also throw custom errors + // in that function, which have the StatusData obj attached, which + // should be returned. + + try { + await dav.sync.folderList(syncData); + } catch (e) { + if (e.name == "dav4tbsync") { + return e.statusData; + } else { + Components.utils.reportError(e); + // re-throw any other error and let TbSync handle it + throw (e); + } + } + + // we fall through, if there was no error + return new TbSync.StatusData(); + } + + + /** + * Is called if TbSync needs to synchronize a folder. + */ + static async syncFolder(syncData, syncJob, syncRunNr) { + // Recommendation: Put the actual function call inside a try catch, to + // ensure returning a proper StatusData object, regardless of what + // happens inside that function. You may also throw custom errors + // in that function, which have the StatusData obj attached, which + // should be returned. + + // Process a single folder. + try { + await dav.sync.folder(syncData); + } catch (e) { + if (e.name == "dav4tbsync") { + return e.statusData; + } else { + Components.utils.reportError(e); + // re-throw any other error and let TbSync handle it + throw (e); + } + } + + // we fall through, if there was no error + return new TbSync.StatusData(); + } +} + + + + + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +// * TargetData implementation +// * Using TbSyncs advanced address book TargetData +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +var TargetData_addressbook = class extends TbSync.addressbook.AdvancedTargetData { + constructor(folderData) { + super(folderData); + } + + // enable or disable changelog + get logUserChanges() { + return false; + } + + directoryObserver(aTopic) { + switch (aTopic) { + case "addrbook-directory-deleted": + case "addrbook-directory-updated": + //Services.console.logStringMessage("["+ aTopic + "] " + this.folderData.getFolderProperty("foldername")); + break; + } + } + + cardObserver(aTopic, abCardItem) { + switch (aTopic) { + case "addrbook-contact-updated": + case "addrbook-contact-deleted": + case "addrbook-contact-created": + //Services.console.logStringMessage("["+ aTopic + "] " + abCardItem.getProperty("DisplayName")); + break; + } + } + + listObserver(aTopic, abListItem, abListMember) { + switch (aTopic) { + case "addrbook-list-member-added": + case "addrbook-list-member-removed": + //Services.console.logStringMessage("["+ aTopic + "] MemberName: " + abListMember.getProperty("DisplayName")); + break; + + case "addrbook-list-deleted": + case "addrbook-list-updated": + //Services.console.logStringMessage("["+ aTopic + "] ListName: " + abListItem.getProperty("ListName")); + break; + + case "addrbook-list-created": + //Services.console.logStringMessage("["+ aTopic + "] Created new X-DAV-UID for List <"+abListItem.getProperty("ListName")+">"); + break; + } + } + + async createAddressbook(newname) { + let authData = dav.network.getAuthData(this.folderData.accountData); + + let baseUrl = "http" + (this.folderData.getFolderProperty("https") ? "s" : "") + "://" + this.folderData.getFolderProperty("fqdn"); + let url = dav.tools.parseUri(baseUrl + this.folderData.getFolderProperty("href") + (dav.sync.prefSettings.getBoolPref("enforceUniqueCalendarUrls") ? "?" + this.folderData.accountID : "")); + this.folderData.setFolderProperty("url", url.spec); + + const getDirectory = (url) => { + // Check if that directory exists already. + for (let ab of MailServices.ab.directories) { + if (ab.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE && ab.getStringValue("carddav.url","") == url.spec) { + return ab; + } + } + let dirPrefId = MailServices.ab.newAddressBook( + newname, + null, + Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE, + null + ); + let directory = MailServices.ab.getDirectoryFromId(dirPrefId); + directory.setStringValue("carddav.url", url.spec); + return directory; + } + + let directory = getDirectory(url); + if (!directory || !(directory instanceof Components.interfaces.nsIAbDirectory)) { + return null; + } + + // Setup password for CardDAV address book, so users do not get prompted. + directory.setStringValue("carddav.username", authData.username); + if (this.folderData.getFolderProperty("downloadonly")) { + directory.setBoolValue("readOnly", true); + } + TbSync.dump("Searching CardDAV authRealm for", url.host); + let connectionData = new dav.network.ConnectionData(this.folderData); + await dav.network.sendRequest("<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:resourcetype /><d:displayname /></d:prop></d:propfind>", url.spec , "PROPFIND", connectionData, {"Depth": "0", "Prefer": "return=minimal"}, {containerRealm: "setup", containerReset: true, passwordRetries: 0}); + + let realm = connectionData.realm || ""; + if (realm !== "") { + TbSync.dump("Adding CardDAV password", "User <"+authData.username+">, Realm <"+realm+">"); + // Manually create a CardDAV style entry in the password manager. + TbSync.passwordManager.updateLoginInfo( + url.prePath, realm, + /* old */ authData.username, + /* new */ authData.username, + authData.password + ); + } + + dav.sync.resetFolderSyncInfo(this.folderData); + + /* + // Since icons are no longer supported, lets disable this for 102. + let serviceprovider = this.folderData.accountData.getAccountProperty("serviceprovider"); + let icon = "custom"; + if (dav.sync.serviceproviders.hasOwnProperty(serviceprovider)) { + icon = dav.sync.serviceproviders[serviceprovider].icon; + } + directory.setStringValue("tbSyncIcon", "dav" + icon); + */ + + return directory; + } +} + + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +// * TargetData implementation +// * Using TbSyncs advanced calendar TargetData +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +var TargetData_calendar = class extends TbSync.lightning.AdvancedTargetData { + constructor(folderData) { + super(folderData); + } + // The calendar target does not support a custom primaryKeyField, because + // the lightning implementation only allows to search for items via UID. + // Like the addressbook target, the calendar target item element has a + // primaryKey getter/setter which - however - only works on the UID. + + // enable or disable changelog + get logUserChanges(){ + return false; + } + + calendarObserver(aTopic, tbCalendar, aPropertyName, aPropertyValue, aOldPropertyValue) { + switch (aTopic) { + case "onCalendarPropertyChanged": + { + //Services.console.logStringMessage("["+ aTopic + "] " + tbCalendar.calendar.name + " : " + aPropertyName); + switch (aPropertyName) { + case "color": + if (aOldPropertyValue.toString().toUpperCase() != aPropertyValue.toString().toUpperCase()) { + //prepare connection data + let connection = new dav.network.ConnectionData(this.folderData); + //update color on server + dav.network.sendRequest("<d:propertyupdate "+dav.tools.xmlns(["d","apple"])+"><d:set><d:prop><apple:calendar-color>"+(aPropertyValue + "FFFFFFFF").slice(0,9)+"</apple:calendar-color></d:prop></d:set></d:propertyupdate>", this.folderData.getFolderProperty("href"), "PROPPATCH", connection); + } + break; + } + } + break; + + case "onCalendarDeleted": + case "onCalendarPropertyDeleted": + //Services.console.logStringMessage("["+ aTopic + "] " +tbCalendar.calendar.name); + break; + } + } + + itemObserver(aTopic, tbItem, tbOldItem) { + switch (aTopic) { + case "onAddItem": + case "onModifyItem": + case "onDeleteItem": + //Services.console.logStringMessage("["+ aTopic + "] " + tbItem.nativeItem.title); + break; + } + } + + async createCalendar(newname) { + let calManager = TbSync.lightning.cal.manager; + let authData = dav.network.getAuthData(this.folderData.accountData); + + let caltype = this.folderData.getFolderProperty("type"); + + let baseUrl = ""; + if (caltype == "caldav") { + baseUrl = "http" + (this.folderData.getFolderProperty("https") ? "s" : "") + "://" + this.folderData.getFolderProperty("fqdn"); + } + + let url = dav.tools.parseUri(baseUrl + this.folderData.getFolderProperty("href") + (dav.sync.prefSettings.getBoolPref("enforceUniqueCalendarUrls") ? "?" + this.folderData.accountID : "")); + this.folderData.setFolderProperty("url", url.spec); + + // Check if that calendar already exists. + let cals = calManager.getCalendars({}); + let newCalendar = null; + let found = false; + for (let calendar of calManager.getCalendars({})) { + if (calendar.uri.spec == url.spec) { + newCalendar = calendar; + found = true; + break; + } + } + + + if (found) { + newCalendar.setProperty("username", authData.username); + newCalendar.setProperty("color", this.folderData.getFolderProperty("targetColor")); + newCalendar.name = newname; + } else { + newCalendar = calManager.createCalendar(caltype, url); // caldav or ics + newCalendar.id = TbSync.lightning.cal.getUUID(); + newCalendar.name = newname; + + newCalendar.setProperty("username", authData.username); + newCalendar.setProperty("color", this.folderData.getFolderProperty("targetColor")); + // removed in TB78, as it seems to not fully enable the calendar, if present before registering + // https://searchfox.org/comm-central/source/calendar/base/content/calendar-management.js#385 + //newCalendar.setProperty("calendar-main-in-composite",true); + newCalendar.setProperty("cache.enabled", this.folderData.accountData.getAccountProperty("useCalendarCache")); + } + + if (this.folderData.getFolderProperty("downloadonly")) newCalendar.setProperty("readOnly", true); + + let comp = this.folderData.getFolderProperty("supportedCalComponent"); + if (comp.length > 0 && !comp.includes("VTODO")) { + newCalendar.setProperty("capabilities.tasks.supported", false); + } + if (comp.length > 0 && !comp.includes("VEVENT")) { + newCalendar.setProperty("capabilities.events.supported", false); + } + + // Setup password for CalDAV calendar, so users do not get prompted (ICS urls do not need a password). + if (caltype == "caldav") { + TbSync.dump("Searching CalDAV authRealm for", url.host); + let connectionData = new dav.network.ConnectionData(this.folderData); + await dav.network.sendRequest("<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:resourcetype /><d:displayname /></d:prop></d:propfind>", url.spec , "PROPFIND", connectionData, {"Depth": "0", "Prefer": "return=minimal"}, {containerRealm: "setup", containerReset: true, passwordRetries: 0}); + + let realm = connectionData.realm || ""; + if (realm !== "") { + TbSync.dump("Adding CalDAV password", "User <"+authData.username+">, Realm <"+realm+">"); + // Manually create a CalDAV style entry in the password manager. + TbSync.passwordManager.updateLoginInfo( + url.prePath, realm, + /* old */ authData.username, + /* new */ authData.username, + authData.password + ); + } + } + + if (!found) { + calManager.registerCalendar(newCalendar); + } + return newCalendar; + } +} + + + + + +/** + * This provider is implementing the StandardFolderList class instead of + * the FolderList class. + */ +var StandardFolderList = class { + /** + * Is called before the context menu of the folderlist is shown, allows to + * show/hide custom menu options based on selected folder. During an active + * sync, folderData will be null. + */ + static onContextMenuShowing(window, folderData) { + } + + + /** + * Return the icon used in the folderlist to represent the different folder + * types. + */ + static getTypeImage(folderData) { + let src = ""; + switch (folderData.getFolderProperty("type")) { + case "carddav": + if (folderData.getFolderProperty("shared")) { + return "chrome://tbsync/content/skin/contacts16_shared.png"; + } else { + return "chrome://tbsync/content/skin/contacts16.png"; + } + case "caldav": + let comp = folderData.getFolderProperty("supportedCalComponent"); + if (folderData.getFolderProperty("shared")) { + return (comp.length > 0 && comp.includes("VTODO") && !comp.includes("VEVENT")) + ? "chrome://tbsync/content/skin/todo16_shared.png" + : "chrome://tbsync/content/skin/calendar16_shared.png" + } else { + return (comp.length > 0 && comp.includes("VTODO") && !comp.includes("VEVENT")) + ? "chrome://tbsync/content/skin/todo16.png" + : "chrome://tbsync/content/skin/calendar16.png" + } + case "ics": + return "chrome://dav4tbsync/content/skin/ics16.png"; + } + } + + + /** + * Return the name of the folder shown in the folderlist. + */ + static getFolderDisplayName(folderData) { + return folderData.getFolderProperty("foldername"); + } + + + /** + * Return the attributes for the ACL RO (readonly) menu element per folder. + * (label, disabled, hidden, style, ...) + * + * Return a list of attributes and their values. If both (RO+RW) do + * not return any attributes, the ACL menu is not displayed at all. + */ + static getAttributesRoAcl(folderData) { + return { + label: TbSync.getString("acl.readonly", "dav"), + }; + } + + + /** + * Return the attributes for the ACL RW (readwrite) menu element per folder. + * (label, disabled, hidden, style, ...) + * + * Return a list of attributes and their values. If both (RO+RW) do + * not return any attributes, the ACL menu is not displayed at all. + */ + static getAttributesRwAcl(folderData) { + let acl = parseInt(folderData.getFolderProperty("acl")); + let acls = []; + if (acl & 0x2) acls.push(TbSync.getString("acl.modify", "dav")); + if (acl & 0x4) acls.push(TbSync.getString("acl.add", "dav")); + if (acl & 0x8) acls.push(TbSync.getString("acl.delete", "dav")); + if (acls.length == 0) acls.push(TbSync.getString("acl.none", "dav")); + + return { + label: TbSync.getString("acl.readwrite::"+acls.join(", "), "dav"), + disabled: (acl & 0x7) != 0x7, + } + } +} + +Services.scriptloader.loadSubScript("chrome://dav4tbsync/content/includes/sync.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://dav4tbsync/content/includes/tools.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://dav4tbsync/content/includes/network.js", this, "UTF-8"); diff --git a/content/skin/ab.css b/content/skin/ab.css new file mode 100644 index 0000000..a1398da --- /dev/null +++ b/content/skin/ab.css @@ -0,0 +1,82 @@ +treechildren::-moz-tree-image(DirCol, davgoogle) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/google16.png"); +} + +treechildren::-moz-tree-image(DirCol, davweb) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/web16.png"); +} + +treechildren::-moz-tree-image(DirCol, davfruux) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/fruux16.png"); +} + +treechildren::-moz-tree-image(DirCol, davposteo) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/posteo16.png"); +} + +treechildren::-moz-tree-image(DirCol, davmbo) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/mbo16.png"); +} + +treechildren::-moz-tree-image(DirCol, davicloud) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/icloud16.png"); +} + +treechildren::-moz-tree-image(DirCol, davyahoo) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/yahoo16.png"); +} + +treechildren::-moz-tree-image(DirCol, davgmx) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/gmx16.png"); +} + +treechildren::-moz-tree-image(DirCol, davcustom) { + margin-inline-end: 2px; + list-style-image: url("chrome://dav4tbsync/content/skin/sabredav16.png"); +} + + +.abMenuItem[AddrBook="true"][TbSyncIcon="davgoogle"] { + list-style-image: url("chrome://dav4tbsync/content/skin/google16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davweb"] { + list-style-image: url("chrome://dav4tbsync/content/skin/web16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davfruux"] { + list-style-image: url("chrome://dav4tbsync/content/skin/fruux16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davposteo"] { + list-style-image: url("chrome://dav4tbsync/content/skin/posteo16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davmbo"] { + list-style-image: url("chrome://dav4tbsync/content/skin/mbo16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davicloud"] { + list-style-image: url("chrome://dav4tbsync/content/skin/icloud16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davyahoo"] { + list-style-image: url("chrome://dav4tbsync/content/skin/yahoo16.png"); +} + +.abMenuItem[AddrBook="true"][TbSyncIcon="davgmx"] { + list-style-image: url("chrome://dav4tbsync/content/skin/gmx16.png"); +} + + +.abMenuItem[AddrBook="true"][TbSyncIcon="davcustom"] { + list-style-image: url("chrome://dav4tbsync/content/skin/sabredav16.png"); +} diff --git a/content/skin/arrow.down10.png b/content/skin/arrow.down10.png Binary files differnew file mode 100644 index 0000000..82eccb5 --- /dev/null +++ b/content/skin/arrow.down10.png diff --git a/content/skin/arrow.up10.png b/content/skin/arrow.up10.png Binary files differnew file mode 100644 index 0000000..db87a3d --- /dev/null +++ b/content/skin/arrow.up10.png diff --git a/content/skin/dragdrop.png b/content/skin/dragdrop.png Binary files differnew file mode 100644 index 0000000..a3797da --- /dev/null +++ b/content/skin/dragdrop.png diff --git a/content/skin/fruux16.png b/content/skin/fruux16.png Binary files differnew file mode 100644 index 0000000..61d25f2 --- /dev/null +++ b/content/skin/fruux16.png diff --git a/content/skin/fruux32.png b/content/skin/fruux32.png Binary files differnew file mode 100644 index 0000000..2d37e6b --- /dev/null +++ b/content/skin/fruux32.png diff --git a/content/skin/fruux48.png b/content/skin/fruux48.png Binary files differnew file mode 100644 index 0000000..c2b9aca --- /dev/null +++ b/content/skin/fruux48.png diff --git a/content/skin/gmx16.png b/content/skin/gmx16.png Binary files differnew file mode 100644 index 0000000..1672e4a --- /dev/null +++ b/content/skin/gmx16.png diff --git a/content/skin/gmx32.png b/content/skin/gmx32.png Binary files differnew file mode 100644 index 0000000..bb2dd43 --- /dev/null +++ b/content/skin/gmx32.png diff --git a/content/skin/gmx48.png b/content/skin/gmx48.png Binary files differnew file mode 100644 index 0000000..d23c125 --- /dev/null +++ b/content/skin/gmx48.png diff --git a/content/skin/icloud16.png b/content/skin/icloud16.png Binary files differnew file mode 100644 index 0000000..4399f6a --- /dev/null +++ b/content/skin/icloud16.png diff --git a/content/skin/icloud32.png b/content/skin/icloud32.png Binary files differnew file mode 100644 index 0000000..6691ebc --- /dev/null +++ b/content/skin/icloud32.png diff --git a/content/skin/icloud48.png b/content/skin/icloud48.png Binary files differnew file mode 100644 index 0000000..2171c1d --- /dev/null +++ b/content/skin/icloud48.png diff --git a/content/skin/ics16.png b/content/skin/ics16.png Binary files differnew file mode 100644 index 0000000..7fc8ab6 --- /dev/null +++ b/content/skin/ics16.png diff --git a/content/skin/mbo16.png b/content/skin/mbo16.png Binary files differnew file mode 100644 index 0000000..bbf127f --- /dev/null +++ b/content/skin/mbo16.png diff --git a/content/skin/mbo32.png b/content/skin/mbo32.png Binary files differnew file mode 100644 index 0000000..ee86331 --- /dev/null +++ b/content/skin/mbo32.png diff --git a/content/skin/mbo48.png b/content/skin/mbo48.png Binary files differnew file mode 100644 index 0000000..90c62dc --- /dev/null +++ b/content/skin/mbo48.png diff --git a/content/skin/posteo16.png b/content/skin/posteo16.png Binary files differnew file mode 100644 index 0000000..922078b --- /dev/null +++ b/content/skin/posteo16.png diff --git a/content/skin/posteo32.png b/content/skin/posteo32.png Binary files differnew file mode 100644 index 0000000..b296393 --- /dev/null +++ b/content/skin/posteo32.png diff --git a/content/skin/posteo48.png b/content/skin/posteo48.png Binary files differnew file mode 100644 index 0000000..a1f015d --- /dev/null +++ b/content/skin/posteo48.png diff --git a/content/skin/sabredav16.png b/content/skin/sabredav16.png Binary files differnew file mode 100644 index 0000000..fda1f68 --- /dev/null +++ b/content/skin/sabredav16.png diff --git a/content/skin/sabredav32.png b/content/skin/sabredav32.png Binary files differnew file mode 100644 index 0000000..b0e80d6 --- /dev/null +++ b/content/skin/sabredav32.png diff --git a/content/skin/sabredav48.png b/content/skin/sabredav48.png Binary files differnew file mode 100644 index 0000000..48a9739 --- /dev/null +++ b/content/skin/sabredav48.png diff --git a/content/skin/type.car10.png b/content/skin/type.car10.png Binary files differnew file mode 100644 index 0000000..ca12b71 --- /dev/null +++ b/content/skin/type.car10.png diff --git a/content/skin/type.car16.png b/content/skin/type.car16.png Binary files differnew file mode 100644 index 0000000..db6fb9d --- /dev/null +++ b/content/skin/type.car16.png diff --git a/content/skin/type.cell10.png b/content/skin/type.cell10.png Binary files differnew file mode 100644 index 0000000..ef5ebac --- /dev/null +++ b/content/skin/type.cell10.png diff --git a/content/skin/type.cell16.png b/content/skin/type.cell16.png Binary files differnew file mode 100644 index 0000000..0716347 --- /dev/null +++ b/content/skin/type.cell16.png diff --git a/content/skin/type.fax10.png b/content/skin/type.fax10.png Binary files differnew file mode 100644 index 0000000..276f0d1 --- /dev/null +++ b/content/skin/type.fax10.png diff --git a/content/skin/type.fax16.png b/content/skin/type.fax16.png Binary files differnew file mode 100644 index 0000000..4b9a92c --- /dev/null +++ b/content/skin/type.fax16.png diff --git a/content/skin/type.home10.png b/content/skin/type.home10.png Binary files differnew file mode 100644 index 0000000..8a72f0f --- /dev/null +++ b/content/skin/type.home10.png diff --git a/content/skin/type.home16.png b/content/skin/type.home16.png Binary files differnew file mode 100644 index 0000000..84d829b --- /dev/null +++ b/content/skin/type.home16.png diff --git a/content/skin/type.nopref.png b/content/skin/type.nopref.png Binary files differnew file mode 100644 index 0000000..27caa40 --- /dev/null +++ b/content/skin/type.nopref.png diff --git a/content/skin/type.other10.png b/content/skin/type.other10.png Binary files differnew file mode 100644 index 0000000..d05fc5f --- /dev/null +++ b/content/skin/type.other10.png diff --git a/content/skin/type.other16.png b/content/skin/type.other16.png Binary files differnew file mode 100644 index 0000000..60a169b --- /dev/null +++ b/content/skin/type.other16.png diff --git a/content/skin/type.pager10.png b/content/skin/type.pager10.png Binary files differnew file mode 100644 index 0000000..5c6fbe7 --- /dev/null +++ b/content/skin/type.pager10.png diff --git a/content/skin/type.pager16.png b/content/skin/type.pager16.png Binary files differnew file mode 100644 index 0000000..6a0f2b1 --- /dev/null +++ b/content/skin/type.pager16.png diff --git a/content/skin/type.pref.png b/content/skin/type.pref.png Binary files differnew file mode 100644 index 0000000..409a30c --- /dev/null +++ b/content/skin/type.pref.png diff --git a/content/skin/type.video10.png b/content/skin/type.video10.png Binary files differnew file mode 100644 index 0000000..188cabc --- /dev/null +++ b/content/skin/type.video10.png diff --git a/content/skin/type.video16.png b/content/skin/type.video16.png Binary files differnew file mode 100644 index 0000000..afb8c7c --- /dev/null +++ b/content/skin/type.video16.png diff --git a/content/skin/type.voice10.png b/content/skin/type.voice10.png Binary files differnew file mode 100644 index 0000000..3ed48b6 --- /dev/null +++ b/content/skin/type.voice10.png diff --git a/content/skin/type.voice16.png b/content/skin/type.voice16.png Binary files differnew file mode 100644 index 0000000..debb016 --- /dev/null +++ b/content/skin/type.voice16.png diff --git a/content/skin/type.work10.png b/content/skin/type.work10.png Binary files differnew file mode 100644 index 0000000..10db4e7 --- /dev/null +++ b/content/skin/type.work10.png diff --git a/content/skin/type.work16.png b/content/skin/type.work16.png Binary files differnew file mode 100644 index 0000000..de3036f --- /dev/null +++ b/content/skin/type.work16.png diff --git a/content/skin/web16.png b/content/skin/web16.png Binary files differnew file mode 100644 index 0000000..3a738a5 --- /dev/null +++ b/content/skin/web16.png diff --git a/content/skin/web32.png b/content/skin/web32.png Binary files differnew file mode 100644 index 0000000..234e2f8 --- /dev/null +++ b/content/skin/web32.png diff --git a/content/skin/web48.png b/content/skin/web48.png Binary files differnew file mode 100644 index 0000000..6909c9e --- /dev/null +++ b/content/skin/web48.png diff --git a/content/skin/yahoo16.png b/content/skin/yahoo16.png Binary files differnew file mode 100644 index 0000000..7225a3a --- /dev/null +++ b/content/skin/yahoo16.png diff --git a/content/skin/yahoo32.png b/content/skin/yahoo32.png Binary files differnew file mode 100644 index 0000000..054ec55 --- /dev/null +++ b/content/skin/yahoo32.png diff --git a/content/skin/yahoo48.png b/content/skin/yahoo48.png Binary files differnew file mode 100644 index 0000000..bfd7d31 --- /dev/null +++ b/content/skin/yahoo48.png |