From f05375c2ec34fa21c643f91ec375d14166f5c3fa Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 26 Mar 2026 14:32:53 +0000
Subject: [PATCH 1/3] fix: PPI + button replaces text immediately; add
auto-sync folder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PPI + button fix:
- After adding a mapping, the real PPI value is now immediately replaced
with the fake value in the input/contenteditable element via the new
replaceInInput() helper (handles both
diff --git a/src/options/options.js b/src/options/options.js
index 4e29c99..20d5646 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -96,6 +96,10 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// --- File-based auto-sync ---
+ // Tell the service worker the user has seen any pending sync notification
+ api.runtime.sendMessage({ type: 'sync:notification-seen' }).catch(() => {});
+ await api.storage.local.remove('ss_sync_notification');
+
await initFileSync();
$('#btnPickSyncFolder').addEventListener('click', pickSyncFolder);
@@ -559,7 +563,7 @@ async function checkFileSyncUpdate() {
const local = await SilentSendSync._getAllData();
if (data.lastModified > (local.lastModified || 0)) {
- await SilentSendSync._applyData(data);
+ await SilentSendSync._applyData(data, 'file');
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
$('#browserSync').checked = settings.browserSync === true;
From cbe5b8584d91a726fc930b3ebf5d704d9ec4d5d9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 26 Mar 2026 14:44:18 +0000
Subject: [PATCH 3/3] feat: GitHub Gist and custom HTTP URL cloud sync
GitHub Gist sync:
- pushToGist(token): pushes all settings to a private Gist; creates a
new Gist on first use and stores the Gist ID in local storage so
subsequent pushes update the same Gist.
- pullFromGist(token): fetches the Gist, compares lastModified, applies
if newer (raw_url used to avoid API truncation).
- No desktop client needed; works across any browser/OS with a GitHub PAT.
- Options page shows a token field + Push/Pull buttons; Gist ID shown
once linked.
Custom URL sync:
- pushToUrl({ url, method, headers }): HTTP PUT to any endpoint.
- pullFromUrl({ url, headers }): HTTP GET, applies if newer.
- Works with Nextcloud/ownCloud WebDAV, self-hosted servers, cloud
functions, or any static-file host that allows PUT.
- Options page shows URL + optional JSON headers field + Push/Pull.
Both methods write ss_sync_notification on apply, triggering the purple
'SYN' badge and desktop notification added in the previous commit.
https://claude.ai/code/session_01TKpSR9M8JgHLXCp5CeDsQP
---
src/lib/sync.js | 160 +++++++++++++++++++++++++++++++++++++--
src/options/options.html | 40 ++++++++++
src/options/options.js | 97 ++++++++++++++++++++++++
3 files changed, 290 insertions(+), 7 deletions(-)
diff --git a/src/lib/sync.js b/src/lib/sync.js
index 6a6cfd6..1c6368c 100644
--- a/src/lib/sync.js
+++ b/src/lib/sync.js
@@ -1,14 +1,16 @@
/**
* Silent Send - Cross-Browser Settings Sync
*
- * Two sync mechanisms:
+ * Sync mechanisms:
*
- * 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste between
- * browsers (works across any browser/device combination).
- *
- * 2. browser.storage.sync — automatic sync within the same browser family
- * (Firefox ↔ Firefox via Firefox Sync, Chrome ↔ Chrome via Google account).
- * Data is chunked to stay within per-item size limits.
+ * 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste.
+ * 2. browser.storage.sync — automatic within the same browser family.
+ * 3. Folder sync (File System Access API) — any locally-mounted folder
+ * including Dropbox, OneDrive, Google Drive, iCloud, Nextcloud, etc.
+ * 4. GitHub Gist — serverless cloud sync using a personal access token;
+ * works across any browser/device without a local desktop client.
+ * 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV,
+ * self-hosted server, cloud function, etc.).
*
* Conflict resolution: newest `lastModified` timestamp wins.
*/
@@ -176,6 +178,150 @@ const SilentSendSync = {
}
},
+ // ----------------------------------------------------------------
+ // GitHub Gist sync
+ // Requires a personal access token with the `gist` scope.
+ // On first push a new secret Gist is created; the Gist ID is stored
+ // in local storage so all subsequent reads/writes use the same Gist.
+ // ----------------------------------------------------------------
+
+ /**
+ * Push current data to a GitHub Gist (creates one if no gist ID stored).
+ * token: GitHub PAT with `gist` scope.
+ * Returns { success, gistId } or { success: false, reason }.
+ */
+ async pushToGist(token) {
+ if (!token) return { success: false, reason: 'No GitHub token provided.' };
+ try {
+ const data = await this._getAllData();
+ const content = JSON.stringify(data, null, 2);
+ const stored = await api.storage.local.get('ss_gist_id');
+ const gistId = stored.ss_gist_id;
+
+ let resp;
+ if (gistId) {
+ // Update existing Gist
+ resp = await fetch(`https://api.github.com/gists/${gistId}`, {
+ method: 'PATCH',
+ headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ files: { 'silent-send-sync.json': { content } } }),
+ });
+ } else {
+ // Create new secret Gist
+ resp = await fetch('https://api.github.com/gists', {
+ method: 'POST',
+ headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ description: 'Silent Send settings sync',
+ public: false,
+ files: { 'silent-send-sync.json': { content } },
+ }),
+ });
+ }
+
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ return { success: false, reason: err.message || `HTTP ${resp.status}` };
+ }
+
+ const json = await resp.json();
+ await api.storage.local.set({ ss_gist_id: json.id });
+ return { success: true, gistId: json.id };
+ } catch (e) {
+ return { success: false, reason: e.message };
+ }
+ },
+
+ /**
+ * Pull data from a GitHub Gist and apply if newer.
+ * token: GitHub PAT with `gist` scope.
+ * Returns { success, imported?, reason? }.
+ */
+ async pullFromGist(token) {
+ if (!token) return { success: false, reason: 'No GitHub token provided.' };
+ try {
+ const stored = await api.storage.local.get('ss_gist_id');
+ const gistId = stored.ss_gist_id;
+ if (!gistId) return { success: false, reason: 'No Gist ID stored. Push first.' };
+
+ const resp = await fetch(`https://api.github.com/gists/${gistId}`, {
+ headers: { Authorization: `token ${token}` },
+ });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ return { success: false, reason: err.message || `HTTP ${resp.status}` };
+ }
+
+ const gist = await resp.json();
+ const file = gist.files?.['silent-send-sync.json'];
+ if (!file) return { success: false, reason: 'Sync file not found in Gist.' };
+
+ // Fetch raw content (may be truncated in the API response)
+ const rawResp = await fetch(file.raw_url);
+ const data = JSON.parse(await rawResp.text());
+
+ const local = await this._getAllData();
+ if (data.lastModified <= (local.lastModified || 0)) {
+ return { success: true, imported: false };
+ }
+
+ await this._applyData(data, 'gist');
+ return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
+ } catch (e) {
+ return { success: false, reason: e.message };
+ }
+ },
+
+ // ----------------------------------------------------------------
+ // Custom HTTP endpoint sync
+ // GET fetches the JSON, PUT/PATCH writes it.
+ // Works with WebDAV (Nextcloud, ownCloud), any REST endpoint, or a
+ // simple static file server that supports PUT.
+ // ----------------------------------------------------------------
+
+ /**
+ * Push to a custom URL via HTTP PUT.
+ * opts: { url, method = 'PUT', headers = {} }
+ */
+ async pushToUrl({ url, method = 'PUT', headers = {} } = {}) {
+ if (!url) return { success: false, reason: 'No URL provided.' };
+ try {
+ const data = await this._getAllData();
+ const resp = await fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body: JSON.stringify(data, null, 2),
+ });
+ if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` };
+ return { success: true };
+ } catch (e) {
+ return { success: false, reason: e.message };
+ }
+ },
+
+ /**
+ * Pull from a custom URL via HTTP GET and apply if newer.
+ * opts: { url, headers = {} }
+ */
+ async pullFromUrl({ url, headers = {} } = {}) {
+ if (!url) return { success: false, reason: 'No URL provided.' };
+ try {
+ const resp = await fetch(url, { headers });
+ if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` };
+ const data = await resp.json();
+
+ const local = await this._getAllData();
+ if (data.lastModified <= (local.lastModified || 0)) {
+ return { success: true, imported: false };
+ }
+
+ await this._applyData(data, 'url');
+ return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
+ } catch (e) {
+ return { success: false, reason: e.message };
+ }
+ },
+
// ----------------------------------------------------------------
// File System Access API helpers — folder-based sync
// The directory handle is stored in IndexedDB so the user only
diff --git a/src/options/options.html b/src/options/options.html
index 0634b24..03a2eb4 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -158,6 +158,46 @@
+
+
+
GitHub Gist Sync — no desktop client needed
+
+ Store settings in a private GitHub Gist. Works across any browser or device
+ with just a GitHub account. Create a
+
+ Personal Access Token
+
+ with the gist scope, paste it below, and click Push.
+ The Gist ID is remembered — future pushes update the same Gist.
+
+
+
+
+
+
+
+
+
+
+
Custom URL Sync
+
+ Any URL that supports HTTP GET (read) and PUT (write). Works with
+ Nextcloud/ownCloud WebDAV, a self-hosted server, or a cloud function.
+ Add custom headers (e.g. Authorization) as JSON if needed.
+