Add Connect Desk source with gitignore for builds and secrets.
Co-authored-by: Cursor <cursoragent@cursor.com>main
commit
373d15ae03
|
|
@ -0,0 +1,39 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Packaged installers and unpacked Electron builds
|
||||
dist/
|
||||
out/
|
||||
release/
|
||||
|
||||
# Logs and debug
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Env and local secrets (never commit credentials)
|
||||
.env
|
||||
.env.*
|
||||
credentials.json
|
||||
accounts.json
|
||||
*.pem
|
||||
*.p12
|
||||
*.key
|
||||
|
||||
# Electron / native
|
||||
.electron-gyp
|
||||
.cache/
|
||||
*.tsbuildinfo
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Distributing Connect Desk
|
||||
|
||||
The installers live in `dist/` after a build:
|
||||
|
||||
| File | Platform | Notes |
|
||||
|---|---|---|
|
||||
| `Connect Desk Setup 1.9.0.exe` | Windows 10/11 (x64) | NSIS installer — runs on any normal Intel/AMD PC, and on Windows-on-ARM via emulation |
|
||||
| `Connect Desk-1.9.0-arm64.dmg` | macOS (Apple Silicon) | Drag-to-Applications DMG |
|
||||
| `Connect Desk-1.9.0.dmg` | macOS (Intel) | Drag-to-Applications DMG |
|
||||
|
||||
Rebuild any time:
|
||||
|
||||
```sh
|
||||
npm run dist # Mac arm64 + Intel DMGs, and Windows x64 NSIS
|
||||
npm run dist:mac # both Mac DMGs
|
||||
npm run dist:win # Windows x64 installer (always --x64; this Mac is Apple Silicon)
|
||||
```
|
||||
|
||||
## What recipients do
|
||||
|
||||
**Windows:** run `Connect Desk Setup 1.9.0.exe`, pick an install folder, finish. A desktop and
|
||||
Start-menu shortcut named "Connect Desk" are created; the app lives in the chosen folder as
|
||||
`Connect Desk.exe`.
|
||||
|
||||
**macOS:** open the DMG, drag **Connect Desk** into **Applications**, launch from there.
|
||||
|
||||
Each installation starts empty: the recipient adds their accounts with **+ Add account** and signs in
|
||||
with their own Connect credentials. Nothing from your machine — no accounts, no sessions, no
|
||||
cookies — is inside the installer; only the app code ships.
|
||||
|
||||
## The unsigned-app warnings (tell recipients this up front)
|
||||
|
||||
These builds are not code-signed — there is no paid Apple Developer ID or Windows signing
|
||||
certificate behind them — so both OSes show a one-time scare prompt:
|
||||
|
||||
- **Windows SmartScreen**: "Windows protected your PC" → click **More info** → **Run anyway**.
|
||||
- **macOS Gatekeeper**: "cannot be opened because the developer cannot be verified" →
|
||||
**right-click the app → Open → Open**. Once is enough.
|
||||
|
||||
This is expected for unsigned software and does not indicate a problem with the app. If you later
|
||||
want prompt-free installs, that requires purchasing certificates: an Apple Developer ID
|
||||
(~$99/year, plus notarization) and a Windows code-signing certificate — the config is ready for
|
||||
both; only the certificates are missing.
|
||||
|
||||
## Architecture cheat sheet
|
||||
|
||||
- The build machine's CPU decides the default target. This Mac is Apple Silicon, so a plain
|
||||
`--win nsis` build silently produced an **arm64** exe that ordinary PCs cannot run — always pass
|
||||
`--x64` for Windows.
|
||||
- For an Intel-Mac DMG: `npx electron-builder --mac dmg --x64`. For one DMG that runs on both:
|
||||
`--universal` (roughly doubles the file size).
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# Connect Desk
|
||||
|
||||
One window. Every Amazon Connect login signed in at the same time.
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
## The problem it solves
|
||||
|
||||
All your accounts live on the **same** Connect domain and differ only by login. A browser profile
|
||||
holds exactly one session per domain, so signing in as account B silently evicts account A — which is
|
||||
why you ended up with a separate Chrome profile per brand.
|
||||
|
||||
Connect Desk gives every account its **own session partition** (its own cookie jar). Ten accounts stay
|
||||
signed in simultaneously, side by side, in one window. Sessions persist across restarts: sign in once
|
||||
per account and stay in.
|
||||
|
||||
## Why it isn't a web page
|
||||
|
||||
Connect sends `Content-Security-Policy: frame-ancestors 'self'`, which forbids any external site from
|
||||
embedding the CCP in an iframe. Only the AWS account that *owns* the instance can allowlist an origin,
|
||||
and these instances are owned by Amazon, not by you — so a browser-based dashboard is permanently
|
||||
blocked.
|
||||
|
||||
`frame-ancestors` restricts documents embedded **in a frame**. Here each account is a **top-level
|
||||
view**, not a frame, so the rule doesn't apply. Verified: the CCP loads in each partition with zero CSP
|
||||
errors, and no AWS permission of any kind is required.
|
||||
|
||||
## The two instances
|
||||
|
||||
Accounts live on one of two Amazon Connect instances (separate directories, separate logins):
|
||||
|
||||
| Marketplaces | Instance |
|
||||
|---|---|
|
||||
| US, CA | `amazon-product-support-a67d8h.my.connect.aws/agent-app-v2` |
|
||||
| UK, DE, FR, IT, ES, NL, BE, SE | `amazon-product-support-q42kxm.my.connect.aws/agent-app-v2` |
|
||||
|
||||
Each account is assigned to an instance when you add it, shown as a **US** / **EU** chip on its row,
|
||||
and changeable from the right-click menu. The URLs are stored in `accounts.json` under `instances`
|
||||
if they ever need editing.
|
||||
|
||||
## Using it
|
||||
|
||||
- **Sidebar** lists your accounts. Click one to switch; the others keep running in the background and
|
||||
keep receiving chats (background throttling is disabled for exactly this reason).
|
||||
- **Incoming chat** is impossible to miss: a full-sidebar popup, a native OS notification (Mac and
|
||||
Windows), a beep, taskbar/Dock flash, and the app switches to the ringing account. Click the popup
|
||||
or the OS notification to jump there. This still works if you were looking at a different account.
|
||||
- **+ Add account** first asks which instance (US/CA or UK/EU) the account belongs to, creates a new
|
||||
isolated session, and drops you straight into naming it. Names also fill in automatically from the
|
||||
signed-in agent; a manual rename always wins.
|
||||
- **Right-click an account** for Rename, Set Available, Set Offline, instance selection, Reload, and
|
||||
Remove (native OS menu).
|
||||
- **Right-click in a chat** for spelling suggestions plus Cut / Copy / Paste. Spellcheck is on for
|
||||
Connect composers (US + UK English).
|
||||
- **All Available / All Offline** flip every signed-in account's status at once; a toast summarizes
|
||||
which accounts changed and which need sign-in.
|
||||
- **Sign in all / Sign out all** manage sessions in bulk. Sign-out ends each session server-side,
|
||||
wipes the account's cookies, and **pauses auto-login for that account** (otherwise saved sign-ins
|
||||
would immediately log it back in) — the dot goes grey with a "signed out" chip. Sign in all (or
|
||||
per-account right-click → Sign in) resumes auto-login. This state survives app restarts.
|
||||
|
||||
## Credentials
|
||||
|
||||
By default none are stored — each account view shows Connect's own login page and the session cookie
|
||||
stays inside that account's partition.
|
||||
|
||||
**Saved sign-ins (optional, off until you use it):** the "Saved sign-ins…" panel stores a username +
|
||||
password per account so accounts log themselves in — at startup and again whenever a session expires.
|
||||
|
||||
Each row has **Save** (store this account's credentials) and **Sign in** (save, then log this one
|
||||
account in immediately); the footer has **Save all**, **Sign in all**, and **Close**. Saving never
|
||||
signs anything in on its own — the two are separate deliberate actions. "Sign in all" works through
|
||||
the accounts **one at a time** with a gap between each, so a dozen simultaneous logins don't hit
|
||||
Amazon's auth endpoint at once.
|
||||
|
||||
How it's protected:
|
||||
|
||||
- Passwords are encrypted with the OS keychain-backed key (`safeStorage`: macOS Keychain / Windows
|
||||
DPAPI) and stored as ciphertext in `credentials.json`. Never plaintext; `accounts.json` never
|
||||
contains secrets; the file cannot be decrypted on another computer or by another OS user.
|
||||
- Credentials are released to an account's view **only** when that view is on the account's real
|
||||
Amazon login host (`<alias>.my.connect.aws` / `<alias>.awsapps.com`) — enforced in the main
|
||||
process, so no other page can request them.
|
||||
- At most 2 automatic attempts per account per 5 minutes, so a wrong password can't be hammered into
|
||||
an account lockout.
|
||||
- "forget" in the panel deletes an account's saved sign-in; removing an account does too.
|
||||
- If the form can't be filled, **nothing is submitted** and a toast says why — an empty submit would
|
||||
burn an auth attempt and push the account toward a lockout.
|
||||
|
||||
The sign-in page is a GWT app whose username box is `<input type="username" id="wdc_username">` —
|
||||
`username` is not a valid HTML input type, so the field is matched by name/id, never by type. The
|
||||
page also carries a hidden `#wdc_organization` decoy and a hidden `#wdc_mfa` second password field;
|
||||
both are deliberately skipped. If Amazon ever enables MFA on an account, auto sign-in cannot complete
|
||||
it and you'll need to sign in manually.
|
||||
|
||||
The honest trade-off: anyone using your unlocked computer session can start the app and be signed in
|
||||
— same as any password manager without a master password. Lock your machine.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **One login per account, once.** AWS confirms there is no cross-account SSO without SAML federation,
|
||||
which only the instance owner can configure. This app removes the *browser juggling*, not the logins.
|
||||
- **Chat detection is best-effort.** It hooks Amazon Connect Streams (`window.connect`) in the CCP
|
||||
iframe inside Agent Workspace, and falls back to scanning the page text if Streams never appears.
|
||||
The fallback can break if Amazon restyles the CCP. Switching accounts and the sessions themselves
|
||||
are unaffected either way.
|
||||
- Ten-plus live CCPs is genuinely heavy. If it drags, remove accounts you aren't covering that day.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `main.js` | Window, one partitioned `WebContentsView` per account, alerts, notifications |
|
||||
| `probe.js` | Main-world Streams probe (injected into the CCP page and its iframes) |
|
||||
| `preload.js` | Injected per account — relays contacts, status commands, login autofill |
|
||||
| `ui-preload.js` | IPC bridge for the sidebar |
|
||||
| `ui/` | Sidebar: account list, alerts, controls |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "connect-desk",
|
||||
"version": "1.9.0",
|
||||
"description": "One window for every Amazon Connect account — simultaneous sign-ins, unified chat alerts, bulk status control.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dist:mac": "electron-builder --mac dmg --arm64 --x64",
|
||||
"dist:win": "electron-builder --win nsis --x64",
|
||||
"dist": "npm run dist:mac && npm run dist:win"
|
||||
},
|
||||
"author": "AI Team",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"electron": "^43.2.0",
|
||||
"electron-builder": "^26.0.12"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.aiteam.connectdesk",
|
||||
"productName": "Connect Desk",
|
||||
"directories": {
|
||||
"buildResources": "build",
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"probe.js",
|
||||
"ui-preload.js",
|
||||
"ui/**"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.business",
|
||||
"target": [
|
||||
"dmg"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Connect Desk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
// Injected into every account view (top page and CCP iframes). Three jobs:
|
||||
// 1. Detect the signed-in agent (name + state) and report it to main.
|
||||
// 2. Detect contacts arriving/leaving for the unified alert strip.
|
||||
// 3. Execute status commands ("go Available"/"go Offline") inside the CCP.
|
||||
//
|
||||
// The probe must run in the PAGE's main world to see `window.connect`. It is
|
||||
// injected with webFrame.executeJavaScript — NOT a <script> tag, because the
|
||||
// CCP's CSP (script-src 'self', no unsafe-inline) silently blocks injected
|
||||
// tags. webFrame.executeJavaScript is not subject to page CSP.
|
||||
//
|
||||
// Agent Workspace keeps Streams in a CCP iframe, so this preload also runs in
|
||||
// subframes (nodeIntegrationInSubFrames). Autofill stays top-frame only.
|
||||
|
||||
const { ipcRenderer, webFrame } = require("electron");
|
||||
const { probeScript } = require("./probe");
|
||||
|
||||
function inject() {
|
||||
webFrame.executeJavaScript(probeScript).catch(() => {});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", inject, { once: true });
|
||||
} else {
|
||||
inject();
|
||||
}
|
||||
|
||||
// Relay page -> main. Same-origin iframe probes post to window.top; only the
|
||||
// top frame forwards those. Cross-origin iframes can't reach top, so they send
|
||||
// IPC themselves.
|
||||
window.addEventListener("message", (e) => {
|
||||
const d = e.data;
|
||||
if (!d || d.__cdmsg !== true) return;
|
||||
if (
|
||||
!["agent", "detection", "state", "auth", "status-result", "incoming", "gone", "reply", "accepted"].includes(
|
||||
d.type
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (window !== window.top) {
|
||||
try {
|
||||
void window.top.location.href; // throws if this iframe is cross-origin
|
||||
return; // same-origin: the top frame's listener will relay
|
||||
} catch {
|
||||
ipcRenderer.send("contact", d);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
ipcRenderer.send("contact", d);
|
||||
});
|
||||
|
||||
// Relay main -> page (status commands). Forward into same-origin iframes so a
|
||||
// top-level send still reaches the CCP frame that owns window.connect.
|
||||
ipcRenderer.on("cd-set-status", (_e, kind) => {
|
||||
const msg = { __cdcmd: true, cmd: "set-status", kind };
|
||||
window.postMessage(msg, "*");
|
||||
try {
|
||||
if (window.top !== window) return;
|
||||
for (const f of document.querySelectorAll("iframe")) {
|
||||
try {
|
||||
f.contentWindow.postMessage(msg, "*");
|
||||
} catch (err) {}
|
||||
}
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
// ---------- saved sign-in autofill ----------
|
||||
// Runs in the preload's isolated world (direct DOM access, invisible to the
|
||||
// page's scripts). Credentials come from the main process, which releases them
|
||||
// only when this view's URL is the account's real Amazon login host, at most
|
||||
// twice per 5 minutes. One attempt per page load. Iframes skip this entirely.
|
||||
(function autofill() {
|
||||
try {
|
||||
if (window !== window.top) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
let done = false;
|
||||
let busy = false;
|
||||
|
||||
// Written against the real Amazon Connect sign-in markup (a GWT app):
|
||||
// <input type="text" id="wdc_organization"> visibility:hidden (decoy)
|
||||
// <input type="username" id="wdc_username"> <-- NOT a valid HTML
|
||||
// type; filtering for
|
||||
// text/email misses it
|
||||
// <input type="password" id="wdc_password">
|
||||
// <input type="password" id="wdc_mfa"> display:none
|
||||
// <button id="wdc_login_button">Sign In</button>
|
||||
|
||||
const cssHidden = (el) => {
|
||||
const c = getComputedStyle(el);
|
||||
return c.display === "none" || c.visibility === "hidden";
|
||||
};
|
||||
|
||||
const idText = (el) =>
|
||||
`${el.name || ""} ${el.id || ""} ${el.getAttribute("placeholder") || ""} ${
|
||||
el.getAttribute("aria-label") || ""
|
||||
}`;
|
||||
|
||||
// GWT and React both ignore a plain .value write: go through the native
|
||||
// setter, then fire the events either framework might be listening for.
|
||||
const setValue = (el, value) => {
|
||||
if (!el) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
|
||||
setter.call(el, value);
|
||||
for (const type of ["input", "change", "keyup", "blur"]) {
|
||||
el.dispatchEvent(new Event(type, { bubbles: true }));
|
||||
}
|
||||
};
|
||||
|
||||
// Skip display:none password fields — #wdc_mfa is one, and grabbing it
|
||||
// instead of the real box would silently fill nothing.
|
||||
const findPassword = () =>
|
||||
[...document.querySelectorAll('input[type="password"]')].find((el) => !cssHidden(el)) || null;
|
||||
|
||||
// Identify the username box by NAME, not by type — its type is the
|
||||
// non-standard "username". Exclude by what a field can't be, then prefer one
|
||||
// named like a username, then fall back to the nearest box above the
|
||||
// password field.
|
||||
const findUsername = (pass) => {
|
||||
const NON_TEXT = new Set([
|
||||
"password", "checkbox", "radio", "hidden", "submit", "button",
|
||||
"file", "image", "reset", "range", "color",
|
||||
]);
|
||||
const cands = [...document.querySelectorAll("input")].filter(
|
||||
(el) => !NON_TEXT.has((el.getAttribute("type") || "text").toLowerCase())
|
||||
);
|
||||
const named = cands.filter((el) => /user|email|login/i.test(idText(el)));
|
||||
return (
|
||||
named.find((el) => !cssHidden(el)) || // #wdc_username
|
||||
named[0] ||
|
||||
cands
|
||||
.filter((el) => !cssHidden(el) && el.compareDocumentPosition(pass) & 4)
|
||||
.pop() ||
|
||||
cands.find((el) => !cssHidden(el)) ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const tryFill = async () => {
|
||||
if (done || busy) return done;
|
||||
const pass = findPassword();
|
||||
if (!pass) return false;
|
||||
const text = (document.body && document.body.innerText) || "";
|
||||
if (!/sign\s?in|log\s?in/i.test(text)) return false; // password box, but not a login page
|
||||
|
||||
busy = true;
|
||||
const cred = await ipcRenderer.invoke("login:request").catch(() => null);
|
||||
busy = false;
|
||||
if (done) return true;
|
||||
if (!cred) {
|
||||
done = true; // nothing saved (or rate-limited) — stop asking this load
|
||||
return true;
|
||||
}
|
||||
|
||||
done = true;
|
||||
const user = findUsername(pass);
|
||||
setValue(user, cred.username);
|
||||
setValue(pass, cred.password);
|
||||
|
||||
// Verify before submitting. A submit with an empty username burns an auth
|
||||
// attempt and moves the account toward a lockout, so report instead.
|
||||
setTimeout(() => {
|
||||
const userOk = user && user.value === cred.username;
|
||||
const passOk = pass.value === cred.password;
|
||||
if (!userOk || !passOk) {
|
||||
ipcRenderer.send("contact", {
|
||||
type: "autofill-failed",
|
||||
reason: !userOk ? "could not fill the username field" : "could not fill the password field",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const btn =
|
||||
document.getElementById("wdc_login_button") ||
|
||||
document.querySelector('button[type="submit"], input[type="submit"]') ||
|
||||
[...document.querySelectorAll("button")].find((b) =>
|
||||
/sign\s?in|log\s?in/i.test(b.textContent)
|
||||
);
|
||||
if (btn) btn.click();
|
||||
else ipcRenderer.send("contact", { type: "autofill-failed", reason: "no Sign In button found" });
|
||||
}, 400);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Login forms often render late — poll for a while, then give up quietly.
|
||||
const timer = setInterval(async () => {
|
||||
if (await tryFill()) clearInterval(timer);
|
||||
}, 900);
|
||||
setTimeout(() => clearInterval(timer), 45000);
|
||||
})();
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
// Main-world probe injected into every account document (top page and CCP
|
||||
// iframes). Serialized with toString(), so this function must be fully
|
||||
// self-contained — no requires, no closed-over values.
|
||||
//
|
||||
// Jobs: detect the signed-in agent, detect contacts arriving/leaving, run
|
||||
// Available/Offline commands, and force spellcheck on chat composers.
|
||||
|
||||
function mainWorldProbe() {
|
||||
if (window.__cdProbe) return;
|
||||
window.__cdProbe = true;
|
||||
|
||||
const post = function (m) {
|
||||
const payload = Object.assign({ __cdmsg: true }, m);
|
||||
window.postMessage(payload, "*");
|
||||
try {
|
||||
if (window.top && window.top !== window) window.top.postMessage(payload, "*");
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
// ---------- signed-in agent: name + auth ----------
|
||||
let namePosted = "";
|
||||
// Login-form furniture is not a name — "Remember username" once got saved as
|
||||
// an agent name because the username field's class matched the selector.
|
||||
const NOT_A_NAME = /remember|username|password|sign\s?in|log\s?in|forgot|welcome/i;
|
||||
const postName = function (n) {
|
||||
n = (n || "").trim();
|
||||
if (!n || NOT_A_NAME.test(n)) return;
|
||||
if (n !== namePosted) {
|
||||
namePosted = n;
|
||||
post({ type: "agent", name: n });
|
||||
}
|
||||
};
|
||||
|
||||
const looksLikeLogin = function () {
|
||||
const t = (document.body && document.body.innerText) || "";
|
||||
return /Please log in|Forgot Password/i.test(t) && t.length < 900;
|
||||
};
|
||||
|
||||
function scrapeName() {
|
||||
try {
|
||||
const text = (document.body && document.body.innerText) || "";
|
||||
if (looksLikeLogin()) return;
|
||||
const patterns = [
|
||||
/Welcome\s+([^\s\n][^\n]{0,60})/,
|
||||
/Signed in as\s+([^\s\n][^\n]{0,60})/i,
|
||||
/Logged in as\s+([^\s\n][^\n]{0,60})/i,
|
||||
];
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
const m = text.match(patterns[i]);
|
||||
if (m) return postName(m[1]);
|
||||
}
|
||||
const el = document.querySelector(
|
||||
'[data-testid*="user" i],[class*="userName" i],[class*="agentName" i]'
|
||||
);
|
||||
if (el && el.textContent) postName(el.textContent);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ---------- agent status control (bulk Available/Offline) ----------
|
||||
function doSetStatus(kind) {
|
||||
const c = window.connect;
|
||||
const fail = function (error) {
|
||||
post({ type: "status-result", kind: kind, ok: false, error: error });
|
||||
};
|
||||
if (!c || !c.Agent) {
|
||||
// Connect lives in the CCP iframe. Frames without Streams stay quiet so
|
||||
// a top-frame "not signed in" doesn't beat a successful iframe reply.
|
||||
// Login pages (top, no iframes, no connect) still fail fast.
|
||||
try {
|
||||
if (window !== window.top) return;
|
||||
if (document.querySelectorAll("iframe").length) return;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
return fail("not signed in");
|
||||
}
|
||||
let agent;
|
||||
try {
|
||||
agent = new c.Agent();
|
||||
} catch (e) {
|
||||
return fail("not signed in");
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
const wanted = kind === "offline" ? c.AgentStateType.OFFLINE : c.AgentStateType.ROUTABLE;
|
||||
target = (agent.getAgentStates() || []).find(function (s) {
|
||||
return s.type === wanted;
|
||||
});
|
||||
} catch (e) {
|
||||
return fail("states unavailable");
|
||||
}
|
||||
if (!target) return fail("no matching state");
|
||||
try {
|
||||
agent.setState(
|
||||
target,
|
||||
{
|
||||
success: function () {
|
||||
post({ type: "status-result", kind: kind, ok: true, state: target.name });
|
||||
},
|
||||
failure: function () {
|
||||
fail("rejected by Connect");
|
||||
},
|
||||
},
|
||||
{ enqueueNextState: true }
|
||||
);
|
||||
} catch (e) {
|
||||
fail((e && e.message) || "error");
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", function (e) {
|
||||
const d = e.data;
|
||||
if (!d || d.__cdcmd !== true) return;
|
||||
if (d.cmd === "set-status") doSetStatus(d.kind);
|
||||
});
|
||||
|
||||
// ---------- Streams hookup ----------
|
||||
let streams = false;
|
||||
const seenContacts = {};
|
||||
|
||||
function handleContact(contact) {
|
||||
let id;
|
||||
try {
|
||||
id = contact.getContactId();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (!id || seenContacts[id]) return;
|
||||
seenContacts[id] = true;
|
||||
let q = "";
|
||||
try {
|
||||
q = (contact.getQueue() && contact.getQueue().name) || "";
|
||||
} catch (e) {}
|
||||
const gone = function () {
|
||||
delete seenContacts[id];
|
||||
post({ type: "gone", contactId: id });
|
||||
};
|
||||
post({ type: "incoming", contactId: id, queue: q });
|
||||
["onConnected", "onEnded", "onMissed", "onDestroy", "onAccepted"].forEach(function (ev) {
|
||||
try {
|
||||
contact[ev] && contact[ev](gone);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function tryStreams() {
|
||||
const c = window.connect;
|
||||
if (!c || typeof c.contact !== "function" || typeof c.agent !== "function") return false;
|
||||
|
||||
try {
|
||||
c.contact(function (contact) {
|
||||
handleContact(contact);
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
streams = true;
|
||||
|
||||
try {
|
||||
c.agent(function (agent) {
|
||||
post({ type: "auth", signedIn: true });
|
||||
try {
|
||||
postName(agent.getName && agent.getName());
|
||||
} catch (e) {}
|
||||
|
||||
let lastState = "";
|
||||
const sendState = function () {
|
||||
try {
|
||||
const s = agent.getState();
|
||||
const key = ((s && s.name) || "") + "|" + ((s && s.type) || "");
|
||||
if (key === lastState) return;
|
||||
lastState = key;
|
||||
post({ type: "state", stateName: (s && s.name) || "", stateType: (s && s.type) || "" });
|
||||
} catch (e) {}
|
||||
};
|
||||
sendState();
|
||||
try {
|
||||
agent.onStateChange(sendState);
|
||||
} catch (e) {}
|
||||
try {
|
||||
agent.onRefresh(sendState);
|
||||
} catch (e) {}
|
||||
|
||||
// Catch a chat that was already ringing before we hooked contact().
|
||||
try {
|
||||
const contacts = agent.getContacts && agent.getContacts();
|
||||
if (contacts && contacts.length) {
|
||||
for (let i = 0; i < contacts.length; i++) handleContact(contacts[i]);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
post({ type: "detection", mode: "streams" });
|
||||
return true;
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
if (!streams) tryStreams();
|
||||
}, 1000);
|
||||
tryStreams();
|
||||
|
||||
// Name + login-page detection loop. Keeps going until the name lands, because
|
||||
// sign-in can happen long after the page first loads.
|
||||
let loginPosted = false;
|
||||
const nameTimer = setInterval(function () {
|
||||
if (!streams) {
|
||||
if (looksLikeLogin()) {
|
||||
if (!loginPosted) {
|
||||
loginPosted = true;
|
||||
post({ type: "auth", signedIn: false });
|
||||
}
|
||||
} else {
|
||||
loginPosted = false;
|
||||
}
|
||||
}
|
||||
scrapeName();
|
||||
if (namePosted && streams) clearInterval(nameTimer);
|
||||
}, 2500);
|
||||
scrapeName();
|
||||
|
||||
// DOM incoming-chat watch — always on, even if Streams hooked.
|
||||
// Agent Workspace shows "Incoming chat" / "Accept chat" in the page even
|
||||
// when connect.contact() never fires. Skipping this once streams=true is
|
||||
// why the sidebar stayed on "No waiting chats" while CCP was ringing.
|
||||
const INCOMING_RE =
|
||||
/incoming chat|incoming contact|accept chat|reject chat|customer is waiting|new chat|chat request/i;
|
||||
let domSeen = false;
|
||||
const scanIncoming = function () {
|
||||
const hit = INCOMING_RE.test((document.body && document.body.innerText) || "");
|
||||
if (hit && !domSeen) {
|
||||
domSeen = true;
|
||||
post({ type: "incoming", contactId: "dom-incoming", queue: "" });
|
||||
} else if (!hit && domSeen) {
|
||||
domSeen = false;
|
||||
post({ type: "gone", contactId: "dom-incoming" });
|
||||
}
|
||||
};
|
||||
try {
|
||||
new MutationObserver(scanIncoming).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
} catch (e) {}
|
||||
setInterval(scanIncoming, 1500);
|
||||
scanIncoming();
|
||||
|
||||
// Extra Streams-only fallback if the SDK never appears at all (login page).
|
||||
setTimeout(function () {
|
||||
if (streams) return;
|
||||
post({ type: "detection", mode: "dom" });
|
||||
}, 20000);
|
||||
|
||||
// Connect's composer often sets spellcheck="false". Force it back on so
|
||||
// Chromium's checker and our context-menu suggestions can run.
|
||||
document.addEventListener(
|
||||
"focusin",
|
||||
function (e) {
|
||||
const t = e.target;
|
||||
if (!t) return;
|
||||
const tag = (t.tagName || "").toUpperCase();
|
||||
const type = (t.type || "").toLowerCase();
|
||||
if (type === "password" || type === "hidden") return;
|
||||
if (t.isContentEditable || tag === "TEXTAREA" || tag === "INPUT") {
|
||||
try {
|
||||
t.spellcheck = true;
|
||||
t.setAttribute("spellcheck", "true");
|
||||
} catch (err) {}
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mainWorldProbe,
|
||||
probeScript: `(${mainWorldProbe.toString()})();`,
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// Bridge for the sidebar. The sidebar never touches Node directly.
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("api", {
|
||||
onState: (cb) => ipcRenderer.on("state", (_e, s) => cb(s)),
|
||||
onError: (cb) => ipcRenderer.on("account:error", (_e, e) => cb(e)),
|
||||
|
||||
getState: () => ipcRenderer.invoke("get-state"),
|
||||
activate: (id) => ipcRenderer.invoke("activate", id),
|
||||
reload: (id) => ipcRenderer.invoke("reload", id),
|
||||
addAccount: (instanceId) => ipcRenderer.invoke("add-account", instanceId),
|
||||
removeAccount: (id) => ipcRenderer.invoke("remove-account", id),
|
||||
renameAccount: (id, label) => ipcRenderer.invoke("rename-account", { id, label }),
|
||||
setAccountInstance: (id, instanceId) =>
|
||||
ipcRenderer.invoke("set-account-instance", { id, instanceId }),
|
||||
focusAlert: (accountId) => ipcRenderer.invoke("focus-alert", accountId),
|
||||
signIn: (id) => ipcRenderer.invoke("signin", id),
|
||||
signOut: (id) => ipcRenderer.invoke("signout", id),
|
||||
signInAll: () => ipcRenderer.invoke("signin-all"),
|
||||
signOutAll: () => ipcRenderer.invoke("signout-all"),
|
||||
credsList: () => ipcRenderer.invoke("creds:list"),
|
||||
credsSave: (entries, opts) => ipcRenderer.invoke("creds:save", entries, opts),
|
||||
setStatus: (id, kind) => ipcRenderer.invoke("set-status", { id, kind }),
|
||||
setStatusAll: (kind) => ipcRenderer.invoke("set-status-all", kind),
|
||||
onToast: (cb) => ipcRenderer.on("toast", (_e, t) => cb(t)),
|
||||
onMenuAction: (cb) => ipcRenderer.on("menu:action", (_e, a) => cb(a)),
|
||||
showAccountMenu: (id) => ipcRenderer.send("show-account-menu", id),
|
||||
showInstanceMenu: () => ipcRenderer.invoke("show-instance-menu"),
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Connect Desk</h1>
|
||||
<div id="instance" title="The Connect instances accounts can use"></div>
|
||||
</header>
|
||||
|
||||
<section id="alerts">
|
||||
<div class="empty">No waiting chats</div>
|
||||
</section>
|
||||
|
||||
<nav id="accounts"></nav>
|
||||
|
||||
<footer>
|
||||
<div id="bulk-label">All accounts</div>
|
||||
<div id="bulk" role="group" aria-label="Set status for all accounts">
|
||||
<button id="all-available" type="button" title="Set every signed-in account to Available">
|
||||
<span class="bdot av"></span>Available
|
||||
</button>
|
||||
<button id="all-offline" type="button" title="Set every signed-in account to Offline">
|
||||
<span class="bdot off"></span>Offline
|
||||
</button>
|
||||
</div>
|
||||
<div id="bulk2" role="group" aria-label="Sessions for all accounts">
|
||||
<button id="all-signin" type="button" title="Sign every account in with its saved credentials">
|
||||
Sign in all
|
||||
</button>
|
||||
<button id="all-signout" type="button" title="Sign every account out and pause auto-login">
|
||||
Sign out all
|
||||
</button>
|
||||
</div>
|
||||
<button id="add" type="button">+ Add account</button>
|
||||
<button id="signins" type="button">Saved sign-ins…</button>
|
||||
<div id="hint">
|
||||
Right-click an account for rename, status, and instance. Adding an account first asks which
|
||||
instance (US/CA or UK/EU) it belongs to.
|
||||
</div>
|
||||
<div id="credit">Developed by AI Team</div>
|
||||
</footer>
|
||||
|
||||
<div id="incoming-pop" hidden>
|
||||
<div class="incoming-pop-card">
|
||||
<div class="incoming-pop-kicker">Incoming chat</div>
|
||||
<div id="incoming-pop-name"></div>
|
||||
<button type="button" id="incoming-pop-go">Open account</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" hidden></div>
|
||||
<div id="creds" hidden></div>
|
||||
|
||||
<script src="ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
:root {
|
||||
--bg: #14171a;
|
||||
--surface: #1b1f24;
|
||||
--surface-2: #222831;
|
||||
--line: #2c333d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b98a5;
|
||||
--accent: #4493f8;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
/* One column, hard-capped at the body's width. Without this the implicit
|
||||
column sizes to max-content — the nowrap instance URL once blew it out to
|
||||
~390px inside a 268px view, pushing the right side of every row and the
|
||||
second footer button past the visible edge. */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
user-select: none;
|
||||
overflow-y: hidden;
|
||||
/* clip, not hidden: hidden containers can still be scrolled programmatically
|
||||
(e.g. by focusing the rename input), which shoved the whole sidebar
|
||||
sideways. clip forbids scrolling entirely. */
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
header,
|
||||
#alerts,
|
||||
#accounts,
|
||||
footer {
|
||||
overflow-x: clip;
|
||||
min-width: 0; /* grid items may never dictate the column width */
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#instance {
|
||||
font: 10px ui-monospace, monospace;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
#instance:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- unified alerts --- */
|
||||
#alerts {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#alerts .empty {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 7px 9px;
|
||||
margin-bottom: 5px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: #12283f;
|
||||
cursor: pointer;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.alert:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.alert b {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
border-color: #7cc0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- accounts --- */
|
||||
#accounts {
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.acct {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 9px;
|
||||
margin-bottom: 3px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acct:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.acct.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dot.loading {
|
||||
background: var(--warn);
|
||||
}
|
||||
.dot.ready {
|
||||
background: var(--ok);
|
||||
}
|
||||
.dot.err {
|
||||
background: var(--err);
|
||||
}
|
||||
.dot.off {
|
||||
background: #566270;
|
||||
}
|
||||
|
||||
.acct .state {
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* which Connect instance the account lives on */
|
||||
.acct .inst {
|
||||
flex: none;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.acct:hover .inst {
|
||||
display: none; /* make room for the tool buttons */
|
||||
}
|
||||
|
||||
.acct:hover .state {
|
||||
display: none; /* make room for the tool buttons */
|
||||
}
|
||||
|
||||
.acct .label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.acct .label.placeholder {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
background: #0d1117;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.acct .badge {
|
||||
min-width: 17px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: #06101d;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.acct .tools {
|
||||
display: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.acct:hover .tools {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.acct .tools button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.acct .tools button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- footer --- */
|
||||
footer {
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
footer button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
#bulk-label {
|
||||
margin-bottom: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#bulk,
|
||||
#bulk2 {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* appearance:none everywhere — if a button ever loses its styling it must
|
||||
degrade to a dark rectangle, never a white UA-default widget. */
|
||||
#bulk button,
|
||||
#bulk2 button,
|
||||
#add,
|
||||
#signins {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#bulk button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
padding: 7px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#bulk button:hover,
|
||||
#bulk2 button:hover,
|
||||
#add:hover,
|
||||
#signins:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#bulk button:active,
|
||||
#bulk2 button:active,
|
||||
#add:active,
|
||||
#signins:active {
|
||||
background: #2a313b;
|
||||
}
|
||||
|
||||
#bulk2 button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#signins {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bdot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.bdot.av {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.bdot.off {
|
||||
background: #566270;
|
||||
}
|
||||
|
||||
#add {
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
#hint {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#credit {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- toast --- */
|
||||
#toast {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 40;
|
||||
padding: 8px 10px;
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#toast[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#incoming-pop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(8, 10, 12, 0.82);
|
||||
}
|
||||
|
||||
#incoming-pop[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.incoming-pop-card {
|
||||
width: 100%;
|
||||
padding: 16px 14px 14px;
|
||||
border: 2px solid #3fb950;
|
||||
border-radius: 10px;
|
||||
background: #12283f;
|
||||
text-align: center;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.incoming-pop-kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
#incoming-pop-name {
|
||||
margin: 8px 0 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
#incoming-pop-go {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #3fb950;
|
||||
color: #0d1117;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- saved sign-ins panel --- */
|
||||
#creds {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#creds[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.creds-head {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.creds-note {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.creds-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.creds-row.cleared {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.creds-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.creds-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.creds-line .rename {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* per-account action bar */
|
||||
.creds-rowbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.creds-rowbar button {
|
||||
appearance: none;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creds-rowbar button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.creds-rowbar button.primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #06101d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.creds-status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 10px;
|
||||
color: var(--ok);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.creds-forget {
|
||||
flex: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.creds-forget:hover {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.creds-row.cleared .creds-forget {
|
||||
color: var(--err);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.creds-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.creds-actions button {
|
||||
appearance: none;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
padding: 8px 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creds-actions button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.creds-actions button.primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #06101d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
const $alerts = document.getElementById("alerts");
|
||||
const $accounts = document.getElementById("accounts");
|
||||
const $instance = document.getElementById("instance");
|
||||
const $toast = document.getElementById("toast");
|
||||
const $incomingPop = document.getElementById("incoming-pop");
|
||||
const $incomingPopName = document.getElementById("incoming-pop-name");
|
||||
const $incomingPopGo = document.getElementById("incoming-pop-go");
|
||||
|
||||
// Electron has no window.prompt(), so renames happen through an inline input.
|
||||
// `editing` survives re-renders so the field isn't wiped mid-typing.
|
||||
let editing = null;
|
||||
let lastState = { accounts: [], alerts: [] };
|
||||
|
||||
// Belt & braces alongside overflow-x:clip — nothing may ever scroll the
|
||||
// sidebar sideways (a focused input once did, which clipped all content).
|
||||
function resetScroll() {
|
||||
for (const el of [document.scrollingElement, document.body, $accounts]) {
|
||||
if (el && el.scrollLeft) el.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- alerts ---------------- */
|
||||
|
||||
function renderAlerts(alerts) {
|
||||
$alerts.innerHTML = "";
|
||||
if (!alerts.length) {
|
||||
const e = document.createElement("div");
|
||||
e.className = "empty";
|
||||
e.textContent = "No waiting chats";
|
||||
$alerts.append(e);
|
||||
return;
|
||||
}
|
||||
for (const a of alerts) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "alert";
|
||||
const b = document.createElement("b");
|
||||
b.textContent = a.label;
|
||||
const s = document.createElement("span");
|
||||
s.textContent = a.kind === "reply" ? a.text || "New message" : a.queue || "waiting";
|
||||
el.append(b, s);
|
||||
el.onclick = () => api.focusAlert(a.accountId);
|
||||
$alerts.append(el);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- rename editor ---------------- */
|
||||
|
||||
function startEdit(id) {
|
||||
editing = id;
|
||||
render(lastState);
|
||||
}
|
||||
|
||||
function commitEdit(id, value) {
|
||||
if (editing !== id) return;
|
||||
editing = null;
|
||||
api.renameAccount(id, value);
|
||||
}
|
||||
|
||||
// Native popups (Chromium autofill, macOS spelling/substitution) anchor to text
|
||||
// inputs and float OVER the window — an orphaned one is what once appeared as a
|
||||
// white box across the footer. Disable everything that can summon one.
|
||||
function plainInput() {
|
||||
const input = document.createElement("input");
|
||||
input.autocomplete = "off";
|
||||
input.spellcheck = false;
|
||||
input.setAttribute("autocapitalize", "off");
|
||||
input.setAttribute("autocorrect", "off");
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildEditor(a) {
|
||||
const input = plainInput();
|
||||
input.className = "rename";
|
||||
input.value = a.label || a.agentName || "";
|
||||
input.placeholder = a.agentName || "Account name";
|
||||
input.onclick = (e) => e.stopPropagation();
|
||||
input.onkeydown = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") commitEdit(a.id, input.value);
|
||||
if (e.key === "Escape") {
|
||||
editing = null;
|
||||
render(lastState);
|
||||
}
|
||||
};
|
||||
input.onblur = () => commitEdit(a.id, input.value);
|
||||
setTimeout(() => {
|
||||
input.focus({ preventScroll: true }); // focus scrolling is what broke alignment
|
||||
input.select();
|
||||
resetScroll();
|
||||
}, 0);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------- account rows ---------------- */
|
||||
|
||||
function dotClass(a) {
|
||||
if (a.loading) return "dot loading";
|
||||
if (a.stayOut && a.signedIn !== true) return "dot off"; // deliberate sign-out, not an error
|
||||
if (a.signedIn === false) return "dot err";
|
||||
if (a.stateType === "routable") return "dot ready";
|
||||
if (a.stateType === "offline") return "dot off";
|
||||
if (a.agentName) return "dot ready";
|
||||
return "dot";
|
||||
}
|
||||
|
||||
function stateText(a) {
|
||||
if (a.stayOut && a.signedIn !== true) return "signed out";
|
||||
if (a.signedIn === false) return "sign in";
|
||||
return a.stateName || "";
|
||||
}
|
||||
|
||||
function buildRow(a, activeId) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "acct" + (a.id === activeId ? " active" : "");
|
||||
el.onclick = () => api.activate(a.id);
|
||||
el.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
api.showAccountMenu(a.id);
|
||||
};
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = dotClass(a);
|
||||
el.append(dot);
|
||||
|
||||
if (editing === a.id) {
|
||||
el.append(buildEditor(a));
|
||||
return el;
|
||||
}
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "label";
|
||||
label.textContent = a.display;
|
||||
if (!a.label && !a.agentName) label.classList.add("placeholder");
|
||||
label.title =
|
||||
(a.agentName ? `Signed in as ${a.agentName}` : "Not signed in yet") +
|
||||
` — ${a.instanceLabel || ""}`;
|
||||
label.ondblclick = (e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(a.id);
|
||||
};
|
||||
el.append(label);
|
||||
|
||||
// Which Connect instance this account lives on (US / EU).
|
||||
if (a.instanceShort) {
|
||||
const inst = document.createElement("span");
|
||||
inst.className = "inst";
|
||||
inst.textContent = a.instanceShort;
|
||||
inst.title = a.instanceLabel;
|
||||
el.append(inst);
|
||||
}
|
||||
|
||||
const st = stateText(a);
|
||||
if (st) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "state";
|
||||
chip.textContent = st;
|
||||
el.append(chip);
|
||||
}
|
||||
|
||||
if (a.waiting) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge";
|
||||
badge.textContent = a.waiting;
|
||||
el.append(badge);
|
||||
}
|
||||
|
||||
const tools = document.createElement("span");
|
||||
tools.className = "tools";
|
||||
for (const [glyph, title, fn] of [
|
||||
["✎", "Rename", () => startEdit(a.id)],
|
||||
["⟳", "Reload", () => api.reload(a.id)],
|
||||
["✕", "Remove account", () => api.removeAccount(a.id)],
|
||||
]) {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = glyph;
|
||||
b.title = title;
|
||||
b.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
tools.append(b);
|
||||
}
|
||||
el.append(tools);
|
||||
return el;
|
||||
}
|
||||
|
||||
/* ---------------- render ---------------- */
|
||||
|
||||
function renderIncomingPop(alerts) {
|
||||
const waiting = (alerts || []).filter((a) => a.kind !== "reply");
|
||||
if (!waiting.length) {
|
||||
$incomingPop.hidden = true;
|
||||
$incomingPopGo.onclick = null;
|
||||
return;
|
||||
}
|
||||
$incomingPop.hidden = false;
|
||||
$incomingPopName.textContent = waiting.map((a) => a.label).join(" · ");
|
||||
$incomingPopGo.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
api.focusAlert(waiting[0].accountId);
|
||||
};
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
lastState = state;
|
||||
$instance.textContent = (state.instances || []).map((i) => i.label).join(" · ");
|
||||
renderAlerts(state.alerts);
|
||||
renderIncomingPop(state.alerts);
|
||||
$accounts.innerHTML = "";
|
||||
for (const a of state.accounts) $accounts.append(buildRow(a, state.activeId));
|
||||
resetScroll();
|
||||
}
|
||||
|
||||
api.onState(render);
|
||||
api.onError(({ message }) => console.warn("account error:", message));
|
||||
api.onMenuAction((a) => {
|
||||
if (a.type === "rename") startEdit(a.id);
|
||||
if (a.type === "creds") openCreds();
|
||||
});
|
||||
|
||||
/* ---------------- toast ---------------- */
|
||||
|
||||
let toastTimer;
|
||||
api.onToast(({ text }) => {
|
||||
$toast.textContent = text;
|
||||
$toast.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => ($toast.hidden = true), 6000);
|
||||
});
|
||||
|
||||
/* ---------------- top-level controls ---------------- */
|
||||
|
||||
// Every account belongs to one Connect instance, so adding starts by picking
|
||||
// which one — then straight into naming, as before.
|
||||
const $add = document.getElementById("add");
|
||||
$add.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const instances = lastState.instances || [];
|
||||
const create = async (instId) => {
|
||||
const id = await api.addAccount(instId);
|
||||
if (id) startEdit(id);
|
||||
};
|
||||
if (instances.length <= 1) return void create(instances[0]?.id);
|
||||
const instId = await api.showInstanceMenu();
|
||||
if (instId) create(instId);
|
||||
};
|
||||
|
||||
document.getElementById("all-available").onclick = () => api.setStatusAll("routable");
|
||||
document.getElementById("all-offline").onclick = () => api.setStatusAll("offline");
|
||||
document.getElementById("all-signin").onclick = () => api.signInAll();
|
||||
document.getElementById("all-signout").onclick = () => api.signOutAll();
|
||||
|
||||
/* ---------------- saved sign-ins panel ---------------- */
|
||||
|
||||
const $creds = document.getElementById("creds");
|
||||
|
||||
function closeCreds() {
|
||||
$creds.hidden = true;
|
||||
$creds.innerHTML = ""; // never leave typed passwords sitting in the DOM
|
||||
}
|
||||
|
||||
function showToast(text, ms = 6000) {
|
||||
$toast.textContent = text;
|
||||
$toast.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => ($toast.hidden = true), ms);
|
||||
}
|
||||
|
||||
async function openCreds() {
|
||||
const rows = await api.credsList();
|
||||
$creds.innerHTML = "";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "creds-head";
|
||||
head.textContent = "Saved sign-ins";
|
||||
const note = document.createElement("div");
|
||||
note.className = "creds-note";
|
||||
note.textContent =
|
||||
"Stored encrypted on this computer only (OS keychain). Filled only on the official " +
|
||||
"Amazon Connect sign-in page for each account's instance. Leave a password blank to keep " +
|
||||
"the one already saved.";
|
||||
$creds.append(head, note);
|
||||
|
||||
const cleared = new Set();
|
||||
const inputs = new Map(); // id -> {u, p, status}
|
||||
|
||||
// Collects one row (or every row) into the shape creds:save expects.
|
||||
const entryFor = (id) => {
|
||||
const { u, p } = inputs.get(id);
|
||||
return { id, username: u.value, password: p.value || null, clear: cleared.has(id) };
|
||||
};
|
||||
const allEntries = () => [...inputs.keys()].map(entryFor);
|
||||
|
||||
const flash = (id, text) => {
|
||||
const s = inputs.get(id)?.status;
|
||||
if (!s) return;
|
||||
s.textContent = text;
|
||||
clearTimeout(s._t);
|
||||
s._t = setTimeout(() => (s.textContent = ""), 4000);
|
||||
};
|
||||
|
||||
// After saving, the password box is empty again but a secret now exists —
|
||||
// reflect that so the row doesn't look unsaved.
|
||||
const markSaved = (id) => {
|
||||
const { p } = inputs.get(id);
|
||||
p.value = "";
|
||||
p.placeholder = "•••••• saved — blank keeps it";
|
||||
};
|
||||
|
||||
const saveOne = async (id) => {
|
||||
const res = await api.credsSave([entryFor(id)]);
|
||||
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
|
||||
if (cleared.has(id)) {
|
||||
cleared.delete(id);
|
||||
inputs.get(id).row.classList.remove("cleared");
|
||||
inputs.get(id).p.placeholder = "password";
|
||||
flash(id, "forgotten");
|
||||
} else {
|
||||
markSaved(id);
|
||||
flash(id, "saved ✓");
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const r of rows) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "creds-row";
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "creds-name";
|
||||
name.textContent = `${r.display} · ${r.instanceShort}`;
|
||||
|
||||
const u = plainInput();
|
||||
u.className = "rename";
|
||||
u.placeholder = "login username / email";
|
||||
u.value = r.username;
|
||||
|
||||
const p = plainInput();
|
||||
p.className = "rename";
|
||||
p.type = "password";
|
||||
p.placeholder = r.hasPassword ? "•••••• saved — blank keeps it" : "password";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "creds-rowbar";
|
||||
|
||||
const bSave = document.createElement("button");
|
||||
bSave.type = "button";
|
||||
bSave.textContent = "Save";
|
||||
bSave.title = "Save this account's sign-in";
|
||||
bSave.onclick = () => saveOne(r.id);
|
||||
|
||||
const bSignIn = document.createElement("button");
|
||||
bSignIn.type = "button";
|
||||
bSignIn.className = "primary";
|
||||
bSignIn.textContent = "Sign in";
|
||||
bSignIn.title = "Save, then sign this account in with these credentials";
|
||||
bSignIn.onclick = async () => {
|
||||
if (!(await saveOne(r.id))) return;
|
||||
flash(r.id, "signing in…");
|
||||
api.signIn(r.id);
|
||||
};
|
||||
|
||||
const forget = document.createElement("button");
|
||||
forget.type = "button";
|
||||
forget.className = "creds-forget";
|
||||
forget.textContent = "forget";
|
||||
forget.title = "Mark this saved sign-in for deletion, then Save";
|
||||
forget.onclick = () => {
|
||||
if (cleared.has(r.id)) {
|
||||
cleared.delete(r.id);
|
||||
row.classList.remove("cleared");
|
||||
} else {
|
||||
cleared.add(r.id);
|
||||
row.classList.add("cleared");
|
||||
}
|
||||
};
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.className = "creds-status";
|
||||
|
||||
bar.append(bSave, bSignIn, status, forget);
|
||||
|
||||
inputs.set(r.id, { u, p, status, row });
|
||||
const line = document.createElement("div");
|
||||
line.className = "creds-line";
|
||||
line.append(u, p);
|
||||
row.append(name, line, bar);
|
||||
$creds.append(row);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "creds-actions";
|
||||
|
||||
const saveAll = document.createElement("button");
|
||||
saveAll.type = "button";
|
||||
saveAll.textContent = "Save all";
|
||||
saveAll.onclick = async () => {
|
||||
const res = await api.credsSave(allEntries());
|
||||
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
|
||||
for (const id of inputs.keys()) {
|
||||
if (cleared.has(id)) continue;
|
||||
markSaved(id);
|
||||
}
|
||||
cleared.clear();
|
||||
for (const { row } of inputs.values()) row.classList.remove("cleared");
|
||||
showToast(`Saved sign-ins for ${res.saved} account(s).`);
|
||||
};
|
||||
|
||||
const signInAll = document.createElement("button");
|
||||
signInAll.type = "button";
|
||||
signInAll.className = "primary";
|
||||
signInAll.textContent = "Sign in all";
|
||||
signInAll.onclick = async () => {
|
||||
const res = await api.credsSave(allEntries());
|
||||
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
|
||||
closeCreds();
|
||||
api.signInAll();
|
||||
};
|
||||
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.textContent = "Close";
|
||||
close.onclick = closeCreds;
|
||||
|
||||
actions.append(saveAll, signInAll, close);
|
||||
$creds.append(actions);
|
||||
|
||||
$creds.hidden = false;
|
||||
}
|
||||
|
||||
document.getElementById("signins").onclick = openCreds;
|
||||
addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !$creds.hidden) closeCreds();
|
||||
});
|
||||
|
||||
api.getState();
|
||||
Loading…
Reference in New Issue