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}
+
+
+
+ {'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();