Merge pull request #6 from outis1one/claude/check-repo-access-VmcDX
Claude/check repo access vmc dx
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting"
|
||||
"scripting",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://claude.ai/*",
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting"
|
||||
"scripting",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://claude.ai/*",
|
||||
|
||||
@@ -146,6 +146,13 @@ const messageHandlers = {
|
||||
sendResponse({ count: tabCounts.get(tabId) || 0 });
|
||||
},
|
||||
|
||||
async 'sync:notification-seen'() {
|
||||
// Options page opened — clear the sync badge and pending notification flag
|
||||
await api.storage.local.remove('ss_sync_notification');
|
||||
api.action.setBadgeText({ text: '' });
|
||||
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
|
||||
},
|
||||
|
||||
async 'update:settings'(message) {
|
||||
await Storage.saveSettings(message.settings);
|
||||
|
||||
@@ -309,6 +316,33 @@ api.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
}
|
||||
}
|
||||
|
||||
// When a sync operation applied new data, show badge + notification
|
||||
if (areaName === 'local' && changes.ss_sync_notification?.newValue) {
|
||||
const notif = changes.ss_sync_notification.newValue;
|
||||
const sourceLabel = {
|
||||
'file': 'sync folder',
|
||||
'browser-sync': 'browser account sync',
|
||||
'code': 'sync code import',
|
||||
}[notif.source] || 'sync';
|
||||
|
||||
// Purple badge — persists until Options is opened
|
||||
api.action.setBadgeText({ text: 'SYN' });
|
||||
api.action.setBadgeBackgroundColor({ color: '#7c3aed' });
|
||||
|
||||
// Desktop notification
|
||||
try {
|
||||
api.notifications.create('ss-sync-applied', {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon48.svg',
|
||||
title: 'Silent Send — Settings Synced',
|
||||
message: `Settings updated via ${sourceLabel}. Open Options to review.`,
|
||||
priority: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
// Notifications permission not granted — badge is still visible
|
||||
}
|
||||
}
|
||||
|
||||
// When sync storage changes (another device pushed new data), pull it into local
|
||||
if (areaName === 'sync' && (changes.ss_sync_meta || Object.keys(changes).some(k => k.startsWith('ss_sync_chunk_')))) {
|
||||
const settings = await Storage.getSettings();
|
||||
@@ -318,6 +352,14 @@ api.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking a sync notification opens the Options page
|
||||
api.notifications.onClicked.addListener((notificationId) => {
|
||||
if (notificationId === 'ss-sync-applied') {
|
||||
api.runtime.openOptionsPage();
|
||||
api.notifications.clear(notificationId);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Set initial state ---
|
||||
api.runtime.onInstalled.addListener(async () => {
|
||||
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
|
||||
@@ -329,6 +371,14 @@ api.runtime.onInstalled.addListener(async () => {
|
||||
(async () => {
|
||||
const settings = await Storage.getSettings();
|
||||
await updateIcon(settings);
|
||||
|
||||
// Restore the SYN badge if the user hasn't opened Options since the last sync
|
||||
const stored = await api.storage.local.get('ss_sync_notification');
|
||||
if (stored.ss_sync_notification) {
|
||||
api.action.setBadgeText({ text: 'SYN' });
|
||||
api.action.setBadgeBackgroundColor({ color: '#7c3aed' });
|
||||
}
|
||||
|
||||
if (settings.browserSync) {
|
||||
await SilentSendSync.pullFromSyncStorage();
|
||||
}
|
||||
|
||||
+30
-1
@@ -1134,9 +1134,17 @@
|
||||
});
|
||||
await setStorageData('ss_mappings', currentMappings);
|
||||
|
||||
// Update local mappings
|
||||
// Update local mappings so the fetch interceptor uses them immediately
|
||||
mappings = currentMappings;
|
||||
|
||||
// Replace the PPI value in the current input right now
|
||||
if (inputEl) {
|
||||
replaceInInput(inputEl, real, fake);
|
||||
// Re-scan — will dismiss warning if no more PPI remains
|
||||
if (inputScanTimer) clearTimeout(inputScanTimer);
|
||||
inputScanTimer = setTimeout(() => scanInputForPPI(inputEl), 150);
|
||||
}
|
||||
|
||||
// Visual feedback
|
||||
btn.textContent = '\u2714';
|
||||
btn.style.color = '#4ade80';
|
||||
@@ -1145,6 +1153,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Replace all occurrences of `real` with `fake` in an input or contenteditable element
|
||||
function replaceInInput(el, real, fake) {
|
||||
if (!el) return;
|
||||
const re = new RegExp(real.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
if (el.value !== undefined) {
|
||||
// <textarea> or <input>
|
||||
el.value = el.value.replace(re, fake);
|
||||
} else if (el.isContentEditable) {
|
||||
// contenteditable div — walk text nodes to avoid breaking inner HTML
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
re.lastIndex = 0;
|
||||
if (re.test(node.textContent)) {
|
||||
re.lastIndex = 0;
|
||||
node.textContent = node.textContent.replace(re, fake);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Storage helpers for page world (uses postMessage to injector)
|
||||
function getStorageData(key) {
|
||||
return new Promise(resolve => {
|
||||
|
||||
+216
-11
@@ -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.
|
||||
*/
|
||||
@@ -65,7 +67,7 @@ const SilentSendSync = {
|
||||
}
|
||||
}
|
||||
|
||||
await this._applyData(data);
|
||||
await this._applyData(data, 'code');
|
||||
return { success: true, importTime: new Date(data.lastModified).toLocaleString() };
|
||||
} catch (e) {
|
||||
return { success: false, reason: e.message };
|
||||
@@ -131,7 +133,7 @@ const SilentSendSync = {
|
||||
const json = chunkKeys.map(k => chunkResult[k] || '').join('');
|
||||
const data = JSON.parse(json);
|
||||
|
||||
await this._applyData(data);
|
||||
await this._applyData(data, 'browser-sync');
|
||||
return { imported: true, time: new Date(data.lastModified).toLocaleString() };
|
||||
} catch (e) {
|
||||
console.warn('[Silent Send] pullFromSyncStorage failed:', e);
|
||||
@@ -154,8 +156,12 @@ const SilentSendSync = {
|
||||
};
|
||||
},
|
||||
|
||||
async _applyData(data) {
|
||||
const toSet = { ss_lastModified: data.lastModified };
|
||||
async _applyData(data, source = 'unknown') {
|
||||
const toSet = {
|
||||
ss_lastModified: data.lastModified,
|
||||
// Signal the service worker to show a badge/notification
|
||||
ss_sync_notification: { source, time: Date.now() },
|
||||
};
|
||||
if (data.identity !== undefined) toSet.ss_identity = data.identity;
|
||||
if (data.mappings !== undefined) toSet.ss_mappings = data.mappings;
|
||||
if (data.settings !== undefined) toSet.ss_settings = data.settings;
|
||||
@@ -171,6 +177,205 @@ const SilentSendSync = {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 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
|
||||
// needs to grant access once per browser session.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
_dbPromise: null,
|
||||
|
||||
_openDB() {
|
||||
if (this._dbPromise) return this._dbPromise;
|
||||
this._dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('ss_sync_handles', 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore('handles');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return this._dbPromise;
|
||||
},
|
||||
|
||||
async saveSyncDirHandle(handle) {
|
||||
const db = await this._openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('handles', 'readwrite');
|
||||
tx.objectStore('handles').put(handle, 'syncDir');
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async loadSyncDirHandle() {
|
||||
try {
|
||||
const db = await this._openDB();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('handles', 'readonly');
|
||||
const req = tx.objectStore('handles').get('syncDir');
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async clearSyncDirHandle() {
|
||||
try {
|
||||
const db = await this._openDB();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('handles', 'readwrite');
|
||||
tx.objectStore('handles').delete('syncDir');
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = resolve;
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
};
|
||||
|
||||
export default SilentSendSync;
|
||||
|
||||
@@ -140,6 +140,64 @@
|
||||
|
||||
<div id="syncStatus" style="margin-top:8px;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">Auto-Sync Folder — fully automatic, no copy-paste</h3>
|
||||
<p class="section-desc" style="margin-bottom:8px">
|
||||
Pick the same folder in each browser once. Changes are written to
|
||||
<code>silent-send-sync.json</code> automatically whenever settings change,
|
||||
and read back whenever this page is opened or regains focus.
|
||||
Works with <strong>any cloud storage that has a desktop sync client</strong> —
|
||||
Dropbox, OneDrive, Google Drive, iCloud Drive, Box, pCloud, Nextcloud,
|
||||
Synology Drive, or any network share. Just pick the cloud-synced folder.
|
||||
</p>
|
||||
<div class="bulk-actions" style="align-items:center">
|
||||
<button class="btn btn-primary" id="btnPickSyncFolder">Choose Sync Folder</button>
|
||||
<span id="syncFolderName" style="font-size:12px;color:#6b7280;font-family:monospace"></span>
|
||||
<button class="btn btn-danger btn-sm" id="btnClearSyncFolder" style="display:none">Clear</button>
|
||||
</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">
|
||||
|
||||
@@ -95,6 +95,107 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
setSyncStatus('', 'neutral');
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
$('#btnClearSyncFolder').addEventListener('click', async () => {
|
||||
await SilentSendSync.clearSyncDirHandle();
|
||||
syncDirHandle = null;
|
||||
updateFileSyncUI();
|
||||
setFileSyncStatus('Sync folder cleared.', 'neutral');
|
||||
});
|
||||
|
||||
// Auto-write when local storage changes (catches popup edits, page-world adds, etc.)
|
||||
api.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area === 'local' && (changes.ss_settings || changes.ss_mappings || changes.ss_identity)) {
|
||||
await writeToSyncFile();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-check sync file whenever the options page regains focus
|
||||
window.addEventListener('focus', 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);
|
||||
@@ -465,6 +566,142 @@ async function importAll(e) {
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// File-based auto-sync (File System Access API)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
let syncDirHandle = null;
|
||||
const SYNC_FILE_NAME = 'silent-send-sync.json';
|
||||
|
||||
async function initFileSync() {
|
||||
syncDirHandle = await SilentSendSync.loadSyncDirHandle();
|
||||
updateFileSyncUI();
|
||||
if (syncDirHandle) {
|
||||
await checkFileSyncUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async function pickSyncFolder() {
|
||||
if (!window.showDirectoryPicker) {
|
||||
setFileSyncStatus('Your browser does not support the File System Access API.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'ss-sync' });
|
||||
syncDirHandle = handle;
|
||||
await SilentSendSync.saveSyncDirHandle(handle);
|
||||
updateFileSyncUI();
|
||||
// Write current settings immediately so the file exists for the other browser
|
||||
await writeToSyncFile();
|
||||
setFileSyncStatus('Sync folder set. Settings will sync automatically.', 'ok');
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setFileSyncStatus('Could not set sync folder: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToSyncFile() {
|
||||
if (!syncDirHandle) return;
|
||||
try {
|
||||
// Re-verify permission is still granted (required after browser restart)
|
||||
const perm = await syncDirHandle.requestPermission({ mode: 'readwrite' });
|
||||
if (perm !== 'granted') return;
|
||||
|
||||
const data = await SilentSendSync._getAllData();
|
||||
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
} catch (e) {
|
||||
// Permission denied or folder removed — don't spam errors
|
||||
console.warn('[Silent Send] writeToSyncFile failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFileSyncUpdate() {
|
||||
if (!syncDirHandle) return;
|
||||
try {
|
||||
const perm = await syncDirHandle.queryPermission({ mode: 'readwrite' });
|
||||
if (perm === 'prompt') {
|
||||
// Need a user gesture to re-request — skip silently
|
||||
return;
|
||||
}
|
||||
if (perm !== 'granted') return;
|
||||
|
||||
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME);
|
||||
const file = await fileHandle.getFile();
|
||||
const data = JSON.parse(await file.text());
|
||||
|
||||
if (!data.version || !data.lastModified) return;
|
||||
|
||||
const local = await SilentSendSync._getAllData();
|
||||
if (data.lastModified > (local.lastModified || 0)) {
|
||||
await SilentSendSync._applyData(data, 'file');
|
||||
mappings = await Storage.getMappings();
|
||||
settings = await Storage.getSettings();
|
||||
$('#browserSync').checked = settings.browserSync === true;
|
||||
renderMappings();
|
||||
renderDomains();
|
||||
renderLog();
|
||||
setFileSyncStatus(
|
||||
'Auto-synced from folder (' + new Date(data.lastModified).toLocaleString() + ').',
|
||||
'ok'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'NotFoundError') {
|
||||
console.warn('[Silent Send] checkFileSyncUpdate failed:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileSyncUI() {
|
||||
const nameEl = $('#syncFolderName');
|
||||
const clearBtn = $('#btnClearSyncFolder');
|
||||
const pickBtn = $('#btnPickSyncFolder');
|
||||
if (!nameEl) return;
|
||||
if (syncDirHandle) {
|
||||
nameEl.textContent = syncDirHandle.name + '/';
|
||||
clearBtn.style.display = '';
|
||||
pickBtn.textContent = 'Change Folder';
|
||||
} else {
|
||||
nameEl.textContent = '';
|
||||
clearBtn.style.display = 'none';
|
||||
pickBtn.textContent = 'Choose Sync Folder';
|
||||
}
|
||||
}
|
||||
|
||||
function setFileSyncStatus(msg, type) {
|
||||
const el = $('#fileSyncStatus');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
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