Connect-Desk-Project/preload.js

194 lines
7.1 KiB
JavaScript

// 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);
})();