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:
+153
-7
@@ -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
|
||||
|
||||
@@ -158,6 +158,46 @@
|
||||
</div>
|
||||
<div id="fileSyncStatus" style="margin-top:6px;font-size:12px;min-height:16px"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
|
||||
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">GitHub Gist Sync — no desktop client needed</h3>
|
||||
<p class="section-desc" style="margin-bottom:8px">
|
||||
Store settings in a private GitHub Gist. Works across any browser or device
|
||||
with just a GitHub account. Create a
|
||||
<a href="https://github.com/settings/tokens/new?scopes=gist&description=SilentSend" target="_blank" rel="noopener">
|
||||
Personal Access Token
|
||||
</a>
|
||||
with the <code>gist</code> scope, paste it below, and click Push.
|
||||
The Gist ID is remembered — future pushes update the same Gist.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">
|
||||
<input type="password" id="gistToken" placeholder="GitHub PAT (ghp_…)" autocomplete="off"
|
||||
style="flex:1;min-width:180px;font-family:monospace;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
||||
<button class="btn btn-primary" id="btnGistPush">Push</button>
|
||||
<button class="btn" id="btnGistPull">Pull</button>
|
||||
</div>
|
||||
<div id="gistSyncStatus" style="font-size:12px;min-height:16px"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
|
||||
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Custom URL Sync</h3>
|
||||
<p class="section-desc" style="margin-bottom:8px">
|
||||
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.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">
|
||||
<input type="url" id="customSyncUrl" placeholder="https://…/silent-send-sync.json"
|
||||
style="flex:2;min-width:200px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
||||
<input type="text" id="customSyncHeaders" placeholder='{"Authorization":"Bearer …"}' autocomplete="off"
|
||||
style="flex:1;min-width:160px;font-family:monospace;font-size:11px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
||||
</div>
|
||||
<div class="bulk-actions">
|
||||
<button class="btn btn-primary" id="btnUrlPush">Push</button>
|
||||
<button class="btn" id="btnUrlPull">Pull</button>
|
||||
</div>
|
||||
<div id="urlSyncStatus" style="margin-top:6px;font-size:12px;min-height:16px"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
|
||||
@@ -122,6 +122,80 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await checkFileSyncUpdate();
|
||||
});
|
||||
|
||||
// --- GitHub Gist sync ---
|
||||
// Restore saved token (session only — never persisted to storage)
|
||||
{
|
||||
const stored = await api.storage.local.get('ss_gist_id');
|
||||
if (stored.ss_gist_id) {
|
||||
setGistSyncStatus(`Gist ID: ${stored.ss_gist_id.slice(0, 12)}…`, 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
$('#btnGistPush').addEventListener('click', async () => {
|
||||
const token = $('#gistToken').value.trim();
|
||||
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
|
||||
setGistSyncStatus('Pushing…', 'neutral');
|
||||
const r = await SilentSendSync.pushToGist(token);
|
||||
if (r.success) {
|
||||
setGistSyncStatus(`Pushed. Gist ID: ${r.gistId.slice(0, 12)}…`, 'ok');
|
||||
} else {
|
||||
setGistSyncStatus('Push failed: ' + r.reason, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('#btnGistPull').addEventListener('click', async () => {
|
||||
const token = $('#gistToken').value.trim();
|
||||
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
|
||||
setGistSyncStatus('Pulling…', 'neutral');
|
||||
const r = await SilentSendSync.pullFromGist(token);
|
||||
if (!r.success) {
|
||||
setGistSyncStatus('Pull failed: ' + r.reason, 'error');
|
||||
} else if (r.imported) {
|
||||
setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
|
||||
mappings = await Storage.getMappings();
|
||||
settings = await Storage.getSettings();
|
||||
renderMappings();
|
||||
renderDomains();
|
||||
renderLog();
|
||||
} else {
|
||||
setGistSyncStatus('Already up to date.', 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Custom URL sync ---
|
||||
$('#btnUrlPush').addEventListener('click', async () => {
|
||||
const url = $('#customSyncUrl').value.trim();
|
||||
if (!url) { setUrlSyncStatus('Enter a URL first.', 'warn'); return; }
|
||||
const headers = parseHeadersField($('#customSyncHeaders').value);
|
||||
setUrlSyncStatus('Pushing…', 'neutral');
|
||||
const r = await SilentSendSync.pushToUrl({ url, headers });
|
||||
if (r.success) {
|
||||
setUrlSyncStatus('Pushed successfully.', 'ok');
|
||||
} else {
|
||||
setUrlSyncStatus('Push failed: ' + r.reason, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('#btnUrlPull').addEventListener('click', async () => {
|
||||
const url = $('#customSyncUrl').value.trim();
|
||||
if (!url) { setUrlSyncStatus('Enter a URL first.', 'warn'); return; }
|
||||
const headers = parseHeadersField($('#customSyncHeaders').value);
|
||||
setUrlSyncStatus('Pulling…', 'neutral');
|
||||
const r = await SilentSendSync.pullFromUrl({ url, headers });
|
||||
if (!r.success) {
|
||||
setUrlSyncStatus('Pull failed: ' + r.reason, 'error');
|
||||
} else if (r.imported) {
|
||||
setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
|
||||
mappings = await Storage.getMappings();
|
||||
settings = await Storage.getSettings();
|
||||
renderMappings();
|
||||
renderDomains();
|
||||
renderLog();
|
||||
} else {
|
||||
setUrlSyncStatus('Already up to date.', 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
// Transfer data
|
||||
$('#btnExportAll').addEventListener('click', exportAllPlain);
|
||||
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
|
||||
@@ -605,6 +679,29 @@ function setFileSyncStatus(msg, type) {
|
||||
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||
}
|
||||
|
||||
function setGistSyncStatus(msg, type) {
|
||||
const el = $('#gistSyncStatus');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||
}
|
||||
|
||||
function setUrlSyncStatus(msg, type) {
|
||||
const el = $('#urlSyncStatus');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||
}
|
||||
|
||||
function parseHeadersField(val) {
|
||||
if (!val || !val.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(val.trim());
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setSyncStatus(msg, type) {
|
||||
const el = $('#syncStatus');
|
||||
if (!el) return;
|
||||
|
||||
Reference in New Issue
Block a user