background.js (7129 bytes)
1 // The background of the add-on. It owns the bookmark file, which it keeps in 2 // storage.local as the text of an sbm file, and it syncs that file with an 3 // sbm-sync server (https://github.com/equwal/sbm-sync). The popup and the 4 // options page ask it through messages, so all changes happen here, one at a 5 // time. 6 "use strict"; 7 8 // Chrome runs this file as a service worker; Firefox loads the libraries 9 // through the manifest. 10 if (typeof importScripts === "function") importScripts("lib/tsv.js", "lib/fuzzy.js", "lib/plan.js"); 11 12 const api = globalThis.browser ?? globalThis.chrome; 13 const DEFAULT_SERVER = "https://sbmsync.com"; 14 const SYNC_MINUTES = 30; 15 16 // One change at a time: each job starts when the one before it ends. 17 let queue = Promise.resolve(); 18 function serial(job) { 19 const run = queue.then(job); 20 queue = run.catch(() => {}); 21 return run; 22 } 23 24 async function state() { 25 const s = await api.storage.local.get(["text", "version", "server", "token", "email", "synced", "error"]); 26 return { text: "", version: "", server: DEFAULT_SERVER, ...s }; 27 } 28 29 /** 30 * Send the file with the name of the version that the server gave last 31 * time. The server merges, and the merged file comes back. 32 */ 33 async function syncNow() { 34 const s = await state(); 35 if (!s.token) return s; 36 let r; 37 try { 38 r = await fetch(s.server + "/api/sync?base=" + encodeURIComponent(s.version), { 39 method: "POST", 40 headers: { Authorization: "Bearer " + s.token, "Content-Type": "text/plain; charset=utf-8" }, 41 body: s.text, 42 }); 43 } catch (e) { 44 await api.storage.local.set({ error: "Cannot reach " + new URL(s.server).host + "." }); 45 return state(); 46 } 47 const body = await r.text(); 48 if (r.status === 401) { 49 await api.storage.local.remove(["token", "version"]); 50 await api.storage.local.set({ error: "Sign in again: see the options." }); 51 } else if (!r.ok) { 52 await api.storage.local.set({ error: body.trim() || "The server answered " + r.status + "." }); 53 } else { 54 const version = r.headers.get("Sbm-Version"); 55 if (!version) throw new Error("The server gave no version."); 56 await api.storage.local.set({ text: body, version, synced: Date.now(), error: "" }); 57 } 58 return state(); 59 } 60 61 async function signIn(server, email, password) { 62 server = server.trim().replace(/\/+$/, ""); 63 if (!server.includes("://")) server = "https://" + server; 64 const r = await fetch(server + "/api/login", { 65 method: "POST", 66 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 67 body: new URLSearchParams({ email: email.trim(), password }), 68 }); 69 const body = await r.text(); 70 if (r.status === 401) throw new Error("Wrong email or password."); 71 if (!r.ok) throw new Error(body.trim() || "The server answered " + r.status + "."); 72 // A new account starts from no version: the first sync is a union. 73 await api.storage.local.set({ server, email: email.trim(), token: body.trim(), version: "", error: "" }); 74 return syncNow(); 75 } 76 77 async function signOut() { 78 const s = await state(); 79 await api.storage.local.remove(["token", "version", "synced", "error"]); 80 if (s.token) { 81 fetch(s.server + "/api/logout", { method: "POST", headers: { Authorization: "Bearer " + s.token } }) 82 .catch(() => {}); // the token stays valid on the server; nothing else is lost 83 } 84 return state(); 85 } 86 87 /** The plan of the account and the payments that the server offers, or null. */ 88 async function account() { 89 const s = await state(); 90 if (!s.token) return null; 91 const r = await fetch(s.server + "/api/account", { headers: { Authorization: "Bearer " + s.token } }); 92 // A server from before this API has no such address. Its account page 93 // has the payments. 94 if (r.status === 404) return null; 95 if (!r.ok) throw new Error((await r.text()).trim() || "The server answered " + r.status + "."); 96 return r.json(); 97 } 98 99 /** Ask the server for a page of Stripe, and open that page in a new tab. */ 100 async function billing(path, form) { 101 const s = await state(); 102 const r = await fetch(s.server + path, { 103 method: "POST", 104 headers: { Authorization: "Bearer " + s.token, "Content-Type": "application/x-www-form-urlencoded" }, 105 body: new URLSearchParams(form), 106 }); 107 const body = (await r.text()).trim(); 108 if (!r.ok) throw new Error(body || "The server answered " + r.status + "."); 109 const url = Plan.web(body, ["https:"]); 110 if (!url) throw new Error("The server gave no https address."); 111 await api.tabs.create({ url }); 112 } 113 114 /** Add a bookmark at the end of the file. With the same page there already, return that bookmark. */ 115 async function add(b) { 116 const s = await state(); 117 const existing = Tsv.find(Tsv.parse(s.text), b.url); 118 if (existing) return { existing }; 119 await api.storage.local.set({ text: s.text + Tsv.appendix(s.text, Tsv.format(b)) }); 120 if (s.token) await syncNow(); 121 return { added: true }; 122 } 123 124 api.runtime.onMessage.addListener((msg, sender, reply) => { 125 const jobs = { 126 sync: () => syncNow(), 127 signIn: () => signIn(msg.server, msg.email, msg.password), 128 signOut: () => signOut(), 129 add: () => add(msg.bookmark), 130 account: () => account(), 131 checkout: () => billing("/api/checkout", { plan: msg.plan }), 132 portal: () => billing("/api/portal", {}), 133 }; 134 const job = jobs[msg.type]; 135 if (!job) return false; 136 serial(job).then( 137 (value) => reply({ value }), 138 (e) => reply({ error: e.message }), 139 ); 140 return true; // reply comes later 141 }); 142 143 // ---- the address bar: type "bm", a space, then words ---- 144 145 function escapeXml(s) { 146 return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); 147 } 148 149 async function matches(text) { 150 const all = Tsv.parse((await state()).text).reverse(); // newest first 151 return Fuzzy.filter(all, text, (b) => b.desc + " " + b.tags.join(" ") + " " + b.url); 152 } 153 154 api.omnibox.setDefaultSuggestion({ description: "Search your sbm bookmarks" }); 155 156 api.omnibox.onInputChanged.addListener(async (text, suggest) => { 157 // Chrome shows the description as XML; Firefox shows it as text. 158 const xml = typeof browser === "undefined"; 159 suggest((await matches(text)).slice(0, 6).map((b) => { 160 const d = (b.desc || b.url) + " - " + b.url; 161 return { content: b.url, description: xml ? escapeXml(d) : d }; 162 })); 163 }); 164 165 api.omnibox.onInputEntered.addListener(async (text, disposition) => { 166 // A picked suggestion gives its URL; typed text opens the best match, or 167 // else an address or a web search, as in bm. 168 const best = (await matches(text))[0]; 169 const known = Tsv.find(Tsv.parse((await state()).text), text); 170 const url = known ? known.url : best ? best.url : Tsv.target(text); 171 if (disposition === "currentTab") api.tabs.update({ url }); 172 else api.tabs.create({ url, active: disposition === "newForegroundTab" }); 173 }); 174 175 // ---- sync on start and every half hour ---- 176 177 api.runtime.onStartup.addListener(() => serial(syncNow)); 178 api.runtime.onInstalled.addListener(() => { 179 api.alarms.create("sync", { periodInMinutes: SYNC_MINUTES }); 180 serial(syncNow); 181 }); 182 api.alarms.onAlarm.addListener((a) => { 183 if (a.name === "sync") serial(syncNow); 184 });