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