if (window.__orbitmarksOptionsLoaded) { throw new Error("options.js loaded twice"); } window.__orbitmarksOptionsLoaded = true; let config = {}; let status = {}; let pendingPermissionOrigin = null; let pendingGrantCallback = null; function showPermissionPrompt(origin, message, onGrant) { pendingPermissionOrigin = origin; pendingGrantCallback = onGrant; $("permissionMessage").textContent = message; $("permissionPrompt").style.display = "block"; } function hidePermissionPrompt() { $("permissionPrompt").style.display = "none"; pendingPermissionOrigin = null; pendingGrantCallback = null; } $("grantPermissionBtn").addEventListener("click", async () => { if (!pendingPermissionOrigin) return; const granted = await browser.permissions.request({ origins: [pendingPermissionOrigin].concat( // For Bluesky's mushroom PDSes let's avoid asking permissions twice: // - The OAuth token retrieval must be done against bsky.social // - Requests for xrpc are done against the PDS itself pendingPermissionOrigin.endsWith(".bsky.network") ? "https://bsky.social/oauth/*" : [], ), }); if (granted) { const cb = pendingGrantCallback; hidePermissionPrompt(); if (cb) cb(); } else { showStatus("Permission denied. Grant it and try again.", "err"); } }); /** * Initializes the options page: loads config and status from background, fills form fields. * @returns {Promise} */ async function init() { config = await browser.runtime.sendMessage({ action: "get-config" }); status = await browser.runtime.sendMessage({ action: "get-status" }); const redirectUri = (() => { const url = new URL(browser.identity.getRedirectURL()); const subdomain = url.hostname.split(".")[0]; return `http://127.0.0.1/mozoauth2/${subdomain}`; })(); $("redirectUriPlaceholder").textContent = redirectUri; const handleInput = $("handle"); if (config.handle) { handleInput.value = config.handle; } else { handleInput.focus(); } $("customClientId").addEventListener("click", () => { $("clientIdField").hidden = !$("clientIdField").hidden; }); $("clientId").value = config.clientId || ""; $("syncInterval").value = String(config.syncInterval || 5); $("syncOnDelete").checked = !!config.syncOnDelete; if (config.did && config.pdsUrl) { await updateAvatar(config.handle); showResolved(config.did, config.pdsUrl); } updateAuthStatus(); updateRootStatus(config.syncRootId); } /** * Shorthand for document.getElementById. * @param {string} id - Element ID * @returns {HTMLElement} */ function $(id) { return document.getElementById(id); } /** * Displays a status message in the options page. * @param {string} msg - The message text * @param {string} type - CSS class: "ok", "err", or "info" * @returns {void} */ function showStatus(msg, type) { const el = $("status"); el.textContent = msg; el.className = `status ${type}`; if (type !== "info") { setTimeout(() => { el.className = "status"; }, 6000); } } /** * Shows the resolved DID and PDS URL in the UI. * @param {string} did - Resolved DID string * @param {string} pdsUrl - Resolved PDS URL * @returns {void} */ function showResolved(did, pdsUrl) { $("resolveResult").style.display = "block"; $("resolvedDid").textContent = did; $("resolvedPdsUrl").textContent = pdsUrl; } /** * Hides the resolved DID/PDS URL display. * @returns {void} */ function hideResolved() { $("resolveResult").style.display = "none"; } /** * Updates the avatar image with current user's. * @param {string} handle - User handle */ async function updateAvatar(handle) { const url = new URL(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${handle}`); const response = await fetch(url, { headers: { Authorization: "Bearer " } }); const root = response.ok ? await response.json() : {}; const img = $("avatar"); if (root.avatar) { img.alt = `Avatar for user ${handle}`; img.src = root.avatar; } else { img.alt = "Atmosphere Account logo"; img.src = "/icons/atmosphere.svg"; } } /** * Updates the authentication status indicator (dot + label) and button visibility. * @returns {void} */ function updateAuthStatus() { const dot = $("authDot"); const label = $("authLabel"); if (status.authenticated) { dot.className = "dot ok"; label.textContent = "Connected"; $("connectBtn").style.display = "none"; $("disconnectBtn").style.display = ""; } else if (config.handle && config.clientId) { dot.className = "dot off"; label.textContent = "Not authenticated"; $("connectBtn").style.display = ""; $("disconnectBtn").style.display = "none"; } else { dot.className = "dot off"; label.textContent = "Enter your handle and Client ID first"; $("connectBtn").style.display = ""; $("disconnectBtn").style.display = "none"; } } /** * Updates the sync root status display by fetching the folder title from bookmarks API. * @param {string|null} rootId - The sync root folder ID, or null * @returns {Promise} */ async function updateRootStatus(rootId) { if (!rootId) { $("rootStatus").value = "⚠️ No sync root configured. Synced folders will appear under this root."; return; } try { const node = await browser.bookmarks.get(rootId); $("rootStatus").value = `📂 ${node[0].title} (ID: ${rootId})`; } catch { $("rootStatus").value = `Sync root ID ${rootId} not found (may have been deleted).`; } } /** * Resolves the entered handle via background script and persists the result. * If a host permission is needed (handle domain, did:web, or PDS URL), * shows a Grant Access button — the click handler calls permissions.request() * in a synchronous user-gesture context, avoiding Firefox's restriction on * async Promise chains. * @returns {Promise} */ async function doResolve() { const handle = $("handle").value.trim(); if (!handle) { showStatus("Enter a handle first.", "err"); return; } const btn = $("resolveBtn"); btn.disabled = true; btn.textContent = "Resolving..."; showStatus(""); hideResolved(); hidePermissionPrompt(); try { const result = await browser.runtime.sendMessage({ action: "resolve-handle", handle }); if (result.status === "needs_permission") { btn.disabled = false; btn.textContent = "Resolve"; showPermissionPrompt( result.origin, `Grant access to ${result.origin.replace("/*", "")} to resolve this handle.`, () => doResolve(), ); return; } if (result.status === "ok") { const pdsOrigin = new URL(result.pdsUrl).origin + "/*"; const hasPermission = await browser.permissions.contains({ origins: [pdsOrigin] }); if (!hasPermission) { btn.disabled = false; btn.textContent = "Resolve"; showPermissionPrompt( pdsOrigin, `Grant access to ${result.pdsUrl} to sync bookmarks with this PDS.`, () => finalizeResolve(handle, result.did, result.pdsUrl), ); return; } await finalizeResolve(handle, result.did, result.pdsUrl); } else { showStatus(`Resolution failed: ${result.message}`, "err"); } } catch (err) { showStatus(`Resolution error: ${err.message}`, "err"); } finally { btn.disabled = false; btn.textContent = "Resolve"; } } /** * Saves the resolved config and updates the UI. * @param {string} handle * @param {string} did * @param {string} pdsUrl */ async function finalizeResolve(handle, did, pdsUrl) { showResolved(did, pdsUrl); await updateAvatar(handle); await browser.runtime.sendMessage({ action: "save-config", config: { handle, did, pdsUrl }, }); config.handle = handle; config.did = did; config.pdsUrl = pdsUrl; showStatus(`Resolved: ${did}`, "ok"); } /** * Initiates OAuth authentication flow via background script. * @returns {Promise} */ async function doConnect() { const clientId = $("clientId").value.trim(); if (!config.pdsUrl || !config.did) { showStatus("Resolve your handle first.", "err"); return; } if (!clientId) { showStatus("Enter your OAuth Client ID.", "err"); return; } await browser.runtime.sendMessage({ action: "save-config", config: { clientId }, }); config.clientId = clientId; const btn = $("connectBtn"); btn.disabled = true; btn.textContent = "Connecting..."; showStatus(""); try { const result = await browser.runtime.sendMessage({ action: "authenticate" }); if (result.status === "needs_permission") { btn.disabled = false; btn.textContent = "Connect to PDS"; showPermissionPrompt( result.origin, result.message || `Grant access to ${result.origin.replace("/*", "")} to authenticate.`, () => doConnect(), ); return; } if (result.status === "ok") { status = await browser.runtime.sendMessage({ action: "get-status" }); updateAuthStatus(); showStatus("Connected successfully!", "ok"); } else { showStatus(`Authentication failed: ${result.message}`, "err"); } } catch (err) { showStatus(`Authentication error: ${err.message}`, "err"); } finally { btn.disabled = false; btn.textContent = "Connect to PDS"; } } /** * Disconnects from the PDS by clearing tokens via background script. * @returns {Promise} */ async function doDisconnect() { if (!confirm("Disconnect from PDS? This will clear your authentication tokens.")) return; const result = await browser.runtime.sendMessage({ action: "disconnect" }); if (result.status === "ok") { config.accessToken = ""; config.refreshToken = ""; status.authenticated = false; updateAuthStatus(); showStatus("Disconnected.", "info"); } } /** * Creates a new sync root folder titled "OrbitMarks" at the bookmarks root. * @returns {Promise} */ async function createRoot() { const result = await browser.runtime.sendMessage({ action: "set-sync-root", title: "OrbitMarks", }); if (result.status === "ok") { showStatus(`Created sync root folder: "${result.folderTitle}"`, "ok"); updateRootStatus(result.folderId); } else { showStatus(`Error: ${result.message}`, "err"); } } /** * Opens an inline folder picker showing the full bookmarks tree for selecting an existing folder as sync root. * @returns {Promise} */ async function chooseRoot() { const tree = await browser.bookmarks.getTree(); const folders = []; function walk(nodes, depth, prefix) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.type === "folder" || node.type === "menu") { const isLast = i === nodes.length - 1; const displayPrefix = depth === 1 ? "" : prefix + (isLast ? "└── " : "├── "); folders.push({ id: node.id, title: node.title, prefix: displayPrefix }); if (node.children) { const childPrefix = depth === 1 ? (isLast ? " " : "│ ") : prefix + (isLast ? " " : "│ "); walk(node.children, depth + 1, childPrefix); } } } } walk(tree, 0, ""); $("rootStatus").hidden = true; const picker = document.createElement("select"); picker.style.width = "100%"; picker.innerHTML = ''; for (const f of folders) { const opt = document.createElement("option"); opt.value = f.id; opt.textContent = f.prefix + (f.title || "(unnamed)"); picker.appendChild(opt); } const confirmBtn = document.createElement("button"); confirmBtn.textContent = "Confirm"; confirmBtn.className = "primary"; const container = document.createElement("div"); container.className = "input-group"; container.appendChild(picker); container.appendChild(confirmBtn); $("rootStatus").after(container); confirmBtn.addEventListener("click", async () => { if (!picker.value) { showStatus("Please select a folder.", "err"); return; } const result = await browser.runtime.sendMessage({ action: "set-sync-root", folderId: picker.value, }); if (result.status === "ok") { showStatus(`Sync root set to: "${result.folderTitle}"`, "ok"); updateRootStatus(result.folderId); } else { showStatus(`Error: ${result.message}`, "err"); } $("rootStatus").hidden = false; container.remove(); }); } /** * Clears the sync root reference (does not delete the actual bookmarks folder). * @returns {Promise} */ async function clearRoot() { if (!confirm("Remove the sync root reference? The bookmarks folder will remain in your tree.")) return; const result = await browser.runtime.sendMessage({ action: "clear-sync-root" }); if (result.status === "ok") { showStatus("Sync root cleared.", "ok"); updateRootStatus(null); } } /** * Triggers a manual sync and displays the result. * @returns {Promise} */ async function syncNow() { const result = await browser.runtime.sendMessage({ action: "sync-now" }); if (result.status === "ok") { showStatus(`Sync complete. ${result.changes} changes applied.`, "ok"); } else if (result.status === "partial") { showStatus(`Sync completed with ${result.errors.length} error(s). Check console.`, "err"); } else { showStatus(`Sync failed: ${result.message}`, "err"); } } /** * Exports session logs to a downloadable text file. * @returns {Promise} */ async function exportLogs() { $("exportLogsBtn").disabled = true; try { const result = await browser.runtime.sendMessage({ action: "get-logs" }); if (result.logs) { const blob = new Blob([result.logs], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `orbitmarks-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showStatus("Logs exported successfully.", "ok"); } else { showStatus("No logs available.", "info"); } } catch (err) { showStatus(`Export failed: ${err.message}`, "err"); } finally { $("exportLogsBtn").disabled = false; } } /** * Clears all session logs from the background script. * @returns {Promise} */ async function clearLogs() { if (!confirm("Clear all session logs? This cannot be undone.")) return; try { await browser.runtime.sendMessage({ action: "clear-logs" }); showStatus("Session logs cleared.", "info"); } catch (err) { showStatus(`Failed to clear logs: ${err.message}`, "err"); } } $("resolveBtn").addEventListener("click", doResolve); $("connectBtn").addEventListener("click", doConnect); $("disconnectBtn").addEventListener("click", doDisconnect); $("createRoot").addEventListener("click", createRoot); $("chooseRoot").addEventListener("click", chooseRoot); $("clearRoot").addEventListener("click", clearRoot); $("syncNow").addEventListener("click", syncNow); $("exportLogsBtn").addEventListener("click", exportLogs); $("clearLogsBtn").addEventListener("click", clearLogs); init();