#!/usr/bin/env node // ============================================================ // fieldBB COMPANION — Delta Chat, inside fieldBB. // // A small program on the rep's own computer. It is a SECOND DEVICE of their // Delta Chat account (Delta Chat → Settings → Add second device), exactly as // Delta Chat for desktop would be, and it answers the fieldBB Chrome // extension over Chrome's native messaging — nobody else can reach it. // // WHAT IT WILL DO, and all it will do: // hello who it is and whether it is paired // pair become a second device, from the QR code the phone shows // cancel stop a pairing in progress // chats the list of conversations (names, not contents) // messages the messages of ONE conversation, after a cursor, a page at a time // recent the latest messages of one conversation (or those before one) // send a TEXT message into a conversation that already exists // accept accept a contact request, so it can be answered // seen mark messages read — on the phone too, which clears its notification // unpair remove the Delta Chat account from this computer // // It sends only text, only into a conversation already in Delta Chat, and // only when the rep presses Send in fieldBB (37.27 — until then it could not // send at all). It cannot delete, forward, block, add people to a group or // start a conversation with somebody new: those stay in Delta Chat. // // Nothing passes through fieldBB's server. The Delta Chat engine // (deltachat-rpc-server, from npm) talks to the rep's own mail server, keeps // its database in this computer's user folder, and this program hands the // fieldBB page what it asks for. // // node fieldbb-companion.mjs --install set it up // node fieldbb-companion.mjs --uninstall remove it // (Chrome starts it by itself once installed) // ============================================================ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; export const VERSION = 1; export const HOST_NAME = "com.fieldbb.companion"; export const CORE_VERSION = "2.62.0"; // Chrome refuses a message from a host larger than 1 MB. Pages of messages // are cut well below it, measured in the bytes that will actually be sent. export const MAX_OUT = 1024 * 1024; export const PAGE_BYTES = 600 * 1024; export const PAGE_MAX = 300; const TEXT_MAX = 20000; // one message's text, in characters // ---------- where things live ---------- export function homeDir(env = process.env) { if (env.FIELDBB_COMPANION_HOME) return env.FIELDBB_COMPANION_HOME; if (process.platform === "win32") return path.join(env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "fieldbb-companion"); return path.join(os.homedir(), ".fieldbb-companion"); } // ---------- Chrome native messaging: 4-byte length, then JSON ---------- export function encodeFrame(obj) { const body = Buffer.from(JSON.stringify(obj), "utf8"); const head = Buffer.alloc(4); head.writeUInt32LE(body.length, 0); return Buffer.concat([head, body]); } /** Feeds bytes in, calls onMessage for every whole frame; tolerates any split. */ export function frameReader(onMessage, { maxIn = 64 * 1024 * 1024 } = {}) { let buf = Buffer.alloc(0); return (chunk) => { buf = buf.length ? Buffer.concat([buf, chunk]) : chunk; while (buf.length >= 4) { const n = buf.readUInt32LE(0); if (n > maxIn) throw new Error("frame-too-large"); if (buf.length < 4 + n) return; const body = buf.subarray(4, 4 + n); buf = buf.subarray(4 + n); let msg; try { msg = JSON.parse(body.toString("utf8")); } catch { msg = { __bad: true }; } onMessage(msg); } }; } // ---------- what the page may ask ---------- export const OPS = ["hello", "pair", "cancel", "chats", "messages", "recent", "send", "accept", "seen", "unpair"]; export const SEND_MAX = 8000; // characters in one message typed in fieldBB // Delta Chat's message states, as fieldBB shows them (constants.ts, DC_STATE_*). export function stateOf(s) { if (s === 10) return "new"; // IN_FRESH: arrived, not yet seen anywhere if (s === 13 || s === 16) return "in"; // IN_NOTICED, IN_SEEN if (s === 24) return "failed"; // OUT_FAILED if (s === 26) return "sent"; // OUT_DELIVERED if (s === 28) return "read"; // OUT_MDN_RCVD if (s >= 18 && s <= 20) return "pending"; // OUT_PREPARING, OUT_DRAFT, OUT_PENDING return ""; } // The chat list's lastUpdated comes in MILLISECONDS; a message's timestamp in // seconds. Everything this program hands out is seconds. export const toSeconds = (t) => { const n = Number(t) || 0; return n > 1e11 ? Math.floor(n / 1000) : n; }; const clip = (s, n) => (typeof s === "string" ? (s.length > n ? s.slice(0, n) : s) : ""); /** One Delta Chat message, as fieldBB keeps it. Only text and who/when — never the file itself. */ export function shapeMessage(m) { const mine = m.fromId === 1; const sender = m.sender || {}; return { id: m.id, chat: m.chatId, at: m.timestamp, mine, from: mine ? "" : clip(sender.address || "", 200), name: mine ? "" : clip(m.overrideSenderName || sender.displayName || sender.name || sender.address || "", 200), text: clip(m.text || "", TEXT_MAX), kind: m.viewType || "Text", file: m.fileName ? clip(m.fileName, 200) : "", info: !!m.isInfo, edited: !!m.isEdited, quote: m.quote && m.quote.kind === "WithMessage" ? clip(m.quote.text || "", 300) : "", // Delta Chat shortens a very long text and keeps the whole of it as HTML // on the device. The copy says so rather than pass the short one off as all. cut: !!m.hasHtml || (typeof m.text === "string" && m.text.length > TEXT_MAX), st: stateOf(m.state), }; } /** * The operations, over a Delta Chat JSON-RPC client. Separate from the * process plumbing so they can be driven against the real engine in a test. * `requireConfigured: false` is for tests only: an account that never * reached a mail server can still hold chats. */ export function makeApi(rpc, { requireConfigured = true } = {}) { let pairing = null; async function account() { for (const id of await rpc.getAllAccountIds()) { if (!requireConfigured || await rpc.isConfigured(id)) return id; } return null; } async function need() { const id = await account(); if (id == null) throw new Error("not-paired"); return id; } return { async hello() { const info = await rpc.getSystemInfo().catch(() => ({})); const id = await account(); let addr = "", name = ""; if (id != null) { addr = (await rpc.getConfig(id, "configured_addr").catch(() => null)) || (await rpc.getConfig(id, "addr").catch(() => null)) || ""; name = (await rpc.getConfig(id, "displayname").catch(() => null)) || ""; } return { v: VERSION, core: info.deltachat_core_version || "", paired: id != null, pairing: !!pairing, addr, name }; }, async pair({ qr } = {}) { qr = String(qr || "").trim(); if (!qr) throw new Error("no-qr"); if (await account() != null && requireConfigured) throw new Error("already-paired"); if (pairing) throw new Error("pairing-in-progress"); // A fresh account to receive into; removed again if anything fails, so // a failed attempt never leaves a half-made account behind. const id = await rpc.addAccount(); try { const kind = (await rpc.checkQr(id, qr)).kind; if (kind === "backupTooNew") throw new Error("delta-too-new"); if (kind !== "backup2") throw new Error("not-a-backup-qr"); pairing = id; // The phone hands the account over DIRECTLY, over the local network: // when this computer cannot reach it, Delta Chat says "failed // connecting to remote endpoint: timed out". Named, so fieldBB can // say what to check instead of repeating Delta Chat's words. try { await rpc.getBackup(id, qr); } catch (e) { throw new Error(isUnreachable(e) ? "cannot-reach-phone" : String((e && e.message) || e)); } await rpc.startIo(id); } catch (e) { try { await rpc.removeAccount(id); } catch {} throw e; } finally { pairing = null; } return this.hello(); }, async cancel() { if (pairing != null) { try { await rpc.stopOngoingProcess(pairing); } catch {} } return { cancelled: true }; }, async chats() { const id = await need(); const out = []; // The normal list and the archived one: an archived chat is still a // conversation the rep may want kept. for (const flags of [null, 1 /* DC_GCL_ARCHIVED_ONLY */]) { const entries = await rpc.getChatlistEntries(id, flags, null, null); if (!entries.length) continue; const items = await rpc.getChatlistItemsByEntries(id, entries); for (const cid of entries) { const it = items[cid]; if (!it || it.kind !== "ChatListItem" || it.isDeviceTalk) continue; let addr = ""; if (it.dmChatContact != null) { try { addr = (await rpc.getContact(id, it.dmChatContact)).address || ""; } catch {} } out.push({ id: it.id, name: clip(it.name, 200), type: it.chatType, addr: clip(addr, 200), at: toSeconds(it.lastUpdated), archived: !!it.isArchived, request: !!it.isContactRequest, self: !!it.isSelfTalk, fresh: it.freshMessageCounter || 0, muted: !!it.isMuted, // The line the chat list shows, as Delta Chat itself writes it. preview: clip(it.summaryText2 || "", 160), previewFrom: clip(it.summaryText1 || "", 60), }); } } return { chats: out }; }, /** * Messages of one chat with an id above `after`, oldest id first, cut to * fit one native message. A message still downloading ends the page * before it, so the cursor never passes a message not yet whole. */ async messages({ chat, after = 0, limit = PAGE_MAX } = {}) { const id = await need(); chat = Number(chat); if (!Number.isInteger(chat) || chat <= 9) throw new Error("bad-chat"); limit = Math.max(1, Math.min(PAGE_MAX, Number(limit) || PAGE_MAX)); const ids = (await rpc.getMessageIds(id, chat, false, false)).filter((m) => m > after).sort((a, b) => a - b); const want = ids.slice(0, limit); const loaded = want.length ? await rpc.getMessages(id, want) : {}; const messages = []; let bytes = 0, cursor = after, stopped = false; for (const mid of want) { const m = loaded[mid]; if (!m || m.kind !== "message") { cursor = mid; continue; } // gone meanwhile: nothing to keep if (m.downloadState && m.downloadState !== "Done" && m.downloadState !== "Failure") { stopped = true; break; } const shaped = shapeMessage(m); const size = Buffer.byteLength(JSON.stringify(shaped), "utf8"); if (messages.length && bytes + size > PAGE_BYTES) { stopped = true; break; } messages.push(shaped); bytes += size; cursor = mid; } const more = stopped ? messages.length > 0 : ids.length > want.length; return { messages, cursor, more }; }, /** * The latest `limit` messages of a chat — or, with `before`, the ones * before that message — oldest first, cut to fit one native message from * the newest end. What a conversation screen opens on and polls. */ async recent({ chat, before = 0, limit = 60 } = {}) { const id = await need(); chat = Number(chat); if (!Number.isInteger(chat) || chat <= 9) throw new Error("bad-chat"); limit = Math.max(1, Math.min(PAGE_MAX, Number(limit) || 60)); let ids = await rpc.getMessageIds(id, chat, false, false); if (before) { const i = ids.indexOf(Number(before)); ids = i >= 0 ? ids.slice(0, i) : ids.filter((m) => m < before); } const want = ids.slice(-limit); const loaded = want.length ? await rpc.getMessages(id, want) : {}; const messages = []; let bytes = 0; for (const mid of [...want].reverse()) { const m = loaded[mid]; if (!m || m.kind !== "message") continue; const shaped = shapeMessage(m); const size = Buffer.byteLength(JSON.stringify(shaped), "utf8"); if (messages.length && bytes + size > PAGE_BYTES) break; messages.unshift(shaped); bytes += size; } const first = messages.length ? messages[0].id : 0; return { messages, earlier: first ? ids.indexOf(first) > 0 : false }; }, /** Text, into a conversation that exists. Returns the message as it will be shown. */ async send({ chat, text } = {}) { const id = await need(); chat = Number(chat); if (!Number.isInteger(chat) || chat <= 9) throw new Error("bad-chat"); text = String(text || "").replace(/\r\n/g, "\n"); if (!text.trim()) throw new Error("empty"); if (text.length > SEND_MAX) throw new Error("too-long"); const info = await rpc.getBasicChatInfo(id, chat); if (info.isContactRequest) throw new Error("contact-request"); if (info.isDeviceChat) throw new Error("bad-chat"); let mid; try { mid = await rpc.miscSendTextMessage(id, chat, text); } catch (e) { // TESTS ONLY: an account that never reached a mail server stores the // message and then cannot queue it. A real account never gets here. if (requireConfigured || !/No self addr/.test(String(e && e.message))) throw e; const ids = await rpc.getMessageIds(id, chat, false, false); mid = ids[ids.length - 1]; } return { message: shapeMessage(await rpc.getMessage(id, mid)) }; }, async accept({ chat } = {}) { const id = await need(); chat = Number(chat); if (!Number.isInteger(chat) || chat <= 9) throw new Error("bad-chat"); await rpc.acceptChat(id, chat); return { accepted: true }; }, /** Mark messages read. Delta Chat tells the rep's other devices, so the phone's notification goes. */ async seen({ ids } = {}) { const id = await need(); const list = (Array.isArray(ids) ? ids : []).map(Number).filter((n) => Number.isInteger(n) && n > 9).slice(0, 500); if (list.length) await rpc.markseenMsgs(id, list); return { seen: list.length }; }, async unpair() { for (const id of await rpc.getAllAccountIds()) { try { await rpc.stopIo(id); } catch {} await rpc.removeAccount(id); } return { unpaired: true }; }, }; } /** One request in, one answer out. Unknown operations are refused by name. */ export async function handle(api, req) { const id = req && req.id; if (!req || req.__bad || typeof req.op !== "string") return { id, ok: false, error: "bad-request" }; if (!OPS.includes(req.op)) return { id, ok: false, error: "unknown-op" }; try { return { id, ok: true, result: await api[req.op](req.args || {}) }; } catch (e) { return { id, ok: false, error: String((e && e.message) || e).slice(0, 300) }; } } // ---------- running under Chrome ---------- async function serve() { // stdout belongs to Chrome: anything else written there breaks the stream. console.log = console.info = console.warn = (...a) => process.stderr.write(a.join(" ") + "\n"); const here = path.dirname(fileURLToPath(import.meta.url)); const dataDir = path.join(homeDir(), "deltachat"); fs.mkdirSync(dataDir, { recursive: true }); let startDeltaChat; try { ({ startDeltaChat } = await import(pathToFileURL(path.join(here, "node_modules", "@deltachat", "stdio-rpc-server", "index.js")).href) .catch(() => import("@deltachat/stdio-rpc-server"))); } catch { process.stdout.write(encodeFrame({ ok: false, error: "engine-missing" })); process.exit(1); } const dc = startDeltaChat(dataDir, { muteStdErr: true }); const api = makeApi(dc.rpc, { requireConfigured: process.env.FIELDBB_COMPANION_TEST_UNCONFIGURED !== "1" }); // Paired already: connect to the mail server, so messages keep arriving // on this computer while Chrome is open. try { await dc.rpc.startIoForAllAccounts(); } catch {} const send = (obj) => { let frame = encodeFrame(obj); if (frame.length > MAX_OUT) frame = encodeFrame({ id: obj.id, ok: false, error: "too-large" }); process.stdout.write(frame); }; const read = frameReader((req) => { handle(api, req).then(send); }); process.stdin.on("data", read); // Chrome closes the pipe when the extension lets go: stop cleanly. process.stdin.on("end", () => { try { dc.close(); } catch {} process.exit(0); }); } // ---------- installing ---------- /** Delta Chat's ways of saying the phone could not be reached. */ export const isUnreachable = (e) => /remote endpoint|timed out|connection refused|unreachable|no route|network is/i.test(String((e && e.message) || e || "")); /** A Chrome extension id: 32 letters a–p. */ export const isExtensionId = (s) => /^[a-p]{32}$/.test(String(s || "")); export function hostManifest(launcher, extensionId) { return { name: HOST_NAME, description: "fieldBB companion — Delta Chat inside fieldBB", path: launcher, type: "stdio", allowed_origins: ["chrome-extension://" + extensionId + "/"], }; } /** The folders Chrome-family browsers read host manifests from, for this user. */ export function manifestDirs(platform = process.platform, home = os.homedir()) { if (platform === "darwin") { const base = path.join(home, "Library", "Application Support"); return ["Google/Chrome", "Chromium", "BraveSoftware/Brave-Browser", "Microsoft Edge"] .map((b) => ({ browser: path.join(base, b), dir: path.join(base, b, "NativeMessagingHosts") })); } const base = path.join(home, ".config"); return ["google-chrome", "chromium", "BraveSoftware/Brave-Browser", "microsoft-edge"] .map((b) => ({ browser: path.join(base, b), dir: path.join(base, b, "NativeMessagingHosts") })); } const WIN_KEYS = [ "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\", "HKCU\\Software\\Chromium\\NativeMessagingHosts\\", "HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\", "HKCU\\Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts\\", ]; export function install({ extensionId, home = homeDir(), userHome = os.homedir(), platform = process.platform, skipEngine = false, log = console.log } = {}) { if (!isExtensionId(extensionId)) throw new Error("That is not a Chrome extension id (32 letters a–p). Copy it from fieldBB → Messages → Delta Chat."); fs.mkdirSync(home, { recursive: true }); const self = fileURLToPath(import.meta.url); const target = path.join(home, "fieldbb-companion.mjs"); if (!fs.existsSync(target) || !sameFile(self, target)) fs.copyFileSync(self, target); if (!skipEngine) { log("Installing the Delta Chat engine " + CORE_VERSION + " (npm)…"); fs.writeFileSync(path.join(home, "package.json"), JSON.stringify({ name: "fieldbb-companion", private: true, type: "module" }, null, 2)); const npm = platform === "win32" ? "npm.cmd" : "npm"; const r = spawnSync(npm, ["install", "--no-audit", "--no-fund", "--prefix", home, "@deltachat/stdio-rpc-server@" + CORE_VERSION, "@deltachat/jsonrpc-client@" + CORE_VERSION], { stdio: "inherit", shell: platform === "win32" }); if (r.status !== 0) throw new Error("npm install failed. Is Node.js (with npm) installed?"); } // Chrome starts the host with a bare environment: the launcher names this // very node binary, so it does not depend on PATH — and names this folder, // so the companion finds its Delta Chat data wherever it was installed. let launcher; if (platform === "win32") { launcher = path.join(home, "fieldbb-companion.bat"); fs.writeFileSync(launcher, `@echo off\r\nset "FIELDBB_COMPANION_HOME=${home}"\r\n"${process.execPath}" "${target}" %*\r\n`); } else { launcher = path.join(home, "fieldbb-companion"); fs.writeFileSync(launcher, `#!/bin/sh\nFIELDBB_COMPANION_HOME="${home}" exec "${process.execPath}" "${target}" "$@"\n`); fs.chmodSync(launcher, 0o755); } const manifest = JSON.stringify(hostManifest(launcher, extensionId), null, 2); const written = []; if (platform === "win32") { const file = path.join(home, HOST_NAME + ".json"); fs.writeFileSync(file, manifest); for (const k of WIN_KEYS) { const r = spawnSync("reg", ["add", k + HOST_NAME, "/ve", "/t", "REG_SZ", "/d", file, "/f"], { stdio: "ignore" }); if (r.status === 0) written.push(k + HOST_NAME); } } else { const dirs = manifestDirs(platform, userHome); for (const [i, d] of dirs.entries()) { // Chrome's own folder always; the others only where that browser exists. if (i > 0 && !fs.existsSync(d.browser)) continue; fs.mkdirSync(d.dir, { recursive: true }); const file = path.join(d.dir, HOST_NAME + ".json"); fs.writeFileSync(file, manifest); written.push(file); } } return { home, launcher, written }; } export function uninstall({ home = homeDir(), userHome = os.homedir(), platform = process.platform } = {}) { if (platform === "win32") { for (const k of WIN_KEYS) spawnSync("reg", ["delete", k + HOST_NAME, "/f"], { stdio: "ignore" }); } else { for (const d of manifestDirs(platform, userHome)) { try { fs.rmSync(path.join(d.dir, HOST_NAME + ".json"), { force: true }); } catch {} } } // The Delta Chat data goes too: this computer stops being a device. fs.rmSync(home, { recursive: true, force: true }); return { removed: home }; } // ---------- entry ---------- // THE SAME FILE, NOT THE SAME SPELLING. Node names this module by its real // path, links followed; argv[1] is what was typed. On a Mac the installer's // temporary folder is /var/folders/…, really /private/var/folders/…, and a // plain comparison said "not me": --install did nothing, silently, and the // installer printed Done. Windows has its own (C:\Users\CESARL~1 vs the long name). export function sameFile(a, b) { const real = (p) => { try { return fs.realpathSync.native(p); } catch { try { return fs.realpathSync(p); } catch { return path.resolve(p); } } }; const norm = (p) => (process.platform === "win32" ? real(p).toLowerCase() : real(p)); return norm(a) === norm(b); } const invokedDirectly = (() => { try { return !!process.argv[1] && sameFile(process.argv[1], fileURLToPath(import.meta.url)); } catch { return false; } })(); if (invokedDirectly) { const args = process.argv.slice(2); if (args[0] === "--install") { try { const r = install({ extensionId: args[1] }); console.log("\n✓ fieldBB companion installed in " + r.home); for (const w of r.written) console.log(" registered: " + w); console.log("\nfieldBB → Messages → Delta Chat now finds it by itself."); } catch (e) { console.error("✗ " + e.message); process.exit(1); } } else if (args[0] === "--uninstall") { const r = uninstall(); console.log("✓ Removed " + r.removed + " and the browser registrations."); } else if (args[0] === "--version") { console.log("fieldbb-companion " + VERSION + " (Delta Chat " + CORE_VERSION + ")"); } else { // Chrome passes the calling extension's origin as the first argument. serve(); } }