From 2b965b08d18be3e9a65a4b2cb6b2a9bfb033de46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?100=E3=81=AE=E4=BA=BA?= <100@pokemori.jp> Date: Fri, 19 Jan 2018 19:48:59 +0900 Subject: [PATCH] Add feature syncing user scripts with local files via WebDAV server to be able to use external editor. Resolves https://github.com/greasemonkey/greasemonkey/issues/2513 , resolves https://github.com/greasemonkey/greasemonkey/issues/2932 , resolves https://github.com/greasemonkey/greasemonkey/issues/3048 . --- _locales/en/messages.json | 24 ++ doc/Messages.md | 23 ++ manifest.json | 2 + package.json | 5 +- src/bg/sync-via-webdav.js | 490 ++++++++++++++++++++++++++++++++ src/bg/sync-via-webdav.run.js | 1 + src/bg/user-script-registry.js | 12 +- src/browser/monkey-menu.css | 8 +- src/browser/monkey-menu.html | 25 ++ src/browser/monkey-menu.js | 65 ++++- src/browser/monkey-menu.run.js | 1 + src/util/rivets-formatters.js | 2 + test/bg/sync-via-webdav.test.js | 336 ++++++++++++++++++++++ test/test.js | 14 + 14 files changed, 999 insertions(+), 9 deletions(-) create mode 100644 src/bg/sync-via-webdav.js create mode 100644 src/bg/sync-via-webdav.run.js create mode 100644 test/bg/sync-via-webdav.test.js create mode 100644 test/test.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index da7ef1c15..5f12c2986 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -87,6 +87,9 @@ "fix_and_save": { "message": "The editor contents have not been saved. Fix this error and try again." }, + "fix_and_save_sync_via_webdav": { + "message": "The modified user JS file have not been synced. Fix this error and try again." + }, "get_user_scripts": { "message": "Get user scripts" }, @@ -285,5 +288,26 @@ }, "use_enhanced_editor_explain": { "message": "Screen reader users should disable this, as the enhanced code editor is not accessible." + }, + "sync_via_webdav": { + "message": "Sync Local Files via WebDAV" + }, + "sync_via_webdav_is_enabled": { + "message": "Sync Enabled" + }, + "sync_via_webdav_is_disabled": { + "message": "Sync Disabled" + }, + "sync_via_webdav_url": { + "message": "WebDAV Directory URL" + }, + "sync_via_webdav_url_pattern_description": { + "message": "Enter a URL that begins with \"http://localhost\", contains a non-empty path, and ends with \"/\"." + }, + "sync_via_webdav_description": { + "message": "The WebDAV server must allow PROPFIND GET MKCOL PUT DELETE methods and depth: infinity." + }, + "sync_via_webdav_error_notification_title": { + "message": "Greasemonkey Sync via WebDAV Error" } } diff --git a/doc/Messages.md b/doc/Messages.md index 5664583c1..0cd6fdc97 100644 --- a/doc/Messages.md +++ b/doc/Messages.md @@ -168,6 +168,29 @@ Request data: * `excludes` A string, one `@exclude` pattern per line. +# SyncViaWebdavChangeOption +Sent by: `browser/monkey-menu.js` +Received by: `bg/sync-via-webdav.js` + +Triggered when "Sync via WebDAV" options on the popup menu is changed by the user. +Enables, Disabled, or Reenables the sync. + +Data: + +Either of the following + +* `enabled` boolean, the new status (true = enabled, false = disabled). +* `url` string, WebDAV directory URL. + +If neither is specified, just gets "Sync via WebDAV" options. + +Response data: + +Ppresented upon async completion. + +* `enabled` boolean, the new status (true = enabled, false = disabled). +* `url` string, WebDAV directory URL. + # UserScriptGet Sent by: `content/edit-user-script.js` diff --git a/manifest.json b/manifest.json index e823ceb08..d63b64514 100644 --- a/manifest.json +++ b/manifest.json @@ -38,6 +38,7 @@ "/src/bg/on-user-script-open-in-tab.js", "/src/bg/on-user-script-xhr.js", "/src/bg/options.js", + "/src/bg/sync-via-webdav.js", "/src/bg/user-script-detect.js", "/src/bg/user-script-registry.js", "/src/bg/updater.js", @@ -59,6 +60,7 @@ "/third-party/webdav/webdav.js", "/src/bg/execute.run.js", + "/src/bg/sync-via-webdav.run.js", "/src/bg/user-script-detect.run.js", "/src/bg/user-script-registry.run.js" ] diff --git a/package.json b/package.json index d081bcbb5..bfabc8a04 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "Greasemonkey", "version": "0.0.1", "scripts": { - "test": "karma start karma.conf.js" + "test": "node test/test.js" }, "devDependencies": { "chai": "^4.1.2", @@ -16,6 +16,7 @@ "mocha": "^3.1.2", "sinon": "^4.1.5", "sinon-chrome": "^2.2.1", - "tinybind": "^0.11.0" + "tinybind": "^0.11.0", + "webdav-server": "^2.6.2" } } diff --git a/src/bg/sync-via-webdav.js b/src/bg/sync-via-webdav.js new file mode 100644 index 000000000..ab121a873 --- /dev/null +++ b/src/bg/sync-via-webdav.js @@ -0,0 +1,490 @@ +/* +Syncs a local directory with user script contents via WebDAV server. +(Doesn't sync requiresContents and script values.) + +The `SyncViaWebdav` object exports methods for syncing to a local directory. +*/ + +// Private implementation. +(function() { + +// A milliseconds. +const DIRECTORY_MONITOR_INTERVAL = 1000; + +/** + * @typedef {object} WebDAV + * @property {import('../../third-party/webdav/index').createClient} createClient + */ + +/** @type {import('../../third-party/webdav/index').WebDAVClient} */ +let webdavClient = null; +let timerId = 0; + +/** @type {Object.} */ +const userScriptUuidErrorNotificationIdPairs = {}; + +/////////////////////////////////////////////////////////////////////////////// + +// Utilities. + +const _queue = []; +let _next, _lock, _releaseLock, _rejectLock; + +async function _getLock() { + const symbol = Symbol(); + if (_next) { + _queue.push(symbol); + } else { + _next = symbol; + } + + do { + await _lock; + } while (_next !== symbol); + + _lock = new Promise((resolve, reject) => { + _releaseLock = resolve; + _rejectLock = exception => { + _next = null; + _lock = null; + _queue.splice(0, _queue.length); + reject(exception); + }; + }); + + _next = _queue.shift(); +} + +const MAX_FILENAME_LENGTH = 255; + +function _prepareToConvertValidFileName(str) { + return str.replace(/[\x00-\x1F]|^\s+/ug, '').replace(/["#*/:<>?\\|]+|^\./ugi, _convertAsciiCodePointsToFull).replace( + /^(?:AUX|CLOCK\$|COM[1-9]|CON|LPT[1-9]|NUL|PRN)\s*\./ui, + match => match.replace('.', _convertAsciiCodePointsToFull)); +} + +const _HALF_TO_FULL_DIFF = '?'.codePointAt() - '?'.codePointAt(); + +function _convertAsciiCodePointsToFull(codePoints) { + return Array.from(codePoints) + .map(codePoint => String.fromCodePoint(codePoint.codePointAt() + _HALF_TO_FULL_DIFF)).join(''); +} + +/////////////////////////////////////////////////////////////////////////////// + +async function isEnabled() { + const syncViaWebdav = await getSettings(); + return syncViaWebdav.enabled && syncViaWebdav.url; +} + +// Returns a string such as "User Script Name (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)". +function generateUserScriptDirectoryName(userScript) { + const name = _prepareToConvertValidFileName(userScript.name); + const uuid = ` (${userScript.uuid})`; + return Array.from(name).slice(0, MAX_FILENAME_LENGTH - uuid.length).join('') + uuid; +} + +// Returns a string such as "User Script Name.user.js". +function generateUserJsFileName(userScript) { + const name = _prepareToConvertValidFileName(userScript.name); + const extension = '.user.js'; + return Array.from(name).slice(0, MAX_FILENAME_LENGTH - extension.length).join('') + extension; +} + +function getSettings() { + return new Promise((resolve, reject) => { + chrome.storage.local.get('syncViaWebdav', v => { + let syncViaWebdav = v['syncViaWebdav']; + if ('undefined' == typeof syncViaWebdav) syncViaWebdav = { + 'enabled': false, + 'url': '', + } + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(syncViaWebdav); + } + }); + }); +} + +async function setSettings(syncViaWebdav) { + syncViaWebdav = Object.assign(await getSettings(), syncViaWebdav); + await new Promise((resolve, reject) => { + chrome.storage.local.set({syncViaWebdav}, () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(); + } + }); + }); +} + +/** @returns {Promise.} */ +async function createClient() { + return WebDAV.createClient((await getSettings()).url); +} + +/** @returns {Promise.} */ +function fetchAllItemStats() { + return webdavClient.getDirectoryContents('/', {'deep': true, 'glob': '/*(*){,/*.user.js}'}); +} + +/** @returns {Promise.} */ +async function fetchRootChildDirectoryStats() { + return (await webdavClient.getDirectoryContents('/', {'glob': '/*(*)'})).filter(stat => stat.type === 'directory'); +} + +/** + * @param {object} userScript + * @param {import('../../third-party/webdav/index').FileStat[]} directoryStats + * @returns {Promise.} + */ +async function fetchUserScriptDirectoryStat(userScript, directoryStats) { + return directoryStats.find(stat => stat.type === 'directory' && stat.basename.endsWith(`(${userScript.uuid})`)); +} + +/** + * @param {object} userScript + * @param {import('../../third-party/webdav/index').FileStat[]} itemStats + * @returns {Promise.} + */ +async function fetchUserJsFileStat(userScript, itemStats) { + return (itemStats || await fetchAllItemStats()) + .find(stat => stat.type === 'file' && stat.filename.includes(`(${userScript.uuid})/`)); +} + +async function removeUserScriptDirectory(userScript) { + await webdavClient.deleteFile( + (await fetchUserScriptDirectoryStat(userScript, await fetchRootChildDirectoryStats())).filename); +} + +/** + * @param {?import('../../third-party/webdav/index').FileStat} userJsFileStat + * @param {object} userScript + * @param {import('../../third-party/webdav/index').FileStat[]} directoryStats + * - Required only when `userJsFileStat` does not specify. + * @returns {Promise.} `FileStat.filename`. + */ +async function syncToUserJsFileFromUserScript(userJsFileStat, userScript, directoryStats = null) { + let filename; + if (userJsFileStat) { + filename = userJsFileStat.filename; + } else { + let userScriptDirectoryFilename = (await fetchUserScriptDirectoryStat(userScript, directoryStats))?.filename; + if (!userScriptDirectoryFilename) { + userScriptDirectoryFilename = '/' + generateUserScriptDirectoryName(userScript); + await webdavClient.createDirectory(userScriptDirectoryFilename); + } + filename = userScriptDirectoryFilename + '/' + generateUserJsFileName(userScript); + } + await webdavClient.putFileContents(filename, userScript.content); + return filename; +} + +/** + * @param {import('../../third-party/webdav/index').FileStat} userJsFileStat + * @param {object} userScript + * @returns {Promise.} + */ +async function syncFromUserJsFileToUserScript(userJsFileStat, userScript) { + const downloader = new UserScriptDownloader(); + downloader.setScriptUrl(userScript.downloadUrl); + downloader.setScriptContent(await webdavClient.getFileContents(userJsFileStat.filename, {'format': 'text'})); + + downloader.setKnownRequires(userScript.requiresContent); + downloader.setKnownResources(userScript.resources); + downloader.setKnownUuid(userScript.uuid); + + try { + await downloader.start(); + const scriptDetails = await downloader.scriptDetails; + scriptDetails.editTime = new Date().getTime(); + scriptDetails.fileSystemEtag = userJsFileStat.etag.replace(/^W\//, ''); + scriptDetails.fileSystemLastModified = new Date(userJsFileStat.lastmod).getTime(); + await UserScriptRegistry.installFromDownloader( + scriptDetails, + await downloader.details(), + {'fromSyncViaWebdav': true}); + await clearErrorNotification(userScript.uuid); + } catch (e) { + // Do not show the same error again. + await setFileSystemEtagAndLastModified(userJsFileStat, userScript); + + let errorList; + if (e instanceof DownloadError) { + errorList = e.failedDownloads.map(d => _('ERROR_at_URL', d.error, d.url)); + } else if (e.message) { + errorList = [e.message]; + } else { + // Log the unknown error. + console.error('Unknown save error saving script when sync via WebDAV', e); + errorList = [_('download_error_unknown')]; + } + + await createErrorNotification( + userScript.name + '\n' + + errorList.map(error => '• ' + error).join('\n') + '\n' + + _('fix_and_save_sync_via_webdav'), + userScript.uuid); + } +} + +/** + * Refetches FileStat to get new modification date, + * and then updates the fileSystemEtag and fileSystemLastModified fields of EditableUserScript. + * @param {import('../../third-party/webdav/index').FileStat} userJsFileStat + * @param {object} userScript + * @returns {Promise.} + */ +async function setFileSystemEtagAndLastModified(userJsFileStat, userScript) { + userScript.setFileSystemEtag(userJsFileStat.etag.replace(/^W\//, '')); + userScript.setFileSystemLastModified(new Date(userJsFileStat.lastmod).getTime()); + await UserScriptRegistry.saveUserScript(userScript, {'fromSyncViaWebdav': true}); +} + +/** + * Overwrites one (FileStat or EditableUserScript) having the older modified date with the other one. + * @param {object} userScript + * @param {?import('../../third-party/webdav/index').FileStat[]} itemStats + * @returns {Promise.} Returns `FileStat.filename` if need set etag and last-modified to UserScriptRegistry. + */ +async function syncUserScriptWithoutPostprocessing(userScript, itemStats) { + if (userScript.fileSystemEtag) { + const userJsFileStat = await fetchUserJsFileStat(userScript, itemStats); + if (userJsFileStat) { + const userJsFileLastModified = new Date(userJsFileStat.lastmod).getTime(); + // Compare with ETag to detect file updates of less than a second. + if (userJsFileStat.etag.replace(/^W\//, '') !== userScript.fileSystemEtag) { + if (userJsFileLastModified < userScript.fileSystemLastModified) { + return await syncToUserJsFileFromUserScript(userJsFileStat, userScript); + } else if (userJsFileLastModified >= userScript.fileSystemLastModified) { + await syncFromUserJsFileToUserScript(userJsFileStat, userScript); + // If the update date is the same, + // if Greasemonkey side is newer, the Etag is assumed to be the same by userScriptEdited, + // and the file system side is treated as newer. + } + } + } else { + return await syncToUserJsFileFromUserScript(null, userScript, itemStats); + } + } else { + return await syncToUserJsFileFromUserScript(null, userScript, itemStats); + } +} + +async function syncUserScripts() { + const userScriptNeedSettingEtagAndLastModifiedFilenamePairs = new Map(); + + const itemStats = await fetchAllItemStats(); + await Promise.all(Array.from(UserScriptRegistry.scriptsToRunAt(null, true)).map(async userScript => { + const filename = await syncUserScriptWithoutPostprocessing(userScript, itemStats); + if (filename) { + userScriptNeedSettingEtagAndLastModifiedFilenamePairs.set(userScript, filename); + } + })); + + if (userScriptNeedSettingEtagAndLastModifiedFilenamePairs.size > 0) { + if (userScriptNeedSettingEtagAndLastModifiedFilenamePairs.size === 1) { + const [userScript, filename] = Array.from(userScriptNeedSettingEtagAndLastModifiedFilenamePairs)[0]; + await setFileSystemEtagAndLastModified( + await webdavClient.stat(filename), + userScript); + } else { + const itemStats = await fetchAllItemStats(); + for (const userScript of userScriptNeedSettingEtagAndLastModifiedFilenamePairs.keys()) { + await setFileSystemEtagAndLastModified(await fetchUserJsFileStat(userScript, itemStats), userScript); + } + } + } +} + +async function enable() { + webdavClient = await createClient(); + await monitorWebdavClient(); +} + +async function disable() { + clearTimeout(timerId); + await setSettings({'enabled': false}), + webdavClient = null; + timerId = 0; +} + +/////////////////////////////////////////////////////////////////////////////// + +// Calls specified function exclusively. +// If an exception is thrown, disables sync and notifies an user of the exception. +async function transact(func) { + await _getLock(); + try { + const returnValue = await func(); + _releaseLock(); + return returnValue; + } catch (e) { + try { + await disable(); + await createErrorNotification(builtErrorMessage(e)); + } catch (e) { + console.error(e); + } + _rejectLock(e); + + console.debug(e.toString(), JSON.stringify(e, null, 2)); + throw e; + } +} + +chrome.notifications.onClosed.addListener(function (notificationId) { + const userScriptUuid = Object.entries(userScriptUuidErrorNotificationIdPairs) + .find(([userScriptUuid, errorNotificationId]) => errorNotificationId === notificationId)[0]; + if (userScriptUuid) { + delete userScriptUuidErrorNotificationIdPairs[userScriptUuid]; + } +}); + +function createErrorNotification(message, userScriptUuid = null) { + return new Promise(resolve => { + chrome.notifications.create( + userScriptUuid && userScriptUuidErrorNotificationIdPairs[userScriptUuid], + { + 'type': 'basic', + 'iconUrl': '/skin/icon.svg', + 'title': _('sync_via_webdav_error_notification_title'), + message, + }, + notificationId => { + if (userScriptUuid) { + userScriptUuidErrorNotificationIdPairs[userScriptUuid] = notificationId; + } + resolve(); + }); + }); +} + +function clearErrorNotification(userScriptUuid) { + const notificationId = userScriptUuidErrorNotificationIdPairs[userScriptUuid]; + if (!notificationId) { + return; + } + delete userScriptUuidErrorNotificationIdPairs[userScriptUuid]; + return new Promise(resolve => { + chrome.notifications.clear(notificationId, () => resolve()); + }); +} + +function builtErrorMessage(e) { + let message = e.toString(); + + if ('response' in e) { + /** @type {?import('../../third-party/webdav/index').Response} */ + const response = e.response; + const data = response?.data; + if (data && typeof data === 'string') { + let body; + const type = response.headers['content-type']; + if (type.startsWith('text/html') || type.startsWith('application/xhtml+xml')) { + const doc = new DOMParser().parseFromString( + data, + type.startsWith('text/html') ? 'text/html' : 'application/xhtml+xml'); + body = [doc.title, doc.body?.innerText].filter(text => text).map(text => text.trim()).join('\n'); + } + message += '\n' + (body || data); + } + } + + return message; +} + +// This function is called every DIRECTORY_MONITOR_INTERVAL milliseconds. +async function monitorWebdavClient() { + await syncUserScripts(); + + timerId = setTimeout(() => { + transact(async () => { + if (await isEnabled()) { + await monitorWebdavClient(); + } + }); + }, DIRECTORY_MONITOR_INTERVAL); +} + +function onSyncViaWebdavChangeOption(message, sender, sendResponse) { + return transact(async () => { + const previousEnabled = await isEnabled(); + + if ('enabled' in message) { + if (message.enabled) { + await setSettings({'enabled': message.enabled}); + if (!previousEnabled && await isEnabled()) { + await enable(); + } + } else { + await disable(); + } + } else if ('url' in message) { + await setSettings({'url': message.url}); + const settings = await getSettings(); + if (settings.url) { + if (settings.enabled) { + if (previousEnabled) { + await disable(); + } + await enable(); + } + } else { + if (previousEnabled) { + await disable(); + } + } + } + + return getSettings(); + }); +} +window.onSyncViaWebdavChangeOption = onSyncViaWebdavChangeOption; + +function run() { + return transact(async () => { + if (await isEnabled()) { + await enable(); + } + }); +} + +function userScriptEdited(userScript) { + transact(async () => { + if (await isEnabled()) { + const itemStats = await fetchAllItemStats(); + const userJsFileStat = await fetchUserJsFileStat(userScript, itemStats); + const filename = await syncToUserJsFileFromUserScript( + userJsFileStat, + userScript, + itemStats); // Specify for if a file is deleted at the same time as editing. + await setFileSystemEtagAndLastModified( + await webdavClient.stat(filename), + userScript); + await clearErrorNotification(userScript.uuid); + } + }); +} + +function userScriptUninstalled(userScript) { + transact(async () => { + if (await isEnabled()) { + await removeUserScriptDirectory(userScript); + await clearErrorNotification(userScript.uuid); + } + }); +} + + +// Export public API. +window.SyncViaWebdav = { + '_run': run, + 'userScriptEdited': userScriptEdited, + 'userScriptUninstalled': userScriptUninstalled, +}; + +})(); diff --git a/src/bg/sync-via-webdav.run.js b/src/bg/sync-via-webdav.run.js new file mode 100644 index 000000000..8b27644e0 --- /dev/null +++ b/src/bg/sync-via-webdav.run.js @@ -0,0 +1 @@ +SyncViaWebdav._run(); diff --git a/src/bg/user-script-registry.js b/src/bg/user-script-registry.js index 42c1c8366..f1c6c5911 100644 --- a/src/bg/user-script-registry.js +++ b/src/bg/user-script-registry.js @@ -73,7 +73,7 @@ async function openDb() { /////////////////////////////////////////////////////////////////////////////// -async function installFromDownloader(userScriptDetails, downloaderDetails) { +async function installFromDownloader(userScriptDetails, downloaderDetails, {fromSyncViaWebdav = false} = {}) { let remoteScript = new RemoteUserScript(userScriptDetails); let scriptValues = downloaderDetails.valueStore; delete downloaderDetails.valueStore; @@ -95,7 +95,7 @@ async function installFromDownloader(userScriptDetails, downloaderDetails) { userScript .updateFromDownloaderDetails(userScriptDetails, downloaderDetails); return userScript; - }).then(saveUserScript) + }).then(userScript => saveUserScript(userScript, {fromSyncViaWebdav})) .then(async (details) => { if (scriptValues) { await ValueStore.deleteStore(details.uuid); @@ -269,8 +269,10 @@ async function onUserScriptUninstall(message, sender, sendResponse) { return new Promise((resolve, reject) => { req.onsuccess = () => { + const userScript = userScripts[message.uuid]; delete userScripts[message.uuid]; resolve(); + SyncViaWebdav.userScriptUninstalled(userScript); }; req.onerror = event => { console.error('onUserScriptUninstall() failure', event); @@ -284,7 +286,7 @@ async function onUserScriptUninstall(message, sender, sendResponse) { window.onUserScriptUninstall = onUserScriptUninstall; -async function saveUserScript(userScript) { +async function saveUserScript(userScript, {fromSyncViaWebdav = false} = {}) { if (!(userScript instanceof EditableUserScript)) { throw new Error( 'Cannot save this type of UserScript object: ' @@ -339,6 +341,10 @@ async function saveUserScript(userScript) { let resDetails = userScript.details; resDetails.id = userScript.id; resolve(resDetails); + + if (!fromSyncViaWebdav) { + SyncViaWebdav.userScriptEdited(userScript); + } }; req.onerror = () => reject(req.error); }).catch(onSaveError); diff --git a/src/browser/monkey-menu.css b/src/browser/monkey-menu.css index 7050e19f1..c013571ae 100755 --- a/src/browser/monkey-menu.css +++ b/src/browser/monkey-menu.css @@ -10,7 +10,7 @@ html, body { body { cursor: default; font: caption; - min-height: 450px; + min-height: 570px; width: 300px; overflow: hidden; } @@ -27,7 +27,7 @@ hr { margin: 6px 0; } -textarea { +textarea, input[type=url] { font-family: monospace; font-size: 90%; white-space: pre; @@ -230,6 +230,10 @@ section.options #add-exclude-current { width: 90vw; } +section.options menuitem.disabled { + cursor: not-allowed; +} + /***************************** MENU COMMANDS *********************************/ section.menu-commands .access-key { diff --git a/src/browser/monkey-menu.html b/src/browser/monkey-menu.html index 499cdd9c4..cdd606947 100644 --- a/src/browser/monkey-menu.html +++ b/src/browser/monkey-menu.html @@ -144,6 +144,31 @@

