Connect-Desk-Project/main.js

1040 lines
33 KiB
JavaScript

// 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<accountId, text>, 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());