// Connect Desk — one window, many Amazon Connect logins. // // The problem this solves: all accounts live on the SAME Connect domain, so a // normal browser profile can hold exactly one session — logging in as account B // evicts account A. Here each account gets its own Electron session partition, // i.e. its own cookie jar, so all of them stay signed in at once. // // Each account is a TOP-LEVEL WebContentsView, not an iframe. That matters: // Connect sends `Content-Security-Policy: frame-ancestors 'self'`, which only // restricts documents embedded in a frame. A top-level document isn't one, so // nothing here needs an approved origin or any AWS permission. const { app, BaseWindow, WebContentsView, Notification, ipcMain, shell, dialog, Menu, safeStorage, webFrameMain } = require("electron"); const path = require("node:path"); const fs = require("node:fs"); const { probeScript } = require("./probe"); const SIDEBAR_W = 268; let win; let chrome; // the sidebar view const views = new Map(); // account id -> WebContentsView let activeId = null; // ---------- persisted config (labels only — never credentials) ---------- const configPath = () => path.join(app.getPath("userData"), "accounts.json"); // The two Amazon Connect instances. Every account belongs to exactly one — // they are separate directories with separate logins. const PRESET_INSTANCES = [ { id: "us-ca", label: "US / CA", short: "US", url: "https://amazon-product-support-a67d8h.my.connect.aws/agent-app-v2", }, { id: "eu", label: "UK / EU (UK DE FR IT ES NL BE SE)", short: "EU", url: "https://amazon-product-support-q42kxm.my.connect.aws/agent-app-v2", }, ]; const DEFAULTS = { v: 3, instances: structuredClone(PRESET_INSTANCES), accounts: [{ id: "acct-1", fallback: "Account 1", instanceId: "us-ca" }], }; function loadConfig() { let cfg; try { cfg = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(configPath(), "utf8")) }; } catch { cfg = structuredClone(DEFAULTS); } let dirty = false; // v1 -> v2: labels written before rename worked were placeholders, not user // choices. Demote them to `fallback` so login auto-detection can supersede // them. A real rename after this sets `label`, which always wins. if (!cfg.v) { for (const a of cfg.accounts) { if (a.label) { a.fallback = a.label; delete a.label; } } cfg.v = 2; dirty = true; } // v2 -> v3: one global instanceUrl becomes per-account instances. Existing // accounts were all on the US/CA instance (same domain), so their saved // sessions carry over untouched — partitions are keyed by account id. if (cfg.v < 3) { delete cfg.instanceUrl; for (const a of cfg.accounts) if (!a.instanceId) a.instanceId = "us-ca"; cfg.v = 3; dirty = true; } // Seed / repair the instance list without clobbering user edits to URLs. if (!Array.isArray(cfg.instances) || !cfg.instances.length) { cfg.instances = structuredClone(PRESET_INSTANCES); dirty = true; } for (const p of PRESET_INSTANCES) { if (!cfg.instances.find((i) => i.id === p.id)) { cfg.instances.push(structuredClone(p)); dirty = true; } } // Scrub agent names that were scraped off login-form furniture by the old // detector ("Remember username" is not a person). for (const a of cfg.accounts) { if (a.agentName && /remember|username|password|sign\s?in|log\s?in/i.test(a.agentName)) { delete a.agentName; dirty = true; } } if (dirty) { try { fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2)); } catch {} } return cfg; } function instanceFor(acct) { return config.instances.find((i) => i.id === acct?.instanceId) || config.instances[0]; } function urlFor(acct) { return instanceFor(acct).url; } // ---------- saved sign-ins (the only place credentials are handled) ---------- // // Passwords are encrypted with the OS keychain-backed key (safeStorage: macOS // Keychain / Windows DPAPI) and stored as ciphertext in credentials.json — // never plaintext, never inside accounts.json, and undecryptable on any other // machine or OS user. Decryption happens only in this process, only at fill // time, and only for a view sitting on the account's real Amazon login host. const credsPath = () => path.join(app.getPath("userData"), "credentials.json"); let creds = {}; // accountId -> { username, passEnc(base64), autoLogin } function loadCreds() { try { creds = JSON.parse(fs.readFileSync(credsPath(), "utf8")); } catch { creds = {}; } } function saveCreds() { fs.writeFileSync(credsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); } // The hosts a credential may ever be released to: the instance's own domain // and its awsapps.com login twin. Nothing else, no matter what the view shows. function loginHostsFor(acct) { try { const host = new URL(instanceFor(acct).url).hostname; return [host, `${host.split(".")[0]}.awsapps.com`]; } catch { return []; } } // A wrong password must not be hammered into Amazon until the account locks: // at most 2 automatic attempts per account per 5 minutes. const fillAttempts = new Map(); ipcMain.handle("login:request", (event) => { const entry = [...views.entries()].find(([, v]) => v.webContents.id === event.sender.id); if (!entry) return null; const [accountId, view] = entry; const acct = config.accounts.find((a) => a.id === accountId); const cred = creds[accountId]; if (!acct || !cred?.passEnc || cred.autoLogin === false) return null; // A deliberate sign-out stays signed out — otherwise auto-login would undo // it the moment the login page appears. "Sign in" / "Sign in all" clears it. if (acct.stayOut) return null; let pageHost = ""; try { pageHost = new URL(view.webContents.getURL()).hostname; } catch {} if (!loginHostsFor(acct).includes(pageHost)) return null; const now = Date.now(); const recent = (fillAttempts.get(accountId) || []).filter((t) => now - t < 5 * 60e3); if (recent.length >= 2) return null; recent.push(now); fillAttempts.set(accountId, recent); if (!safeStorage.isEncryptionAvailable()) return null; try { return { username: cred.username || "", password: safeStorage.decryptString(Buffer.from(cred.passEnc, "base64")), }; } catch { return null; } }); ipcMain.handle("creds:list", () => config.accounts.map((a) => ({ id: a.id, display: a.label || a.agentName || a.fallback || a.id, instanceShort: instanceFor(a).short, username: creds[a.id]?.username || "", hasPassword: !!creds[a.id]?.passEnc, })) ); ipcMain.handle("creds:save", (_e, entries, opts = {}) => { if (!safeStorage.isEncryptionAvailable()) { return { ok: false, error: "This OS user has no encryption keystore — sign-ins were NOT saved." }; } let saved = 0; for (const en of entries || []) { if (!config.accounts.find((a) => a.id === en.id)) continue; if (en.clear) { delete creds[en.id]; continue; } const username = (en.username || "").trim(); const password = typeof en.password === "string" ? en.password : ""; const cur = creds[en.id] || {}; if (!username && !password && !cur.passEnc) continue; creds[en.id] = { username: username || cur.username || "", passEnc: password ? safeStorage.encryptString(password).toString("base64") : cur.passEnc || "", autoLogin: true, }; if (creds[en.id].passEnc) saved++; } saveCreds(); // Saving alone never signs anything in — Save and Sign in are separate // actions. It only refreshes the attempt budget so a corrected password can // be tried immediately. for (const en of entries || []) fillAttempts.delete(en.id); pushState(); return { ok: true, saved }; }); function saveConfig(cfg) { fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2)); } let config = null; // ---------- layout ---------- function layout() { if (!win) return; const { width, height } = win.getContentBounds(); chrome.setBounds({ x: 0, y: 0, width: SIDEBAR_W, height }); const rest = { x: SIDEBAR_W, y: 0, width: Math.max(0, width - SIDEBAR_W), height }; for (const [id, v] of views) { if (id === activeId) { v.setBounds(rest); } else { // Keep a full-size surface so the CCP keeps running, but park it off // the window. On Windows, setVisible(false) WebContentsViews still // steal mouse hits (All accounts buttons look dead) and can stall // IPC/JS so Available / Offline / Sign in never reach the page. v.setBounds({ x: -rest.width - 80, y: 0, width: rest.width, height: rest.height }); } v.setVisible(true); } // Account views are added after the sidebar, so re-stack chrome on top. try { win.contentView.addChildView(chrome); } catch {} if (process.env.CD_DEBUG) { console.log("[layout]", JSON.stringify({ content: win.getContentBounds(), sidebar: chrome.getBounds(), pane: rest })); } } // ---------- account views ---------- function createAccountView(acct) { const view = new WebContentsView({ webPreferences: { // The whole point: a dedicated, persistent cookie jar per account. partition: `persist:${acct.id}`, preload: path.join(__dirname, "preload.js"), // Without this, Electron throttles hidden views and background accounts // would stop receiving chats — which would defeat the purpose. backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, // Agent Workspace keeps Streams (`window.connect`) in a CCP iframe. // Preload + probe must run there or incoming chats are invisible to us. nodeIntegrationInSubFrames: true, spellcheck: true, }, }); const wc = view.webContents; enableSpellcheck(wc.session); attachCcpContextMenu(wc); injectProbeOnFrames(wc); // Keep auth redirects inside this account's partition. Anything genuinely // external goes to the real browser. wc.setWindowOpenHandler(({ url }) => { if (/\.(my\.connect\.aws|awsapps\.com|amazonaws\.com)/.test(new URL(url).hostname)) { return { action: "allow" }; } shell.openExternal(url); return { action: "deny" }; }); wc.on("page-title-updated", () => pushState()); wc.on("did-finish-load", () => { retries.delete(acct.id); pushState(); }); // Loading many CCPs at once can time one out. Retry with backoff rather than // leaving a dead panel that needs a manual reload. wc.on("did-fail-load", (_e, code, desc, url, isMainFrame) => { if (code === -3 || !isMainFrame) return; // -3 = aborted, usually a redirect const n = (retries.get(acct.id) || 0) + 1; retries.set(acct.id, n); send("account:error", { id: acct.id, message: `${desc} (${code})`, attempt: n }); if (n <= 4) { setTimeout(() => { if (views.has(acct.id) && !wc.isDestroyed()) wc.loadURL(urlFor(acct)); }, n * 3000); } pushState(); }); // Park off-screen until activate()/layout() places it. Do not use // setVisible(false) — on Windows that view still eats sidebar clicks. view.setBounds({ x: -2000, y: 0, width: 800, height: 600 }); view.setVisible(true); win.contentView.addChildView(view); views.set(acct.id, view); lockZoom(wc, acct.id); try { win.contentView.addChildView(chrome); } catch {} return view; } const retries = new Map(); function enableSpellcheck(ses) { try { ses.setSpellCheckerEnabled(true); } catch {} try { const available = ses.availableSpellCheckerLanguages || []; const want = ["en-US", "en-GB"].filter((l) => !available.length || available.includes(l)); if (want.length) ses.setSpellCheckerLanguages(want); } catch {} } function attachCcpContextMenu(wc) { wc.on("context-menu", (event, params) => { event.preventDefault?.(); const template = []; for (const suggestion of params.dictionarySuggestions || []) { template.push({ label: suggestion, click: () => wc.replaceMisspelling(suggestion), }); } if (params.misspelledWord) { if (template.length) template.push({ type: "separator" }); template.push({ label: "Add to Dictionary", click: () => wc.session.addWordToSpellCheckerDictionary(params.misspelledWord), }); } if (template.length) template.push({ type: "separator" }); const flags = params.editFlags || {}; template.push( { role: "cut", enabled: !!flags.canCut }, { role: "copy", enabled: !!flags.canCopy }, { role: "paste", enabled: !!flags.canPaste }, { role: "selectAll", enabled: flags.canSelectAll !== false } ); Menu.buildFromTemplate(template).popup(); }); } function injectProbeOnFrames(wc) { const inject = (frame) => { try { if (!frame || frame.isDestroyed?.()) return; frame.executeJavaScript(probeScript).catch(() => {}); } catch {} }; wc.on("did-frame-finish-load", (_e, _isMainFrame, frameProcessId, frameRoutingId) => { try { inject(webFrameMain.fromId(frameProcessId, frameRoutingId)); } catch {} }); } function sendToAllFrames(wc, channel, ...args) { if (!wc || wc.isDestroyed()) return; // Always hit the top frame — webContents.send is reliable on Windows. // frame.send to CCP iframes is extra; if it no-ops we still have the // preload's postMessage forward into same-origin iframes. try { wc.send(channel, ...args); } catch {} try { const frames = wc.mainFrame?.framesInSubtree || []; const seen = new Set(); for (const frame of frames) { try { if (!frame || frame.isDestroyed?.()) continue; const key = `${frame.processId}:${frame.routingId}`; if (seen.has(key)) continue; seen.add(key); frame.send(channel, ...args); } catch {} } } catch {} } function bringToFront() { if (!win) return; if (win.isMinimized()) win.restore(); win.show(); try { win.moveTop(); } catch {} if (process.platform === "darwin") app.dock?.bounce("informational"); } // The sidebar and the CCPs are fixed layouts — any zoom (trackpad pinch, or a // stray Cmd+= from browser muscle memory) scales the page past its view and // clips it. Worse, Electron persists zoom per origin, so it survives restarts. // Lock every webContents at 100%. function lockZoom(wc, tag) { const reset = () => { const z = wc.getZoomFactor(); if (z !== 1 && process.env.CD_DEBUG) console.log(`[zoom] ${tag} was ${z}, resetting`); wc.setZoomFactor(1); try { const p = wc.setVisualZoomLevelLimits(1, 1); // disables pinch zoom p?.catch?.(() => {}); } catch {} }; wc.on("did-finish-load", reset); wc.on("zoom-changed", reset); // ctrl/cmd + mouse-wheel zoom } // Start the accounts a beat apart so a dozen CCPs don't all hit the same host // in the same instant. function loadStaggered(ids, gap = 1200) { ids.forEach((id, i) => { setTimeout(() => { const wc = views.get(id)?.webContents; const acct = config.accounts.find((a) => a.id === id); if (wc && !wc.isDestroyed()) wc.loadURL(urlFor(acct)); }, i * gap); }); } function activate(id) { if (!views.has(id)) return; clearReplyAlerts(id); activeId = id; layout(); pushState(); } function clearReplyAlerts(accountId) { for (const key of [...alerts.keys()]) { const a = alerts.get(key); if (a && a.accountId === accountId && a.kind === "reply") alerts.delete(key); } } function lookingAt(accountId) { return activeId === accountId && win && !win.isDestroyed() && win.isFocused(); } function hasWaiting(accountId) { for (const a of alerts.values()) { if (a.accountId === accountId && a.kind === "waiting") return true; } return false; } function stopFlash() { try { win?.flashFrame?.(false); } catch {} } function announceIncoming(accountId, contactId, queue) { if (hasWaiting(accountId)) return; const name = displayName(accountId); const key = `${accountId}:${contactId}`; alerts.set(key, { accountId, label: name, contactId, kind: "waiting", queue: queue || "", }); try { const n = new Notification({ title: `Incoming chat — ${name}`, body: queue ? `Queue: ${queue}` : "A customer is waiting. Click to open.", silent: false, }); n.on("click", () => { bringToFront(); activate(accountId); }); n.show(); } catch {} try { shell.beep(); } catch {} bringToFront(); try { win?.flashFrame?.(true); } catch {} if (activeId !== accountId) activate(accountId); toast(`Incoming chat — ${name}`); pushState(); } const INCOMING_PAGE_JS = `(() => { try { return /incoming chat|accept chat|reject chat/i.test((document.body && document.body.innerText) || ""); } catch (e) { return false; } })()`; function pollIncomingPages() { for (const [id, view] of views) { const wc = view.webContents; if (!wc || wc.isDestroyed() || wc.isLoading()) continue; wc.executeJavaScript(INCOMING_PAGE_JS, true) .then((hit) => { const key = `${id}:dom-incoming`; if (hit) announceIncoming(id, "dom-incoming", ""); else if (alerts.get(key)?.contactId === "dom-incoming") { alerts.delete(key); if (![...alerts.values()].some((a) => a.kind === "waiting")) stopFlash(); pushState(); } }) .catch(() => {}); } } function removeAccount(id) { const v = views.get(id); if (v) { win.contentView.removeChildView(v); v.webContents.close(); views.delete(id); } live.delete(id); for (const key of [...alerts.keys()]) if (key.startsWith(`${id}:`)) alerts.delete(key); delete creds[id]; saveCreds(); fillAttempts.delete(id); config.accounts = config.accounts.filter((a) => a.id !== id); saveConfig(config); if (activeId === id) { const next = config.accounts[0]; activeId = null; if (next) activate(next.id); } pushState(); } // ---------- state -> sidebar ---------- const alerts = new Map(); // `${accountId}:${contactId}` -> {accountId, label, contactId, queue} const live = new Map(); // accountId -> {stateName, stateType, signedIn} (runtime only) function send(channel, payload) { if (chrome && !chrome.webContents.isDestroyed()) chrome.webContents.send(channel, payload); } function accountBadge(id) { let n = 0; for (const a of alerts.values()) { if (a.accountId !== id) continue; n += a.kind === "reply" ? a.count || 1 : 1; } return n; } function pushState() { send("state", { instances: config.instances.map(({ id, label, short }) => ({ id, label, short })), activeId, accounts: config.accounts.map((a) => { const wc = views.get(a.id)?.webContents; const lv = live.get(a.id) || {}; const inst = instanceFor(a); return { ...a, // What the sidebar shows: a manual rename always wins, otherwise the // agent name detected after login, otherwise the placeholder. display: a.label || a.agentName || a.fallback || a.id, instanceId: inst.id, instanceShort: inst.short, instanceLabel: inst.label, loading: wc ? wc.isLoading() : false, title: wc && !wc.isDestroyed() ? wc.getTitle() : "", waiting: accountBadge(a.id), stateName: lv.stateName || "", stateType: lv.stateType || "", signedIn: lv.signedIn, }; }), alerts: [...alerts.values()], }); let total = 0; for (const a of alerts.values()) total += a.kind === "reply" ? a.count || 1 : 1; if (process.platform === "darwin") app.dock?.setBadge(total ? String(total) : ""); } // ---------- IPC ---------- ipcMain.handle("get-state", () => pushState()); ipcMain.handle("activate", (_e, id) => activate(id)); ipcMain.handle("reload", (_e, id) => views.get(id)?.webContents.reload()); ipcMain.handle("add-account", (_e, instanceId) => { const id = `acct-${Date.now().toString(36)}`; const inst = config.instances.find((i) => i.id === instanceId) || config.instances[0]; // No name is asked for up front — it fills itself in from the agent name once // you sign in, and can be overridden any time. const acct = { id, fallback: `Account ${config.accounts.length + 1}`, instanceId: inst.id, }; config.accounts.push(acct); saveConfig(config); createAccountView(acct); loadStaggered([id], 0); activate(id); return id; }); async function confirmRemoveAccount(id) { const a = config.accounts.find((x) => x.id === id); const name = a?.label || a?.agentName || a?.fallback || id; const { response } = await dialog.showMessageBox(win, { type: "warning", buttons: ["Cancel", "Remove"], defaultId: 0, cancelId: 0, message: `Remove "${name}"?`, detail: "Its saved session is discarded, so you'll need to sign in again to use it.", }); if (response === 1) removeAccount(id); } ipcMain.handle("remove-account", (_e, id) => confirmRemoveAccount(id)); ipcMain.handle("rename-account", (_e, { id, label }) => { const a = config.accounts.find((x) => x.id === id); if (!a) return; const next = (label || "").trim(); // Clearing the name hands control back to auto-detection. if (next) a.label = next; else delete a.label; saveConfig(config); pushState(); }); // Move an account to the other Connect instance. Its partition (and any saved // session cookies for either domain) stays intact — only the loaded URL changes. function setAccountInstance(id, instanceId) { const a = config.accounts.find((x) => x.id === id); const inst = config.instances.find((i) => i.id === instanceId); if (!a || !inst || a.instanceId === inst.id) return; a.instanceId = inst.id; delete a.agentName; // the old instance's agent identity no longer applies saveConfig(config); live.delete(id); const wc = views.get(id)?.webContents; if (wc && !wc.isDestroyed()) wc.loadURL(inst.url); pushState(); } ipcMain.handle("set-account-instance", (_e, { id, instanceId }) => setAccountInstance(id, instanceId)); // Fired by preload.js when an account sees a contact arrive or leave, or once // it works out which agent is signed in. ipcMain.on("contact", (event, msg) => { const entry = [...views.entries()].find(([, v]) => v.webContents.id === event.sender.id) || [...views.entries()].find(([, v]) => v.webContents === event.sender); if (!entry) return; const [accountId] = entry; if (msg.type === "agent") { fillAttempts.delete(accountId); // signed in — reset the auto-fill budget live.set(accountId, { ...(live.get(accountId) || {}), signedIn: true }); const a = config.accounts.find((x) => x.id === accountId); if (a && msg.name && a.agentName !== msg.name) { a.agentName = msg.name; saveConfig(config); } pushState(); return; } if (msg.type === "state") { live.set(accountId, { ...(live.get(accountId) || {}), stateName: msg.stateName, stateType: msg.stateType, signedIn: true, }); pushState(); return; } if (msg.type === "auth") { live.set(accountId, { ...(live.get(accountId) || {}), signedIn: msg.signedIn }); pushState(); return; } if (msg.type === "status-result") { handleStatusResult(accountId, msg); return; } if (msg.type === "autofill-failed") { // Nothing was submitted, so no auth attempt was spent. Say so plainly // rather than leaving the account sitting on a filled-but-idle form. toast(`${displayName(accountId)}: auto sign-in couldn't fill the form (${msg.reason}) — sign in manually`); return; } if (msg.type === "detection") return; const name = displayName(accountId); const key = `${accountId}:${msg.contactId}`; if (msg.type === "accepted") { const cur = alerts.get(key); if (cur && cur.kind !== "reply") alerts.delete(key); pushState(); return; } if (msg.type === "reply") { if (lookingAt(accountId)) return; const prev = alerts.get(key); const count = (prev && prev.kind === "reply" ? prev.count || 1 : 0) + 1; alerts.set(key, { accountId, label: name, contactId: msg.contactId, kind: "reply", queue: "New message", text: msg.text || "New message", count, }); const n = new Notification({ title: `New message — ${name}`, body: msg.text || "A customer replied.", }); n.on("click", () => { bringToFront(); activate(accountId); }); n.show(); if (process.platform === "darwin") app.dock?.bounce("informational"); toast(`New message — ${name}`); pushState(); return; } if (msg.type === "incoming") { announceIncoming(accountId, msg.contactId, msg.queue || ""); return; } else if (msg.type === "gone") { alerts.delete(key); if (![...alerts.values()].some((a) => a.kind === "waiting")) stopFlash(); } pushState(); }); ipcMain.handle("focus-alert", (_e, accountId) => activate(accountId)); ipcMain.on("show-account-menu", (_e, id) => { const a = config.accounts.find((x) => x.id === id); if (!a) return; const instId = instanceFor(a).id; const template = [ { label: "Rename", click: () => send("menu:action", { type: "rename", id }) }, { label: "Saved sign-in…", click: () => send("menu:action", { type: "creds" }) }, { type: "separator" }, { label: "Set Available", click: () => sendToAllFrames(views.get(id)?.webContents, "cd-set-status", "routable"), }, { label: "Set Offline", click: () => sendToAllFrames(views.get(id)?.webContents, "cd-set-status", "offline"), }, { type: "separator" }, { label: "Sign in", click: () => { const auto = signInAccount(id); saveConfig(config); pushState(); if (!auto) { toast(`${displayName(id)}: no saved sign-in — enter it on the login page or in Saved sign-ins`); } }, }, { label: "Sign out", click: async () => { await signOutAccount(id); saveConfig(config); pushState(); toast(`${displayName(id)}: signed out`); }, }, { type: "separator" }, ...config.instances.map((inst) => ({ label: inst.label, type: "radio", checked: inst.id === instId, click: () => setAccountInstance(id, inst.id), })), { type: "separator" }, { label: "Reload", click: () => views.get(id)?.webContents.reload() }, { label: "Remove account…", click: () => confirmRemoveAccount(id) }, ]; Menu.buildFromTemplate(template).popup(); }); ipcMain.handle("show-instance-menu", () => { return new Promise((resolve) => { let done = false; const finish = (value) => { if (done) return; done = true; resolve(value); }; const template = (config.instances || []).map((inst) => ({ label: inst.label, click: () => finish(inst.id), })); Menu.buildFromTemplate(template).popup({ callback: () => finish(null) }); }); }); // ---------- agent status (single + bulk) ---------- const toast = (text) => send("toast", { text }); let bulk = null; // {kind, results: Map, total, timer} function displayName(accountId) { const a = config.accounts.find((x) => x.id === accountId); return a ? a.label || a.agentName || a.fallback || a.id : accountId; } function flushBulk() { if (!bulk) return; clearTimeout(bulk.timer); const missing = bulk.total - bulk.results.size; const parts = [...bulk.results.values()]; if (missing > 0) parts.push(`${missing} account(s) did not respond`); toast(parts.join(" · ") || "No accounts responded"); bulk = null; } function handleStatusResult(accountId, msg) { const text = msg.ok ? `${displayName(accountId)}: ${msg.state || "done"} ✓` : `${displayName(accountId)}: ${msg.error}`; if (bulk && bulk.kind === msg.kind) { if (bulk.results.has(accountId)) return; bulk.results.set(accountId, text); if (bulk.results.size >= bulk.total) flushBulk(); return; } toast(text); } // ---------- sign-out / sign-in (single + bulk) ---------- // Full sign-out: end the Connect session server-side, then wipe this account's // partition (cookies incl. the awsapps SSO refresh token), then show the login // page again. stayOut keeps auto-login from immediately reversing it. async function signOutAccount(id) { const acct = config.accounts.find((a) => a.id === id); const view = views.get(id); if (!acct || !view) return; acct.stayOut = true; const wc = view.webContents; try { const origin = new URL(instanceFor(acct).url).origin; await Promise.race([ wc.loadURL(`${origin}/logout`).catch(() => {}), new Promise((r) => setTimeout(r, 3000)), ]); await wc.session.clearStorageData().catch(() => {}); } catch {} live.set(id, { signedIn: false }); for (const key of [...alerts.keys()]) if (key.startsWith(`${id}:`)) alerts.delete(key); fillAttempts.delete(id); if (!wc.isDestroyed()) wc.loadURL(urlFor(acct)).catch(() => {}); } function signInAccount(id) { const acct = config.accounts.find((a) => a.id === id); if (!acct) return false; delete acct.stayOut; fillAttempts.delete(id); const wc = views.get(id)?.webContents; const canAuto = !!creds[id]?.passEnc; if (wc && !wc.isDestroyed() && live.get(id)?.signedIn !== true) { wc.loadURL(urlFor(acct)).catch(() => {}); // fresh load; autofill takes it from here } return canAuto; } ipcMain.handle("signout", async (_e, id) => { await signOutAccount(id); saveConfig(config); pushState(); toast(`${displayName(id)}: signed out`); }); ipcMain.handle("signout-all", async () => { await Promise.all(config.accounts.map((a) => signOutAccount(a.id))); saveConfig(config); pushState(); toast(`Signed out ${config.accounts.length} account(s). Auto-login is paused until you sign in again.`); }); ipcMain.handle("signin", (_e, id) => { const auto = signInAccount(id); saveConfig(config); pushState(); if (!auto) toast(`${displayName(id)}: no saved sign-in — enter it on the login page or in Saved sign-ins`); }); // One by one, with a gap — firing every login at Amazon's auth endpoint in the // same instant risks throttling, and a stuck one shouldn't block the rest. let signInAllRunning = false; ipcMain.handle("signin-all", async () => { if (signInAllRunning) return; const targets = config.accounts.filter((a) => live.get(a.id)?.signedIn !== true); if (!targets.length) { toast("Every account is already signed in."); return; } const withCreds = targets.filter((a) => creds[a.id]?.passEnc).length; if (!withCreds) { toast("No saved sign-ins yet — add them under Saved sign-ins…"); } signInAllRunning = true; toast(`Signing in ${targets.length} account(s), one at a time…`); try { for (let i = 0; i < targets.length; i++) { const a = targets[i]; delete a.stayOut; fillAttempts.delete(a.id); const wc = views.get(a.id)?.webContents; if (wc && !wc.isDestroyed()) wc.loadURL(urlFor(a)).catch(() => {}); pushState(); if (i < targets.length - 1) await new Promise((r) => setTimeout(r, 3000)); } } finally { signInAllRunning = false; saveConfig(config); pushState(); } }); ipcMain.handle("set-status", (_e, { id, kind }) => { sendToAllFrames(views.get(id)?.webContents, "cd-set-status", kind); }); ipcMain.handle("set-status-all", (_e, kind) => { flushBulk(); // close out any previous batch first bulk = { kind, results: new Map(), total: views.size, // Accounts sitting on a login page fail fast; a hung one shouldn't stall // the summary forever. timer: setTimeout(flushBulk, 12000), }; toast(kind === "offline" ? "Setting all accounts to Offline…" : "Setting all accounts to Available…"); for (const v of views.values()) sendToAllFrames(v.webContents, "cd-set-status", kind); }); // ---------- boot ---------- // Windows: notifications only appear when the app has an explicit user-model id. app.setAppUserModelId("com.aiteam.connectdesk"); app.whenReady().then(() => { config = loadConfig(); loadCreds(); // No View menu: its zoom/reload roles are exactly what scrambled the sidebar. // Edit stays — sign-in forms need paste. appMenu is macOS-only. const template = []; if (process.platform === "darwin") template.push({ role: "appMenu" }); template.push({ role: "editMenu" }, { role: "windowMenu" }); Menu.setApplicationMenu(Menu.buildFromTemplate(template)); win = new BaseWindow({ width: 1440, height: 900, minWidth: 880, minHeight: 560, title: "Connect Desk" }); chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "ui-preload.js"), contextIsolation: true }, }); win.contentView.addChildView(chrome); lockZoom(chrome.webContents, "sidebar"); if (process.env.CD_DEBUG) { chrome.webContents.on("console-message", (_e, _level, msg) => console.log("[sidebar]", msg) ); } chrome.webContents.loadFile(path.join(__dirname, "ui", "index.html")); for (const a of config.accounts) createAccountView(a); // Active account first, so the one you're looking at is ready soonest. if (config.accounts[0]) activate(config.accounts[0].id); loadStaggered(config.accounts.map((a) => a.id)); layout(); win.on("resize", layout); win.on("focus", stopFlash); chrome.webContents.on("did-finish-load", pushState); setInterval(pollIncomingPages, 1500); }); app.on("window-all-closed", () => app.quit());