{'editor'|i18n}


{'use_enhanced_editor_explain'|i18n}

+ +

{'sync_via_webdav'|i18n}

+

+ + + + + {options.syncViaWebdav.enabled|i18nBool 'sync_via_webdav_is_enabled' 'sync_via_webdav_is_disabled'} + + + + {'sync_via_webdav_description'|i18n} +
+ {'sync_via_webdav_url'|i18n}
+ + {syncViaWebdavUrlValidationMessage} +

diff --git a/src/browser/monkey-menu.js b/src/browser/monkey-menu.js index bebb561f2..910846d17 100644 --- a/src/browser/monkey-menu.js +++ b/src/browser/monkey-menu.js @@ -6,6 +6,10 @@ let gTplData = { 'options': { 'globalExcludesStr': '', 'useCodeMirror': true, + 'syncViaWebdav': { + 'enabled': false, + 'url': '', + }, }, 'menuCommands': [], 'originGlob': null, @@ -13,6 +17,8 @@ let gTplData = { 'active': [], 'inactive': [], }, + 'syncViaWebdavUrlValidationMessage': '', + 'pendingSyncViaWebdavChangeOption': true, }; let gMainFocusedItem = null; // TODO: this needs to be a stack. @@ -49,7 +55,7 @@ function onClick(event) { function onKeyDown(event) { - if (event.target.tagName == 'TEXTAREA') return; + if (event.target.tagName == 'TEXTAREA' || event.target.type == 'url') return; if (event.code == 'Enter') { return activate(event.target); @@ -147,6 +153,18 @@ function onLoad() { gTplData.options.useCodeMirror = options.useCodeMirror; finish(); }); + + // numPending++ and finish() are unnecessary + // because options.syncViaWebdav are managed separately + // using gTplData.pendingSyncViaWebdavChangeOption. + chrome.runtime.sendMessage( + {'name': 'SyncViaWebdavChangeOption'}, + syncViaWebdav => { + if (!chrome.runtime.lastError) { + gTplData.options.syncViaWebdav = syncViaWebdav; + gTplData.pendingSyncViaWebdavChangeOption = false; + } + }); numPending++; chrome.tabs.query( @@ -176,7 +194,7 @@ function onTransitionEnd(e) { for (let section of document.getElementsByTagName('section')) { let isCurrent = section.className == document.body.id; section.style.visibility = isCurrent ? 'visible' : 'hidden'; - if (isCurrent && e && e.target.tagName != 'TEXTAREA') { + if (isCurrent && e && e.target.tagName != 'TEXTAREA' && e.target.type != 'url') { // Make screen readers report the new section like a dialog. Otherwise, // they would report nothing. section.focus(); @@ -192,6 +210,21 @@ function onTransitionStart() { } } + +function onInput(event) { + let el = event.target; + switch (el.name) { + case 'sync-via-webdav-url': + if (!el.checkValidity()) { + gTplData.syncViaWebdavUrlValidationMessage = el.validationMessage; + break; + } + gTplData.syncViaWebdavUrlValidationMessage = ''; + changeSyncViaWebdavOption(el.name); + break; + } +} + /////////////////////////////////////////////////////////////////////////////// // Either by mouse click or key, an element has been activated. @@ -244,6 +277,10 @@ function activate(el) { gTplData.activeScript.userMatches = addOriginGlobTo(gTplData.activeScript.userMatches); return; + case 'toggle-sync-via-webdav': + if (gTplData.pendingSyncViaWebdavChangeOption) return; + changeSyncViaWebdavOption(el.id); + return; case 'backup-export': chrome.runtime.sendMessage({'name': 'ExportDatabase'}, logUnhandledError); @@ -328,6 +365,30 @@ function addOriginGlobTo(str) { } +function changeSyncViaWebdavOption(name) { + gTplData.pendingSyncViaWebdavChangeOption = true; + + const options = {'name': 'SyncViaWebdavChangeOption'}; + switch (name) { + case 'toggle-sync-via-webdav': + options.enabled = !gTplData.options.syncViaWebdav.enabled; + break; + case 'sync-via-webdav-url': + options.url = gTplData.options.syncViaWebdav.url.trim(); + break; + } + + chrome.runtime.sendMessage(options, syncViaWebdav => { + if (chrome.runtime.lastError) { + window.close(); + } else { + gTplData.options.syncViaWebdav = syncViaWebdav; + gTplData.pendingSyncViaWebdavChangeOption = false; + } + }); +} + + function loadScripts(userScriptsDetail, url) { userScriptsDetail.sort((a, b) => i18nUserScript('name', a).localeCompare(i18nUserScript('name', b))); for (let userScriptDetail of userScriptsDetail) { diff --git a/src/browser/monkey-menu.run.js b/src/browser/monkey-menu.run.js index d7ea4d5b3..d21bc54d0 100644 --- a/src/browser/monkey-menu.run.js +++ b/src/browser/monkey-menu.run.js @@ -7,6 +7,7 @@ window.addEventListener('mouseout', onMouseOut, false); window.addEventListener('keydown', onKeyDown, false); window.addEventListener('transitionend', onTransitionEnd, false); window.addEventListener('transitionstart', onTransitionStart, false); +window.addEventListener('input', onInput, false); // When closing, navigate to main including its 'trigger pending uninstall'. window.addEventListener('unload', navigateToMainMenu, false); diff --git a/src/util/rivets-formatters.js b/src/util/rivets-formatters.js index e14dfda80..3a50da296 100644 --- a/src/util/rivets-formatters.js +++ b/src/util/rivets-formatters.js @@ -4,3 +4,5 @@ tinybind.formatters.i18nBool = (cond, t, f) => _(cond ? t : f); tinybind.formatters.empty = value => value.length == 0; tinybind.formatters.not = value => !value; tinybind.formatters.i18nUserScript = i18nUserScript; +tinybind.formatters.or = (a, b) => a || b; +tinybind.formatters.trim = value => value.trim(); diff --git a/test/bg/sync-via-webdav.test.js b/test/bg/sync-via-webdav.test.js new file mode 100644 index 000000000..48c311a3d --- /dev/null +++ b/test/bg/sync-via-webdav.test.js @@ -0,0 +1,336 @@ +/** + * @typedef {object} WebDAV + * @property {import('../../third-party/webdav/index').createClient} createClient + */ + +describe('bg/sync-via-webdav', () => { + const WEBDAV_ROOT_DIRECTORY_URL = new URL('/webdav/', location).href; + + const fakeStorage = {}; + /** @type {import('../../third-party/webdav/index').WebDAVClient} */ + let webdavClient; + + before(async () => { + chrome.storage.local.set.callsFake((keys, callback) => { + Object.assign(fakeStorage, keys); + callback(); + }); + + chrome.storage.local.get.callsFake((keys, callback) => { + callback(Object.assign(keys, fakeStorage)); + }); + }); + + after(() => { + chrome.storage.local.set.flush(); + chrome.storage.local.get.flush(); + }); + + beforeEach(async () => { + webdavClient = WebDAV.createClient(WEBDAV_ROOT_DIRECTORY_URL); + await webdavClient.createDirectory('/'); + }); + + afterEach(async () => { + if (setTimeout.restore) { + setTimeout.restore(); + } + if (console.error.restore) { + console.error.restore(); + } + chrome.notifications.create.flush(); + chrome.notifications.clear.flush(); + + await onSyncViaWebdavChangeOption({'enabled': false}, {}, () => {}); + + for (const key of Object.keys(fakeStorage)) { + delete fakeStorage[key]; + } + + await webdavClient.deleteFile('/'); + }); + + const NAMESPACE = 'https://github.com/greasemonkey/greasemonkey/issues/2513'; + async function addUserScript({name, namespace = NAMESPACE, uuid = null}) { + const script = new EditableUserScript({name, namespace, uuid, 'content': `// ==UserScript== +// @name ${name} +// @namespace ${NAMESPACE} +// ==/UserScript==`}); + await UserScriptRegistry.saveUserScript(script); + return script; + } + + it('initialization succeeds', async () => { + const scripts = [ + { + 'name': '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~', + 'filename': '!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~', + }, + { + 'name': '  leading white space', + 'filename': 'leading white space', + }, + { + 'name': '.leading full stop', + 'filename': '.leading full stop', + }, + { + 'name': '🐒'.repeat(256), + 'directoryName': '🐒'.repeat(255 - ' (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)'.length), + 'filename': '🐒'.repeat(255 - '.user.js'.length), + }, + { + 'name': 'aux', + 'filename': 'aux', + }, + { + 'name': 'aux  .', + 'filename': 'aux  .', + }, + ]; + + for (const script of scripts) { + const userScript = await addUserScript({'name': script.name}); + Object.assign(script, {'uuid': userScript.uuid, 'content': userScript.content}); + script.directoryName = `${script.directoryName || script.filename} (${script.uuid})`; + script.filename += '.user.js'; + } + const userScriptsCount = Array.from((await UserScriptRegistry.scriptsToRunAt(null, true))).length; + assert.isAtLeast(userScriptsCount, scripts.length); + + fakeStorage.syncViaWebdav = { + 'enabled': true, + 'url': WEBDAV_ROOT_DIRECTORY_URL, + }; + + await SyncViaWebdav._run(); + + /** @type {import('../../third-party/webdav/index').FileStat[]} */ + const itemStats = await webdavClient.getDirectoryContents('/', {'deep': true}); + + assert.lengthOf( + itemStats.filter(stat => stat.type === 'directory'), + userScriptsCount, + JSON.stringify(itemStats, null, 2)); + + for (const script of scripts) { + const directoryStat = itemStats.find(stat => stat.filename.endsWith(`(${script.uuid})`)); + assert.isOk(directoryStat, script.directoryName); + assert.equal(directoryStat.basename, script.directoryName); + const stat = itemStats.find(stat => stat.filename.includes(`(${script.uuid})/`)) + assert.isOk(stat, script.filename); + assert.equal(stat.basename, script.filename); + assert.equal(stat.filename, `/${script.directoryName}/${script.filename}`); + assert.equal(await webdavClient.getFileContents(stat.filename, {'format': 'text'}), script.content); + } + }); + + /** + * @param {string} uuid + * @returns {Promise.} + */ + async function getUserJsFileStat(uuid) { + return (await webdavClient.getDirectoryContents('/', {'deep': true, 'glob': `/*\\(${uuid})/*.user.js`}))[0]; + } + + it('update an user script on Greasemonkey, then a local file will be updated', async () => { + const script = await addUserScript({'name': 'Greasemonkey → File'}); + + await onSyncViaWebdavChangeOption({'url': WEBDAV_ROOT_DIRECTORY_URL}, {}, () => {}); + await onSyncViaWebdavChangeOption({'enabled': true}, {}, () => {}); + + const stat = await getUserJsFileStat(script.uuid); + assert.isOk(stat); + assert.equal(await webdavClient.getFileContents(stat.filename, {'format': 'text'}), script.content); + + const content = (await addUserScript({'name': 'Greasemonkey → File updated', 'uuid': script.uuid})).content; + + // Wait for sync to be completed. + await new Promise(resolve => { + setTimeout(resolve); + }); + await onSyncViaWebdavChangeOption({}, {}, () => {}); + + assert.equal(await webdavClient.getFileContents(stat.filename, {'format': 'text'}), content); + }); + + it('overwrite and save a local file, then an user script on Greasemonkey will be updated', async () => { + const scripts = await Promise.all([ + 'File → Greasemonkey (same last-modified)', + 'File → Greasemonkey (different last-modified)', + 'File → Greasemonkey (invalid URL)', + 'File → Greasemonkey (same name and namespace)', + 'File → Greasemonkey (same name and namespace) dummy', + ].map(async name => { + const script = await addUserScript({name}); + script.name = name; + return script; + })); + const INVALID_URL = 'https://greasemonkey.invalid/lib.js'; + + // Shorten DIRECTORY_MONITOR_INTERVAL to shorten the test time. + const _setTimeout = setTimeout; + sinon.stub(window, 'setTimeout').callsFake((handler, timeout = 0, ...args) => { + _setTimeout(handler, 100, args); + }); + + // Wait for the milliseconds portion to reach zero. + await new Promise(resolve => { + _setTimeout(resolve, 1000 - new Date().getMilliseconds()); + }); + + // Suppress console.error() in installFromDownloader(). + const error = console.error; + sinon.stub(console, 'error').callsFake((...args) => { + if (typeof args[0] === 'string' && args[0].includes('installFromDownloader') + && typeof args[1] === 'string' && args[1].includes(NAMESPACE)) { + return; + } + error(...args); + }); + + await onSyncViaWebdavChangeOption({'url': WEBDAV_ROOT_DIRECTORY_URL}, {}, () => {}); + await onSyncViaWebdavChangeOption({'enabled': true}, {}, () => {}); + + const notificationOptionsList = []; + chrome.notifications.create.callsFake((notificationId, options, callback = null) => { + notificationOptionsList.push(options); + if (callback) { + callback(''); + } + }); + chrome.notifications.clear.callsFake((notificationId, callback = null) => { + if (callback) { + callback(true); + } + }); + let expectedErrorCount = 0; + + for (const script of scripts) { + script.fileStat = await getUserJsFileStat(script.uuid); + assert.isOk(script.fileStat, script.name); + + assert.equal( + await webdavClient.getFileContents(script.fileStat.filename, {'format': 'text'}), + script.content, + script.name); + + script.modifiedName = script.name.replace(' dummy', '') + ' updated'; + script.modifiedContent = script.content.replace(script.name, script.modifiedName); + if (script.name.includes('invalid URL')) { + script.modifiedContent = `// ==UserScript== +// @name ${script.modifiedName} +// @namespace ${NAMESPACE} +// @require ${INVALID_URL} +// ==/UserScript==`; + } + + if (!script.name.includes('different last-modified')) { + await webdavClient.putFileContents(script.fileStat.filename, script.modifiedContent); + if (script.name.includes('same last-modified')) { + script.etag = (await getUserJsFileStat(script.uuid)).etag; + } + if (script.name.includes('invalid URL') || script.name.includes(' dummy')) { + expectedErrorCount++; + } + } + } + + // Wait until last-modified is different. + await new Promise(resolve => { + _setTimeout(resolve, 1000); + }); + + for (const script of scripts) { + if (script.name.includes('different last-modified') || script.name.includes('invalid URL')) { + await webdavClient.putFileContents(script.fileStat.filename, script.modifiedContent); + if (script.name.includes('invalid URL')) { + expectedErrorCount++; + } + } + + script.modifiedFileStat = await getUserJsFileStat(script.uuid); + if (script.name.includes('same last-modified')) { + assert.equal(script.modifiedFileStat.lastmod, script.fileStat.lastmod); + // Check that writes are not looping. + assert.equal(script.modifiedFileStat.etag, script.etag); + } else if (script.name.includes('different last-modified')) { + assert.notEqual(script.modifiedFileStat.lastmod, script.fileStat.lastmod); + } + } + + // Wait for sync to be completed. + await new Promise(resolve => { + _setTimeout(resolve, 500); + }); + + for (const script of scripts) { + if (script.name.includes('same name')) { + // Cannot determine which of the two scripts causes the error. + continue; + } + const modifiedUserScript = UserScriptRegistry.scriptByUuid(script.uuid); + const isErrorScript = script.name.includes('invalid URL'); + assert.equal( + modifiedUserScript.content, + isErrorScript ? script.content : script.modifiedContent, + script.name); + assert.equal( + modifiedUserScript.name, + isErrorScript ? script.name : script.modifiedName, + script.name); + } + + assert.equal( + notificationOptionsList.length, + expectedErrorCount, + JSON.stringify(notificationOptionsList, null, 2)); + for (const options of notificationOptionsList) { + if (options.message.includes('invalid URL')) { + assert.include(options.message, INVALID_URL); + } else if (options.message.includes('same name')) { + assert.include(options.message, NAMESPACE); + } else { + return Promise.reject(new Error()); + } + } + }).timeout(3500); + + it('install new user script, then a local file will be created', async () => { + await onSyncViaWebdavChangeOption({'enabled': true}, {}, () => {}); + await onSyncViaWebdavChangeOption({'url': WEBDAV_ROOT_DIRECTORY_URL}, {}, () => {}); + + const script = await addUserScript({'name': 'Unnamed Script 100000'}); + + // Wait for sync to be completed. + await new Promise(resolve => { + setTimeout(resolve); + }); + await onSyncViaWebdavChangeOption({}, {}, () => {}); + + const stat = await getUserJsFileStat(script.uuid); + assert.isOk(stat); + assert.equal(await webdavClient.getFileContents(stat.filename, {'format': 'text'}), script.content); + }); + + it('uninstall an user script, then a local file will be removed', async () => { + const script = await addUserScript({'name': 'Unnamed Script 1000000'}); + + await onSyncViaWebdavChangeOption({'enabled': true}, {}, () => {}); + await onSyncViaWebdavChangeOption({'url': WEBDAV_ROOT_DIRECTORY_URL}, {}, () => {}); + + const stat = await getUserJsFileStat(script.uuid); + assert.isOk(stat); + assert.equal(await webdavClient.getFileContents(stat.filename, {'format': 'text'}), script.content); + + await onUserScriptUninstall(script, {}, () => {}); + + // Wait for sync to be completed. + await new Promise(resolve => { + setTimeout(resolve); + }); + await onSyncViaWebdavChangeOption({}, {}, () => {}); + + assert.isNotOk(await getUserJsFileStat(script.uuid)); + }); +}); diff --git a/test/test.js b/test/test.js new file mode 100644 index 000000000..035a0657c --- /dev/null +++ b/test/test.js @@ -0,0 +1,14 @@ +const karma = require('karma'); +const webdav = require('webdav-server').v2; + +const webdavServer = new webdav.WebDAVServer({'port': 7329, 'maxRequestDepth': Number.POSITIVE_INFINITY}); +webdavServer.start(); + +new karma.Server( + karma.config.parseConfig( + module.filename + '/../../karma.conf.js', + {'proxies': {'/webdav/': 'http://localhost:7329/webdav/'}}), + exitCode => { + webdavServer.stop(); + process.exit(exitCode); + }).start();