diff --git a/gulpfile.js b/gulpfile.js index c84660db2..9e73c7f5c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -101,7 +101,7 @@ var injectIncludeFirefox = ['browser-polyfill.js'].concat( ['api.js'], injectIncludeLast); -var injectIncludeSafari = ['browser-polyfill.js'].concat( +var injectIncludeSafari = ['reinjectGuard.js', 'browser-polyfill.js'].concat( injectInclude, ['api.js'], ['frameMessaging.js'], @@ -158,6 +158,7 @@ if (!argv.p) { injectIncludeManifestV3.push('test/testInject.js'); } var backgroundIncludeBrowserExt = ['browser-polyfill.js'].concat(backgroundInclude, [ + 'hostPermissions.js', 'webRequestIntercept.js', 'contentTypeHandler.js', 'saveWithoutProgressWindow.js', @@ -334,6 +335,17 @@ function processFile() { backgroundScripts.map((s) => `"${s}"`).join(',\n\t\t\t')) .replace("/*INJECT SCRIPTS*/", injectScripts.map((s) => `"${s}"`).join(',\n\t\t\t')) + if (basename == 'manifest.json' && browser == 'safari') { + // Safari runs content scripts only on sites where the user has granted + // access, so the pre-detection gray webpage icon can show indefinitely -- + // default to the Z instead + let manifest = JSON.parse(contents); + manifest.browser_action.default_icon = { + 16: "images/zotero-z-16px.png", + 32: "images/zotero-z-32px.png" + }; + contents = JSON.stringify(manifest, null, '\t'); + } } contents = contents diff --git a/src/browserExt/background.js b/src/browserExt/background.js index 3644e2880..81a3d3e4d 100644 --- a/src/browserExt/background.js +++ b/src/browserExt/background.js @@ -356,12 +356,12 @@ Zotero.Connector_Browser = new function() { } deferred = Zotero.Promise.defer(); this.injectTranslationScripts[key] = deferred; - - let response = await Zotero.Messaging.sendMessage('ping', null, tab, frameId) - if (response && frameId == 0) return deferred.resolve(); - url = url ? `${url} - ${tab.url}` : tab.url - Zotero.debug(`Injecting translation scripts into ${frameId} ${url}`); + try { + let response = await Zotero.Messaging.sendMessage('ping', null, tab, frameId) + if (response && frameId == 0) return deferred.resolve(); + url = url ? `${url} - ${tab.url}` : tab.url + Zotero.debug(`Injecting translation scripts into ${frameId} ${url}`); return await Zotero.Connector_Browser.injectScripts(_injectTranslationScripts, tab, frameId); } catch (e) { Zotero.debug(`Translation Inject: Script injection rejected ${key}`); @@ -664,6 +664,14 @@ Zotero.Connector_Browser = new function() { var isPDF = tabInfo.isPDF; var translators = tabInfo.translators; + // Safari runs content scripts only on sites where the user has granted access, and + // clicking the button on other sites enables the Connector on the site instead of + // saving. Until a content script reports detection results, keep the default Z icon + // rather than showing a save action that hasn't been determined. + if (Zotero.isSafari && !translators && !isPDF) { + return; + } + // Show the save menu if we have more than one save option to show, which is true in all cases // other than for PDFs with no translator var showSaveMenu = (translators && translators.length) || !isPDF; @@ -761,10 +769,11 @@ Zotero.Connector_Browser = new function() { // it's not treated like we do it within a gesture await browser.permissions.request({permissions: ['clipboardWrite']}); } - const shouldContinue = await _checkPermissions(tab); + const shouldContinue = await Zotero.HostPermissions.checkChromiumActionPermissions(tab); if (!shouldContinue) { return; } + await _ensureScriptsInjected(tab); // The PDF viewer in Chromium is apparently implemented as a special extension. // If you right-click on the pdf-reader UI and select a Zotero option, the handler @@ -798,7 +807,13 @@ Zotero.Connector_Browser = new function() { if (isOnline) { icon = "images/zotero-new-z-16px.png"; title = "Zotero is Online"; - } else { + } + else if (isOnline === null) { + // Zotero's status is unknown without localhost access, so don't claim it's offline + icon = "images/zotero-new-z-16px.png"; + title = "Zotero Connector"; + } + else { icon = "images/zotero-z-16px-offline.png"; title = "Zotero is Offline"; } @@ -1039,68 +1054,25 @@ Zotero.Connector_Browser = new function() { } /** - * Check if we have permission to run on all sites. - * Prompts the user if permissions are insufficient. - * @param {Object} tab - The current tab object - * @returns {Promise} - Returns false if the action should not proceed + * Safari doesn't run content scripts in tabs that are already open when the user grants + * site access, so inject them on demand before performing a user action, and give + * translator detection a moment to report before a save mode is chosen */ - async function _checkPermissions(tab) { - // Firefox doesn't have per-site permissions in MV2. - if (Zotero.isFirefox) { - return true; - } - - try { - const hasPermissions = await browser.permissions.contains({ - origins: ["https://*/*"] - }); - - if (hasPermissions) { - return true; - } - - const messageIntro = Zotero.getString("permissions_siteAccess_message_intro"); - let promptProps = { - title: Zotero.getString("permissions_siteAccess_title"), - button1Text: Zotero.getString("permissions_siteAccess_openPreferences"), - button2Text: Zotero.getString("general_cancel"), - button3Text: Zotero.getString("general_continueAnyway"), - message: messageIntro + Zotero.getString("permissions_siteAccess_message") - }; - if (Zotero.isSafari) { - promptProps = { - title: Zotero.getString("permissions_siteAccess_title"), - button1Text: Zotero.getString("general_cancel"), - button2Text: "", - button3Text: Zotero.getString("general_continueAnyway"), - message: messageIntro + Zotero.getString( - "permissions_siteAccess_message_safari", - Zotero.getString('appConnector', ZOTERO_CONFIG.CLIENT_NAME) - ) - }; - } - - const result = await Zotero.Messaging.sendMessage('confirm', promptProps, tab); - - if (result) { - if (!Zotero.isSafari && result.button === 1) { - browser.tabs.create({ - url: `about:extensions/?id=${browser.runtime.id}` - }); - } - return result.button === 3; - } - } catch (e) { - Zotero.debug('Error checking permissions: ' + e.message); - return true; + async function _ensureScriptsInjected(tab) { + if (!Zotero.isSafari) return; + await Zotero.Connector_Browser.injectTranslationScripts(tab); + let tabInfo = Zotero.Connector_Browser.getTabInfo(tab.id); + for (let i = 0; i < 30 && !tabInfo.translators; i++) { + await Zotero.Promise.delay(100); } } - + async function _browserAction(tab) { - const shouldContinue = await _checkPermissions(tab); + const shouldContinue = await Zotero.HostPermissions.checkChromiumActionPermissions(tab); if (!shouldContinue) { return; } + await _ensureScriptsInjected(tab); let tabInfo = Zotero.Connector_Browser.getTabInfo(tab.id); if (_isBetaBuildBeyondExpiration) { diff --git a/src/browserExt/hostPermissions.js b/src/browserExt/hostPermissions.js new file mode 100644 index 000000000..7f53994b3 --- /dev/null +++ b/src/browserExt/hostPermissions.js @@ -0,0 +1,292 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +Zotero.HostPermissions = new function() { + const LOCALHOST_DOMAIN = '127.0.0.1'; + const REPOSITORY_DOMAIN = 'repo.zotero.org'; + const DOMAIN_CONFIG = { + [LOCALHOST_DOMAIN]: { + origin: 'http://127.0.0.1/*', + message: 'permissions_siteAccess_message_localhost_required' + }, + [REPOSITORY_DOMAIN]: { + origin: 'https://repo.zotero.org/*', + message: 'permissions_siteAccess_message_repo_required' + }, + 'api.zotero.org': { + origin: 'https://api.zotero.org/*', + message: 'permissions_siteAccess_message_api_required' + } + }; + + this._permissionsPromptDisplayed = false; + this._localhostPromptDisplayed = false; + this._repoPromptDisplayed = false; + // Set when a request was blocked with localhost access still missing, meaning Safari is + // treating the access as denied and won't show its permission dialog + this.localhostRequestBlocked = false; + // When the user last granted a host permission, e.g., in Safari's permission dialog + this.permissionsGrantedAt = 0; + + let promptQueues = new Map(); + let knownPermissions = null; + + async function hasPermission(domain) { + const config = DOMAIN_CONFIG[domain]; + if (!config) throw new Error(`Unknown host-permission domain ${domain}`); + return browser.permissions.contains({ origins: [config.origin] }); + } + this.hasPermission = hasPermission; + + // Safari doesn't notify the extension of grants made in its permission dialog or Safari + // Settings, so track the permission state and record when a permission appears that the + // last seen state lacked. Refreshing on an interval also records revocations, so a + // revoked-and-regranted permission is still detected as a grant. + async function updateTrackedPermissions() { + const current = { + localhost: await hasPermission(LOCALHOST_DOMAIN), + allHosts: await browser.permissions.contains({ origins: ["https://*/*"] }) + }; + if (knownPermissions + && ((current.localhost && !knownPermissions.localhost) + || (current.allHosts && !knownPermissions.allHosts))) { + Zotero.HostPermissions.permissionsGrantedAt = Date.now(); + if (current.localhost) { + Zotero.HostPermissions.localhostRequestBlocked = false; + } + } + knownPermissions = current; + } + if (Zotero.isSafari) { + updateTrackedPermissions(); + setInterval(updateTrackedPermissions, 15e3); + } + + // Detect grants made via a browser event, where the browser supports it + browser.permissions.onAdded?.addListener(() => { + this.permissionsGrantedAt = Date.now(); + }); + + // Tell the content script re-injection guard whether a grant explains the repeated injection + Zotero.Messaging.addMessageListener('reinjectGuard.shouldReload', async () => { + if (Date.now() - this.permissionsGrantedAt < 60e3) return true; + // The interval may not have run since the grant, so check directly + await updateTrackedPermissions(); + return Date.now() - this.permissionsGrantedAt < 60e3; + }); + + /** + * Display a Safari host-permission prompt. All prompts end with instructions for enabling the + * requested access. + * @param {Object} options + * @param {String[]} [options.domains] - Specific required domains + * @param {Boolean} [options.recommendAllHosts] - Recommend access to all websites + * @param {Boolean} [options.nativePromptToFollow] - A request that can trigger Safari's own + * permission dialog for the domains follows this prompt, so point to that dialog instead + * of Safari Settings + * @param {Object} tab - The current tab object + * @returns {Promise} - Whether a prompt was displayed + */ + this.prompt = async function(options={}, tab) { + if (!Zotero.isSafari) return false; + + // Serialize prompts so concurrent callers cannot display overlapping modals in the same + // tab, and prevent a modal left open in one tab from blocking prompts in other tabs. + // Permissions are checked when a queued prompt runs, so access granted during an earlier + // prompt is respected. + let key = tab?.id ?? null; + let queue = promptQueues.get(key) || Promise.resolve(); + let promise = queue.then(() => showPrompt(options, tab)); + let tail = promise.then(() => {}, () => {}); + promptQueues.set(key, tail); + tail.then(() => { + if (promptQueues.get(key) === tail) { + promptQueues.delete(key); + } + }); + return promise; + } + + async function showPrompt({domains=[], recommendAllHosts=false, nativePromptToFollow=false}={}, tab) { + // Resolve all requested permissions first so the user sees one combined prompt containing only + // the access that is actually missing. + const permissionChecks = domains.map(domain => hasPermission(domain)); + if (recommendAllHosts) { + permissionChecks.push(browser.permissions.contains({ origins: ["https://*/*"] })); + } + const results = await Promise.all(permissionChecks); + const missingDomains = domains.filter((domain, index) => !results[index]); + const missingAllHosts = recommendAllHosts && !results[results.length - 1]; + if (!missingDomains.length && !missingAllHosts) return false; + + // Explanations of the missing access come first. Domains that Safari's own dialog is about + // to cover get a pointer to that dialog; everything else gets Safari Settings + // instructions. + let message = missingDomains + .map(domain => Zotero.getString(DOMAIN_CONFIG[domain].message)) + .join(''); + let connectorName = Zotero.getString('appConnector', ZOTERO_CONFIG.CLIENT_NAME); + // e.g., "repo.zotero.org and api.zotero.org", with a locale-appropriate + // conjunction + let domainList = new Intl.ListFormat(browser.i18n.getUILanguage(), { type: 'conjunction' }) + .format(missingDomains.map(domain => `${domain}`)); + if (nativePromptToFollow && missingDomains.length) { + message += Zotero.getString("permissions_siteAccess_message_nativePrompt_safari"); + if (missingAllHosts) { + // Safari's dialog can also grant all-websites access via its "Remember for other + // websites" checkbox + message += Zotero.getString("permissions_siteAccess_message_allHosts_nativePrompt_safari"); + } + } + else if (missingDomains.length && missingAllHosts) { + message += Zotero.getString("permissions_siteAccess_message_safari_functionality"); + message += Zotero.getString( + "permissions_siteAccess_message_domains_allHosts_safari", + [connectorName, domainList] + ); + } + else if (missingDomains.length) { + message += Zotero.getString( + missingDomains.length > 1 + ? "permissions_siteAccess_message_domains_safari" + : "permissions_siteAccess_message_domain_safari", + [connectorName, domainList] + ); + } + else { + message += Zotero.getString("permissions_siteAccess_message_safari_functionality"); + message += Zotero.getString("permissions_siteAccess_message_safari", connectorName); + } + + await Zotero.Messaging.sendMessage('confirm', { + title: Zotero.getString("permissions_siteAccess_title"), + button2Text: "", + message + }, tab); + return true; + } + + /** + * Check initial permissions when content scripts first gain access to a page. + * @param {Object} tab - The current tab object + */ + this.onPageLoad = async function(tab) { + if (!Zotero.isSafari || this._permissionsPromptDisplayed) return; + this._permissionsPromptDisplayed = true; + let markLocalhostPromptShown = false; + let markRepoPromptShown = false; + + try { + const hasLocalhostPermission = await hasPermission(LOCALHOST_DOMAIN); + const showLocalhostPrompt = !hasLocalhostPermission && !this._localhostPromptDisplayed; + + if (showLocalhostPrompt) { + this._localhostPromptDisplayed = true; + markLocalhostPromptShown = true; + } + // Prompt for localhost access and also recommend all hosts. The ping below can trigger + // Safari's own permission dialog for localhost. + await this.prompt({ + domains: showLocalhostPrompt ? [LOCALHOST_DOMAIN] : [], + recommendAllHosts: true, + nativePromptToFollow: showLocalhostPrompt + }, tab); + + let localhostAllowed = hasLocalhostPermission; + let zoteroOnline = null; + if (showLocalhostPrompt) { + // Trigger Safari's native localhost permission prompt after the explanation. + try { + await Zotero.Connector.ping({}, { + active: true, + permissionPromptShown: true + }, tab); + zoteroOnline = true; + } + catch (e) { + zoteroOnline = false; + } + // The user may have granted access in Safari's native prompt, so don't rely on the value + // captured before the request. + localhostAllowed = await hasPermission(LOCALHOST_DOMAIN); + } + else if (localhostAllowed) { + zoteroOnline = await Zotero.Connector.checkIsOnline(); + } + + // Only fall back to the repository permission when localhost access exists and the ping proves + // that Zotero itself is offline. A blocked localhost request cannot establish that. + if (localhostAllowed && zoteroOnline === false && !this._repoPromptDisplayed) { + this._repoPromptDisplayed = true; + markRepoPromptShown = true; + // Prompt that we need access to repo.zotero.org for translators when zotero is offline. + await this.prompt({domains: [REPOSITORY_DOMAIN]}, tab); + } + } + catch (e) { + // If displaying a prompt failed, roll back only the markers written during this attempt so a + // later page load can retry without disturbing successful prompts from earlier in the session. + if (markLocalhostPromptShown) this._localhostPromptDisplayed = false; + if (markRepoPromptShown) this._repoPromptDisplayed = false; + this._permissionsPromptDisplayed = false; + Zotero.debug('Error checking host permissions: ' + e.message); + } + } + + /** + * Check Chromium's all-host permission before an explicit Connector action. + * @param {Object} tab - The current tab object + * @returns {Promise} - Whether the action should continue + */ + this.checkChromiumActionPermissions = async function(tab) { + if (!Zotero.isChromium) return true; + + try { + const hasPermissions = await browser.permissions.contains({ + origins: ["https://*/*"] + }); + if (hasPermissions) return true; + + const result = await Zotero.Messaging.sendMessage('confirm', { + title: Zotero.getString("permissions_siteAccess_title"), + button1Text: Zotero.getString("permissions_siteAccess_openPreferences"), + button2Text: Zotero.getString("general_cancel"), + button3Text: Zotero.getString("general_continueAnyway"), + message: Zotero.getString("permissions_siteAccess_message_intro") + + Zotero.getString("permissions_siteAccess_message") + }, tab); + if (result?.button === 1) { + browser.tabs.create({ + url: `about:extensions/?id=${browser.runtime.id}` + }); + } + return result?.button === 3; + } + catch (e) { + Zotero.debug('Error checking Chromium permissions: ' + e.message); + return true; + } + } +} diff --git a/src/browserExt/manifest-v3.json b/src/browserExt/manifest-v3.json index 3f85b7c85..e51644aba 100644 --- a/src/browserExt/manifest-v3.json +++ b/src/browserExt/manifest-v3.json @@ -12,7 +12,7 @@ }, "default_title": "Save to Zotero" }, - "host_permissions": ["http://*/*", "https://*/*"], + "host_permissions": ["http://127.0.0.1/*", "https://repo.zotero.org/*", "https://api.zotero.org/*", "http://*/*", "https://*/*"], "permissions": ["tabs", "contextMenus", "cookies", "scripting", "offscreen", "webRequest", "declarativeNetRequest", "webNavigation", "storage"], "declarative_net_request": { diff --git a/src/browserExt/manifest.json b/src/browserExt/manifest.json index d43aeaca5..a30aadac6 100644 --- a/src/browserExt/manifest.json +++ b/src/browserExt/manifest.json @@ -13,7 +13,7 @@ "default_title": "Save to Zotero" }, "permissions": [ - "http://*/*", "https://*/*", + "http://127.0.0.1/*", "https://repo.zotero.org/*", "https://api.zotero.org/*", "http://*/*", "https://*/*", "tabs", "contextMenus", "cookies", "storage", "scripting", "webRequest", "webRequestBlocking", "webNavigation", "declarativeNetRequest" ], diff --git a/src/common/connector.js b/src/common/connector.js index 785a65f92..ffd5b4e0e 100644 --- a/src/common/connector.js +++ b/src/common/connector.js @@ -26,6 +26,7 @@ // TODO: refactor this class Zotero.Connector = new function() { const CONNECTOR_API_VERSION = 3; + const PASSIVE_METHODS = new Set(['ping', 'getTranslatorCode', 'getTranslators', 'getClientHostnames']); this.isOnline = (Zotero.isSafari || Zotero.isFirefox) ? false : null; this.clientVersion = ''; @@ -34,11 +35,19 @@ Zotero.Connector = new function() { }; /** - * Checks if Zotero is online and passes current status to callback + * Checks if Zotero is online + * @returns {Promise} - null if Safari blocked the localhost request or the + * request was skipped without localhost access, leaving Zotero's status unknown */ - this.checkIsOnline = async function() { + this.checkIsOnline = async function({active=false, permissionPromptShown=false}={}, tab=null) { + let hadLocalhostPermission = true; + if (Zotero.isSafari && active) { + hadLocalhostPermission = await browser.permissions.contains({ + origins: ["http://127.0.0.1/*"] + }); + } try { - await this.ping({}); + await this.ping({}, {active, permissionPromptShown}, tab); return true; } catch (e) { if (e.status != 0) { @@ -46,6 +55,22 @@ Zotero.Connector = new function() { Zotero.logError(e); return true; } + if (Zotero.isSafari) { + const hasLocalhostPermission = await browser.permissions.contains({ + origins: ["http://127.0.0.1/*"] + }); + if (!hasLocalhostPermission) { + // Zotero's status is unknown when the request was blocked or skipped without + // localhost access. + return null; + } + if (active && !hadLocalhostPermission) { + // The user granted access in Safari's permission dialog after the request had + // already been blocked, so the failure says nothing about Zotero's status -- + // ping again + return this.checkIsOnline({active, permissionPromptShown: true}, tab); + } + } return false; } }; @@ -106,8 +131,8 @@ Zotero.Connector = new function() { } } - this.ping = async function(payload={}) { - let response = await Zotero.Connector.callMethod("ping", payload); + this.ping = async function(payload={}, options={}, tab=null) { + let response = await Zotero.Connector.callMethod({method: "ping", ...options}, payload, tab); if (response && 'prefs' in response) { this._processPreferences(response.prefs); this._processTranslatorHash(response.prefs); @@ -115,8 +140,8 @@ Zotero.Connector = new function() { return response || {}; } - this.getClientVersion = async function() { - let isOnline = await this.checkIsOnline(); + this.getClientVersion = async function(options={}, tab=null) { + let isOnline = await this.checkIsOnline({...options, active: options.active ?? !!tab}, tab); return isOnline && this.clientVersion; } @@ -137,6 +162,32 @@ Zotero.Connector = new function() { options = {method: options}; } var method = options.method; + let localhostPermissionMissing = false; + if (Zotero.isSafari) { + const hasLocalhostPermission = await browser.permissions.contains({ + origins: ["http://127.0.0.1/*"] + }); + if (!hasLocalhostPermission) { + localhostPermissionMissing = true; + const isActive = options.active || !PASSIVE_METHODS.has(method); + if (!isActive) { + throw new Zotero.Connector.CommunicationError( + `Connector: Skipping passive ${method} request without localhost permission` + ); + } + // Skip the explanation once a blocked request has shown that Safari won't + // display its permission dialog -- the error handling for the failed request + // points to Safari Settings instead + if (!options.permissionPromptShown && !Zotero.HostPermissions.localhostRequestBlocked) { + // This request can trigger Safari's own permission dialog for localhost, where + // the user can also grant all-websites access + await Zotero.HostPermissions.prompt( + {domains: ['127.0.0.1'], recommendAllHosts: true, nativePromptToFollow: true}, + tab + ); + } + } + } var headers = Object.assign({ "Content-Type":"application/json", "X-Zotero-Version":Zotero.version, @@ -205,6 +256,10 @@ Zotero.Connector = new function() { return val; } } catch (e) { + if (localhostPermissionMissing && e.status == 0 + && !await browser.permissions.contains({origins: ["http://127.0.0.1/*"]})) { + Zotero.HostPermissions.localhostRequestBlocked = true; + } if (!(e instanceof Zotero.Connector.CommunicationError) && !(e instanceof Zotero.HTTP.StatusError)){ // Unexpected error, including a timeout Zotero.logError(e); diff --git a/src/common/images/zotero-z-16px.png b/src/common/images/zotero-z-16px.png new file mode 100644 index 000000000..6a0a946c9 Binary files /dev/null and b/src/common/images/zotero-z-16px.png differ diff --git a/src/common/images/zotero-z-32px.png b/src/common/images/zotero-z-32px.png new file mode 100644 index 000000000..6ef3ddfac Binary files /dev/null and b/src/common/images/zotero-z-32px.png differ diff --git a/src/common/inject/inject.jsx b/src/common/inject/inject.jsx index 934229b2c..b91cae29e 100644 --- a/src/common/inject/inject.jsx +++ b/src/common/inject/inject.jsx @@ -74,6 +74,13 @@ Zotero.Inject = { await Zotero.initInject(); // Zotero namespace APIs now initialized + + // Safari initially grants access only to sites approved by the user. The first click on the + // extension button reloads the page with content scripts enabled, without firing onClicked, + // so show the one-time site-access explanation from the injected script. + if (Zotero.isSafari && isTopWindow) { + await Zotero.HostPermissions.onPageLoad(); + } document.addEventListener("ZoteroItemUpdated", function() { Zotero.debug("Inject: ZoteroItemUpdated event received"); @@ -290,15 +297,29 @@ Zotero.Inject = { }); }, - async firstSaveToServerPrompt() { + /** + * @param {Boolean} localhostDenied - Zotero is unreachable because Safari denies the + * Connector access to 127.0.0.1, rather than because Zotero isn't running + */ + async firstSaveToServerPrompt(localhostDenied=false) { var clientName = ZOTERO_CONFIG.CLIENT_NAME; - var result = await this.confirm({ - button1Text: Zotero.getString('general_tryAgain'), - button2Text: Zotero.getString('general_cancel'), - button3Text: Zotero.getString('error_connection_enableSavingToOnlineLibrary'), - title: Zotero.getString('error_connection_isAppRunning', clientName), - message: Zotero.getString( + let title, message; + if (localhostDenied) { + title = Zotero.getString('permissions_siteAccess_title'); + message = Zotero.getString('permissions_siteAccess_message_localhost_required') + + Zotero.getString( + 'permissions_siteAccess_message_domain_safari', + [Zotero.getString('appConnector', clientName), '127.0.0.1'] + ) + + Zotero.getString( + 'permissions_siteAccess_message_saveToServer', + [Zotero.getString('appConnector', clientName), ZOTERO_CONFIG.DOMAIN_NAME] + ); + } + else { + title = Zotero.getString('error_connection_isAppRunning', clientName); + message = Zotero.getString( 'error_connection_save', [ Zotero.getString('appConnector', clientName), @@ -307,7 +328,14 @@ Zotero.Inject = { ] ) + '

' - + Zotero.Inject.getConnectionErrorTroubleshootingString() + + Zotero.Inject.getConnectionErrorTroubleshootingString(); + } + var result = await this.confirm({ + button1Text: Zotero.getString('general_tryAgain'), + button2Text: Zotero.getString('general_cancel'), + button3Text: Zotero.getString('error_connection_enableSavingToOnlineLibrary'), + title, + message }); switch (result.button) { @@ -337,26 +365,39 @@ Zotero.Inject = { * If Zotero is offline and attempting action fallback to zotero.org for first time: prompts about it * Prompt only available on BrowserExt which supports programmatic injection * Otherwise just resolves to true - * + * + * @param {Boolean} permissionPromptShown - Skip the localhost permission explanation before + * the status check, e.g., on a retry after it has already been displayed * return {Promise} whether the action should proceed */ - async checkActionToServer() { + async checkActionToServer(permissionPromptShown=false) { var [firstSaveToServer, zoteroIsOnline] = await Zotero.Promise.all([ - Zotero.Prefs.getAsync('firstSaveToServer'), - Zotero.Connector.checkIsOnline() + Zotero.Prefs.getAsync('firstSaveToServer'), + Zotero.Connector.checkIsOnline({active: true, permissionPromptShown}) ]); - if (zoteroIsOnline || !firstSaveToServer) { + if (zoteroIsOnline) { return true; } - var result = await this.firstSaveToServerPrompt(); + // null means Safari blocked the localhost request, leaving Zotero's status unknown + let localhostDenied = zoteroIsOnline === null; + if (!localhostDenied && Zotero.isSafari) { + await Zotero.HostPermissions.prompt({ + domains: ['repo.zotero.org', 'api.zotero.org'] + }); + } + if (!firstSaveToServer) { + return true; + } + var result = await this.firstSaveToServerPrompt(localhostDenied); if (result == 'server') { Zotero.Prefs.set('firstSaveToServer', false); return true; - } else if (result == 'retry') { + } + else if (result == 'retry') { // If we perform the retry immediately and Zotero is still unavailable the prompt returns instantly // making the user interaction confusing so we wait a bit first await Zotero.Promise.delay(500); - return this.checkActionToServer(); + return this.checkActionToServer(true); } return false; }, diff --git a/src/common/integration/connectorIntegration.js b/src/common/integration/connectorIntegration.js index 2efe1ae45..d05a5d834 100644 --- a/src/common/integration/connectorIntegration.js +++ b/src/common/integration/connectorIntegration.js @@ -83,16 +83,31 @@ Zotero.ConnectorIntegration = { } else if (e.status == 0) { var connectorName = Zotero.getString('appConnector', ZOTERO_CONFIG.CLIENT_NAME); - Zotero.Inject.confirm({ - title: Zotero.getString('error_connection_isAppRunning', ZOTERO_CONFIG.CLIENT_NAME), - message: Zotero.getString( - 'integration_error_connection', - [connectorName, ZOTERO_CONFIG.CLIENT_NAME] - ) - + '

' - + Zotero.Inject.getConnectionErrorTroubleshootingString(), - button2Text: "", - }); + if (Zotero.isSafari && !await Zotero.HostPermissions.hasPermission('127.0.0.1')) { + // Safari denies the Connector access to Zotero, so whether Zotero is running + // is unknown + Zotero.Inject.confirm({ + title: Zotero.getString('permissions_siteAccess_title'), + message: Zotero.getString('permissions_siteAccess_message_localhost_required') + + Zotero.getString( + 'permissions_siteAccess_message_domain_safari', + [connectorName, '127.0.0.1'] + ), + button2Text: "", + }); + } + else { + Zotero.Inject.confirm({ + title: Zotero.getString('error_connection_isAppRunning', ZOTERO_CONFIG.CLIENT_NAME), + message: Zotero.getString( + 'integration_error_connection', + [connectorName, ZOTERO_CONFIG.CLIENT_NAME] + ) + + '

' + + Zotero.Inject.getConnectionErrorTroubleshootingString(), + button2Text: "", + }); + } } Zotero.logError(e); return; diff --git a/src/common/messages.js b/src/common/messages.js index dc7cbeff2..c6a52cd6b 100644 --- a/src/common/messages.js +++ b/src/common/messages.js @@ -137,7 +137,9 @@ var MESSAGES = { setStore: false }, Connector: { - checkIsOnline: true, + checkIsOnline: { + background: {minArgs: 1} + }, callMethod: true, saveSingleFile: { inject: { @@ -157,10 +159,17 @@ var MESSAGES = { } } }, - getClientVersion: true, + getClientVersion: { + background: {minArgs: 1} + }, reportActiveURL: false, getPref: true }, + HostPermissions: { + onPageLoad: true, + prompt: true, + hasPermission: true, + }, Connector_Browser: { onSelect: true, onPageLoad: false, diff --git a/src/common/messaging.js b/src/common/messaging.js index bf514a23b..fd50f21cc 100644 --- a/src/common/messaging.js +++ b/src/common/messaging.js @@ -135,6 +135,8 @@ Zotero.Messaging = new function() { if (!Zotero.isSafari) { let response; try { + // Chromium delivers messages to extension-origin frames only when broadcasting, and + // fails if a specific frame is targeted. response = await browser.tabs.sendMessage(tab.id, [messageName, args]); } catch (e) {} @@ -149,12 +151,13 @@ Zotero.Messaging = new function() { let response; try { - // We could target specific frames here - // but Chromium browsers do not send messages to frames - // that load an extension page when specified, only when broadcast. Sigh. + // The extension UI frames are owned by the top-page content script. Target it explicitly so + // an injected child frame without a registered UI frame cannot return first and make the + // caller think the modal has already closed. response = await browser.tabs.sendMessage( tab.id, ['zoteroFrame.sendMessage', [messageName, args]], + {frameId: 0} ); } catch (e) {} diff --git a/src/common/preferences/preferences.jsx b/src/common/preferences/preferences.jsx index eceedbb52..e42a04ea9 100644 --- a/src/common/preferences/preferences.jsx +++ b/src/common/preferences/preferences.jsx @@ -41,7 +41,11 @@ var Zotero_Preferences = { visiblePaneName: null, init: async function() { Zotero.isPreferences = true; + // Resolved once the pane's host-permission prompt has been dismissed (immediately on + // browsers where no prompt is displayed) + Zotero_Preferences.permissionsPromptDeferred = Zotero.Promise.defer(); Zotero.Messaging.init(); + Zotero.Messaging.addMessageListener('confirm', props => Zotero.ModalPrompt.confirm(props)); await Zotero.i18n.init(); await Zotero.Prefs.loadNamespace(['interceptKnownFileTypes', 'allowedInterceptHosts']); @@ -87,6 +91,22 @@ var Zotero_Preferences = { Zotero_Preferences.refreshData(); window.setInterval(() => Zotero_Preferences.refreshData(), 1000); + + if (Zotero.isSafari) { + // Let the initialized preferences pane render before displaying a modal over it. + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + try { + // A single combined prompt covers missing localhost access and the all-hosts + // recommendation. The client status check suppresses its own localhost prompt. + await Zotero.HostPermissions.prompt({domains: ['127.0.0.1'], recommendAllHosts: true}); + } + finally { + Zotero_Preferences.permissionsPromptDeferred.resolve(); + } + } + else { + Zotero_Preferences.permissionsPromptDeferred.resolve(); + } }, /** @@ -349,16 +369,22 @@ Zotero_Preferences.Components = {}; Zotero_Preferences.Components.ClientStatus = class ClientStatus extends React.Component { constructor(props) { super(props); - this.checkStatus(); this.state = { available: false }; this.checkStatus = this.checkStatus.bind(this); + // Run the initial status check only after the pane's host-permission prompt has been + // dismissed, so the request cannot trigger Safari's native permission dialog while the + // explanation is still displayed + Zotero_Preferences.permissionsPromptDeferred.promise.then(this.checkStatus); } checkStatus() { - return Zotero.Connector.checkIsOnline().then(function(status) { + // The preferences pane explains missing localhost access in its combined permission + // prompt, so don't show another one here. The request itself still triggers Safari's + // native permission dialog where possible. + return Zotero.Connector.checkIsOnline({active: true, permissionPromptShown: true}).then(function(status) { this.setState({available: status}); }.bind(this)); } diff --git a/src/common/repo.js b/src/common/repo.js index 60068b00b..a1cfe7e9c 100644 --- a/src/common/repo.js +++ b/src/common/repo.js @@ -34,6 +34,16 @@ */ Zotero.Repo = new function() { this.infoRe = /^\s*{[\S\s]*?}\s*?[\r\n]/; + + async function checkRepositoryAccess() { + if (!Zotero.isSafari) return; + const hasPermission = await browser.permissions.contains({ + origins: ["https://repo.zotero.org/*"] + }); + if (!hasPermission) { + throw new Error('Repo: Skipping request without repo.zotero.org permission'); + } + } /** * Get translator code from repository @@ -57,6 +67,7 @@ Zotero.Repo = new function() { // then try repo const url = `${ZOTERO_CONFIG.REPOSITORY_URL}code/${translatorID}?version=${Zotero.version}`; try { + await checkRepositoryAccess(); let xhr = await Zotero.HTTP.request("GET", url); code = xhr.responseText; } @@ -109,7 +120,8 @@ Zotero.Repo = new function() { this.getTranslatorMetadataFromServer = async function(reset=false) { var url = ZOTERO_CONFIG.REPOSITORY_URL + "metadata?version=" + Zotero.version + "&last="+ (reset ? "0" : Zotero.Prefs.get("connector.repo.lastCheck.repoTime")); - + + await checkRepositoryAccess(); xhr = await Zotero.HTTP.request('GET', url); var date = xhr.getResponseHeader("Date"); Zotero.Prefs.set("connector.repo.lastCheck.localTime", Date.now()); diff --git a/src/common/zotero.js b/src/common/zotero.js index 9afa8b321..e9cca6bd3 100644 --- a/src/common/zotero.js +++ b/src/common/zotero.js @@ -193,6 +193,11 @@ var Zotero = global.Zotero = new function() { Zotero.Messaging.init(); Zotero.Connector_Types.init(); await Zotero.Prefs.init(); + // The Safari extension is bundled with the Zotero app, so the generic first-use prompt + // telling users to install Zotero is unnecessary. + if (Zotero.isSafari) { + Zotero.Prefs.set('firstUse', false); + } Zotero.Debug.init(); let storingDebugOnRestart = Zotero.Prefs.get('debug.store'); diff --git a/src/messages.json b/src/messages.json index 3472b7fee..8825973a8 100644 --- a/src/messages.json +++ b/src/messages.json @@ -320,8 +320,43 @@ "permissions_siteAccess_message": { "message": "

To enable this, change the \"Site Access\" setting to \"On all sites\" in the Extension Preferences page.

" }, + "permissions_siteAccess_message_safari_functionality": { + "message": "

For full Zotero Connector functionality, including updating of the save button for each webpage, more reliable saving, and proxy redirection, allow Zotero Connector to run on all websites.

" + }, "permissions_siteAccess_message_safari": { "message": "

To enable this, open Safari Settings, select Websites at the top, select $1 in the sidebar, and change the For other websites option to Allow.

", "description": "$1 is the connector name (e.g., Zotero Connector)" + }, + "permissions_siteAccess_message_localhost_required": { + "message": "

To communicate with Zotero, Zotero Connector requires access to 127.0.0.1, an address that refers to your own computer.

" + }, + "permissions_siteAccess_message_repo_required": { + "message": "

Access to repo.zotero.org is required to download translators when Zotero is unavailable.

" + }, + "permissions_siteAccess_message_api_required": { + "message": "

Access to api.zotero.org is required to save to your online Zotero library when Zotero is unavailable.

" + }, + "permissions_siteAccess_message_domain_safari": { + "message": "

To enable this, open Safari Settings, select Websites at the top, select $1 in the sidebar, and set $2 to Allow.

", + "description": "$1 is the connector name (e.g., Zotero Connector); $2 is the required domain with markup (e.g., \"127.0.0.1\")" + }, + "permissions_siteAccess_message_domains_safari": { + "message": "

To enable these, open Safari Settings, select Websites at the top, select $1 in the sidebar, and set $2 to Allow.

", + "description": "$1 is the connector name (e.g., Zotero Connector); $2 is a list of required domains with markup (e.g., \"repo.zotero.org and api.zotero.org\")" + }, + "permissions_siteAccess_message_domains_allHosts_safari": { + "message": "

To enable these, open Safari Settings, select Websites at the top, select $1 in the sidebar, set $2 to Allow, and change the For other websites option to Allow.

", + "description": "$1 is the connector name (e.g., Zotero Connector); $2 is a list of required domains with markup (e.g., \"repo.zotero.org and api.zotero.org\")" + }, + "permissions_siteAccess_message_nativePrompt_safari": { + "message": "

Safari may now ask whether to allow this access. Choose Always Allow to continue.

" + }, + "permissions_siteAccess_message_allHosts_nativePrompt_safari": { + "message": "

For full Zotero Connector functionality, including updating of the save button for each webpage, more reliable saving, and proxy redirection, allow Zotero Connector to run on all websites by checking Remember for other websites in the same dialog.

", + "description": "All-websites recommendation shown when Safari's own permission dialog is expected to follow" + }, + "permissions_siteAccess_message_saveToServer": { + "message": "

Alternatively, $1 can save some pages directly to your $2 account.

", + "description": "$1 will contain the localized string 'Zotero Connector'. $2 will contain the domain name." } } diff --git a/src/safari/reinjectGuard.js b/src/safari/reinjectGuard.js new file mode 100644 index 000000000..31763c84e --- /dev/null +++ b/src/safari/reinjectGuard.js @@ -0,0 +1,65 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +(function() { +// Safari injects the extension's content scripts into already-loaded matching pages when the +// user grants new host permissions. The scripts run in the same content world as the first +// copy, which recreates the Zotero namespace and throws on top-level let and const +// redeclarations, breaking the page. Reload it for a clean single injection, which is also +// how Safari itself enables the extension on a page via the toolbar button. +// +// Safari can also inject the scripts twice while a page is loading, which a reload cannot +// fix, so reload only when the background page confirms that the user just granted a host +// permission, and never reload more than once a minute in a tab. +var RELOAD_INTERVAL = 60e3; + +if (!document.documentElement.hasAttribute('data-zotero-connector-injected')) { + document.documentElement.setAttribute('data-zotero-connector-injected', 'true'); + return; +} +console.warn('Zotero Connector: Duplicate content script injection detected'); + +async function permissionsRecentlyGranted() { + if (await browser.runtime.sendMessage(['reinjectGuard.shouldReload', null])) { + return true; + } + // The background page may not have registered the grant yet + await new Promise(resolve => setTimeout(resolve, 3000)); + return browser.runtime.sendMessage(['reinjectGuard.shouldReload', null]); +} + +permissionsRecentlyGranted().then(function(granted) { + if (!granted) return; + try { + var lastReload = +sessionStorage.getItem('zotero-connector-reinject-reload') || 0; + if (Date.now() - lastReload < RELOAD_INTERVAL) { + return; + } + sessionStorage.setItem('zotero-connector-reinject-reload', Date.now()); + } + catch (e) {} + location.reload(); +}, function() {}); +})(); diff --git a/src/zotero-google-docs-integration b/src/zotero-google-docs-integration index 2f9ad7ff9..8b932bf11 160000 --- a/src/zotero-google-docs-integration +++ b/src/zotero-google-docs-integration @@ -1 +1 @@ -Subproject commit 2f9ad7ff9e971e8021479f8b30da35eea51e1eae +Subproject commit 8b932bf1125015787c5e0c5bcd0aa7c91058bdf4 diff --git a/test/tests/connectorTest.mjs b/test/tests/connectorTest.mjs index d05e15372..eade39bc5 100644 --- a/test/tests/connectorTest.mjs +++ b/test/tests/connectorTest.mjs @@ -67,4 +67,170 @@ describe('Connector', function() { assert.isTrue(result); }); }); + + describe('Safari localhost permissions', function() { + it('skips passive Connector requests when localhost access is missing', async function() { + let result = await background(async function() { + let isSafari = Zotero.isSafari; + Zotero.isSafari = true; + sinon.stub(browser.permissions, 'contains').resolves(false); + sinon.stub(Zotero.HTTP, 'request'); + sinon.stub(Zotero.HostPermissions, 'prompt'); + try { + let online = await Zotero.Connector.checkIsOnline(); + return { + online, + requested: Zotero.HTTP.request.called, + prompted: Zotero.HostPermissions.prompt.called + }; + } + finally { + browser.permissions.contains.restore(); + Zotero.HTTP.request.restore(); + Zotero.HostPermissions.prompt.restore(); + Zotero.isSafari = isSafari; + } + }); + + assert.isNull(result.online); + assert.isFalse(result.requested); + assert.isFalse(result.prompted); + }); + + it('warns before an active Connector request when localhost access is missing', async function() { + let result = await background(async function() { + let isSafari = Zotero.isSafari; + Zotero.isSafari = true; + sinon.stub(browser.permissions, 'contains').resolves(false); + sinon.stub(Zotero.HostPermissions, 'prompt').resolves(); + sinon.stub(Zotero.HTTP, 'request').resolves({ + status: 200, + getResponseHeader: () => 'application/json', + responseText: '{}' + }); + try { + await Zotero.Connector.callMethod('saveSnapshot', {}); + return { + requested: Zotero.HTTP.request.called, + prompted: Zotero.HostPermissions.prompt.calledWithMatch({domains: ['127.0.0.1']}) + }; + } + finally { + browser.permissions.contains.restore(); + Zotero.HostPermissions.prompt.restore(); + Zotero.HTTP.request.restore(); + Zotero.isSafari = isSafari; + } + }); + + assert.isTrue(result.prompted); + assert.isTrue(result.requested); + }); + + it('warns only once when Safari blocks requests with localhost access missing', async function() { + let result = await background(async function() { + let isSafari = Zotero.isSafari; + Zotero.isSafari = true; + sinon.stub(browser.permissions, 'contains').resolves(false); + sinon.stub(Zotero.HostPermissions, 'prompt').resolves(); + sinon.stub(Zotero.HTTP, 'request').resolves({ + status: 0, + getResponseHeader: () => null, + responseText: '', + response: '' + }); + try { + for (let i = 0; i < 2; i++) { + try { + await Zotero.Connector.callMethod('saveSnapshot', {}); + } + catch (e) {} + } + return { + promptCount: Zotero.HostPermissions.prompt.callCount, + requestCount: Zotero.HTTP.request.callCount + }; + } + finally { + browser.permissions.contains.restore(); + Zotero.HostPermissions.prompt.restore(); + Zotero.HTTP.request.restore(); + Zotero.HostPermissions.localhostRequestBlocked = false; + Zotero.isSafari = isSafari; + } + }); + + assert.equal(result.promptCount, 1); + assert.equal(result.requestCount, 2); + }); + + it("pings again when localhost access is granted in Safari's permission dialog", async function() { + let result = await background(async function() { + let isSafari = Zotero.isSafari; + Zotero.isSafari = true; + let contains = sinon.stub(browser.permissions, 'contains'); + // Missing for the pre-ping check and the request gate, then granted in Safari's + // permission dialog triggered by the blocked request + contains.resolves(true); + contains.onCall(0).resolves(false); + contains.onCall(1).resolves(false); + sinon.stub(Zotero.HostPermissions, 'prompt').resolves(); + let request = sinon.stub(Zotero.HTTP, 'request'); + request.onCall(0).resolves({ + status: 0, + getResponseHeader: () => null, + responseText: '', + response: '' + }); + request.onCall(1).resolves({ + status: 200, + getResponseHeader: () => 'application/json', + responseText: '{}' + }); + try { + let online = await Zotero.Connector.checkIsOnline({active: true}); + return { + online, + requestCount: Zotero.HTTP.request.callCount, + promptCount: Zotero.HostPermissions.prompt.callCount + }; + } + finally { + browser.permissions.contains.restore(); + Zotero.HostPermissions.prompt.restore(); + Zotero.HTTP.request.restore(); + Zotero.isSafari = isSafari; + } + }); + + assert.isTrue(result.online); + assert.equal(result.requestCount, 2); + assert.equal(result.promptCount, 1); + }); + }); + + describe('Safari repository permissions', function() { + it('does not request translator metadata without repo.zotero.org permission', async function() { + let requested = await background(async function() { + let isSafari = Zotero.isSafari; + Zotero.isSafari = true; + sinon.stub(browser.permissions, 'contains').resolves(false); + sinon.stub(Zotero.HTTP, 'request'); + try { + try { + await Zotero.Repo.getTranslatorMetadataFromServer(); + } + catch (e) {} + return Zotero.HTTP.request.called; + } + finally { + browser.permissions.contains.restore(); + Zotero.HTTP.request.restore(); + Zotero.isSafari = isSafari; + } + }); + + assert.isFalse(requested); + }); + }); }); \ No newline at end of file