1040 lines
33 KiB
JavaScript
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());
|