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
This commit is contained in:
Claude
2026-03-26 14:44:18 +00:00
parent 1e475196d6
commit cbe5b8584d
3 changed files with 290 additions and 7 deletions
+153 -7
View File
@@ -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