// ==UserScript== // @name M365 Copilot — token → VS Code // @name:es M365 Copilot — token → VS Code // @namespace https://github.com/cristiancastineiras/M365CopilotVSCode // @version 3.0.3 // @description Captures your Microsoft 365 Copilot session token and sends it to the M365 Copilot extension for VS Code automatically (or lets you copy it). // @description:es Captura el token de tu sesión de Microsoft 365 Copilot y lo envía a la extensión M365 Copilot de VS Code automáticamente (o te deja copiarlo). // @author Cristian Castineiras // @homepageURL https://github.com/cristiancastineiras/M365CopilotVSCode // @supportURL https://github.com/cristiancastineiras/M365CopilotVSCode/issues // @updateURL https://raw.githubusercontent.com/cristiancastineiras/M365CopilotVSCode/main/extensiones/vscode/m365copilot-token.user.js // @downloadURL https://raw.githubusercontent.com/cristiancastineiras/M365CopilotVSCode/main/extensiones/vscode/m365copilot-token.user.js // @run-at document-start // @match https://m365.cloud.microsoft/* // @match https://*.cloud.microsoft/* // @match https://www.office.com/* // @match https://outlook.office.com/* // @match https://teams.microsoft.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect localhost // ==/UserScript== // GENERATED by extensiones/vscode/scripts/build-userscript.mjs from userscript/main.ts // and @m365copilot/core — do not edit by hand: run `pnpm build` in extensiones/vscode. (function() { //#region ../../packages/core/dist/constants.js /** * Contrato de red entre la extensión de navegador, los userscripts de * Tampermonkey y el servidor local de la extensión de VS Code. * * Es la ÚNICA fuente de verdad: si el puerto o la ruta se desincronizan, el * token deja de sincronizarse silenciosamente (exactamente el bug que había * cuando el navegador apuntaba a 39283/profile y el servidor escuchaba en * 51827/token). Manténlo aquí y consúmelo desde ambos lados. */ const TOKEN_SERVER_HOST = "localhost"; const TOKEN_SERVER_PORT = 51827; const TOKEN_SERVER_URL = `http://${TOKEN_SERVER_HOST}:${TOKEN_SERVER_PORT}`; /** Ruta donde el servidor de VS Code recibe el perfil/token (POST). */ const TOKEN_ENDPOINT_PATH = "/token"; /** Ruta de health-check para saber si VS Code está escuchando (GET). */ const HEALTH_ENDPOINT_PATH = "/health"; /** * Web de M365 Copilot: donde se captura el token. La extensión de navegador * abre aquí una pestaña en segundo plano para renovarlo, y la de VS Code la * ofrece cuando el token falta o caduca. */ const M365_CHAT_URL = "https://m365.cloud.microsoft/chat/"; /** Clave de localStorage donde el interceptor del navegador acumula la captura. */ const CAPTURE_STORE_KEY = "m365copilot.capture.v1"; //#endregion //#region ../../packages/core/dist/jwt.js /** * Decodifica el payload de un JWT sin validar la firma. Isomorfo: `atob` y * `TextDecoder` existen tanto en el navegador como en Node 18+, así que sirve * igual en el content script del navegador y en la extensión de VS Code. */ function decodeJwtPayload(token) { try { const part = String(token).split(".")[1]; if (!part) return null; const b64 = part.replace(/-/g, "+").replace(/_/g, "/"); const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, "="); const binary = atob(padded); const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)); return JSON.parse(new TextDecoder("utf-8").decode(bytes)); } catch { return null; } } /** Extrae los claims relevantes, normalizando el nombre de usuario (upn). */ function extractClaims(token) { const raw = decodeJwtPayload(token); if (!raw) return null; return { oid: typeof raw.oid === "string" ? raw.oid : void 0, tid: typeof raw.tid === "string" ? raw.tid : void 0, aud: typeof raw.aud === "string" ? raw.aud : void 0, exp: typeof raw.exp === "number" ? raw.exp : void 0, upn: typeof raw.upn === "string" && raw.upn || typeof raw.unique_name === "string" && raw.unique_name || typeof raw.preferred_username === "string" && raw.preferred_username || void 0 }; } //#endregion //#region ../../packages/core/dist/token.js /** ¿Es un token de Substrate/Sydney, el que usa Copilot? */ function isSydneyToken(token) { const claims = decodeJwtPayload(token); if (!claims) return false; const aud = String(claims.aud || ""); const scp = String(claims.scp || claims.scope || ""); return aud.includes("substrate.office.com") || aud.includes("substrate.svc.cloud.microsoft") || /sydney|m365chat/i.test(scp); } //#endregion //#region ../../packages/core/dist/capture.js /** Un JWT de verdad no se acerca a esto; algo mayor no es un token. */ const MAX_JWT_CHARS = 8192; /** Frames SignalR salientes que se llegan a inspeccionar. */ const MAX_FRAME_CHARS = 1048576; /** Plantilla de invocación que merece la pena guardar. */ const MAX_TEMPLATE_CHARS = 65536; const MAX_ENDPOINT_CHARS = 2048; /** Tres partes base64url y un tamaño razonable. */ function isValidJwt(token) { if (typeof token !== "string" || token.length > 8192) return false; const parts = token.split("."); return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)); } /** `exp` en el futuro y un `iat` coherente con él. */ function areClaimsUsable(claims, now = Date.now()) { if (!claims || typeof claims.exp !== "number" || claims.exp <= 0) return false; if (claims.exp * 1e3 <= now) return false; const iat = claims.iat; if (iat !== void 0 && (typeof iat !== "number" || iat > claims.exp)) return false; return true; } /** * Diagnostica un token candidato contra el store actual. `accepted` sólo viene * cuando `diagnosis === 'accepted'`. Es la lógica de decisión única: tanto el * sí/no (`acceptToken`) como el motivo salen de aquí. */ function diagnoseToken(current, token, now = Date.now()) { const candidate = token.trim(); if (!isValidJwt(candidate)) return { diagnosis: "not-a-jwt" }; if (!isSydneyToken(candidate)) return { diagnosis: "not-sydney" }; const claims = extractClaims(candidate); if (!claims) return { diagnosis: "no-claims" }; if (!areClaimsUsable(claims, now)) return { diagnosis: "expired" }; if (current.accessToken === candidate) return { diagnosis: "duplicate" }; const currentExp = current.claims?.exp ?? 0; if (claims.exp && currentExp && claims.exp < currentExp) return { diagnosis: "older-than-current" }; return { diagnosis: "accepted", accepted: { accessToken: candidate, claims } }; } /** * Lo que hay que guardar si `token` merece sustituir al actual, o null: debe * ser un JWT válido, de Substrate/Sydney (el de Copilot, no cualquier token de * Office), sin caducar, distinto del guardado y no más viejo que él. */ function acceptToken(current, token, now = Date.now()) { return diagnoseToken(current, token, now).accepted ?? null; } /** * Tokens candidatos dentro de un valor de localStorage/sessionStorage: MSAL * guarda cada access token como JSON (`secret`), otras librerías como * `access_token`/`accessToken`, y algunas entradas son el JWT pelado. */ function tokensInStorageValue(value) { if (!value || value.length < 40 || !value.includes("eyJ")) return []; try { const parsed = JSON.parse(value); if (!parsed || typeof parsed !== "object") return []; return [ parsed.secret, parsed.access_token, parsed.accessToken ].filter((candidate) => typeof candidate === "string"); } catch { const raw = value.trim(); return /^ey[\w-]+\.[\w-]+\.[\w-]+$/.test(raw) ? [raw] : []; } } /** El token que viaja en la query de la URL del WebSocket, si lo hay. */ function tokenInSocketUrl(url) { try { return new URL(String(url).replace(/^ws/i, "http")).searchParams.get("access_token"); } catch { return null; } } /** ¿Es un socket de Substrate/Chathub (y no presencia, notificaciones…)? */ function isCopilotSocketUrl(url) { const raw = String(url); return /chathub/i.test(raw) || /substrate/i.test(raw); } /** * Dominios de Microsoft desde los que se acepta un endpoint. El hub del chat * vive en `substrate.office.com`: la versión anterior sólo admitía * `.microsoft.com`/`.microsoft`, así que lo descartaba y el popup se quedaba * con «Endpoint: pendiente» para siempre. */ const MICROSOFT_HOST_SUFFIXES = [ ".office.com", ".office.net", ".microsoft.com", ".microsoft", ".cloud.microsoft" ]; function isMicrosoftHost(hostname) { const host = hostname.toLowerCase(); return MICROSOFT_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix)); } /** * La URL del socket sin lo volátil (token, ids de sesión): no hay que guardar * un token caducado dentro del endpoint. '' si no es un host de Microsoft. */ function normalizeEndpoint(raw) { if (typeof raw !== "string" || raw.length > MAX_ENDPOINT_CHARS) return ""; try { const url = new URL(raw.replace(/^ws/i, "http")); if (!isMicrosoftHost(url.hostname)) return ""; for (const param of [ "access_token", "ConversationId", "chatsessionid", "clientrequestid", "X-SessionId" ]) url.searchParams.delete(param); return url.toString().replace(/^http/i, "ws"); } catch { return ""; } } /** SignalR separa los frames con este carácter (record separator). */ const RS = ""; /** * Mira un envío saliente por el socket: ¿lleva la invocación `chat` (prueba de * que es el hub del chat)? Si la lleva, devuelve también su plantilla. */ function inspectOutgoingFrames(data) { if (typeof data !== "string" || data.length > 1048576) return { sawChat: false }; let found = { sawChat: false }; for (const chunk of data.split(RS)) { if (!chunk) continue; let frame; try { frame = JSON.parse(chunk); } catch { continue; } if (frame.type !== 4 && frame.type !== 1 || frame.target !== "chat") continue; found = { sawChat: true }; const args = Array.isArray(frame.arguments) ? frame.arguments[0] : null; if (args && typeof args === "object" && JSON.stringify(args).length <= 65536) found = { sawChat: true, template: args, invocationType: frame.type }; } return found; } /** Un `tone` con forma de identificador de modelo de BizChat (p. ej. `Gpt_5_6_Reasoning`). */ const TONE_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{1,63}$/; /** El `tone` (modelo) de una plantilla de invocación `chat`, si es válido. */ function toneOfTemplate(template) { const tone = template?.tone; return typeof tone === "string" && TONE_PATTERN.test(tone) ? tone : null; } /** * `tones` con `tone` añadido como el más reciente (sin duplicados, acotado). * Así M365 puede estrenar un modelo y VS Code enterarse sin publicar versión: * en cuanto alguien lo usa en la web, la captura lo trae. */ function withObservedTone(tones, tone) { const list = (tones ?? []).filter((item) => item !== tone); if (tone) list.push(tone); return list.slice(-20); } /** El store sin el token si éste ya caducó (para invitar a recapturarlo), o null si no hay que tocarlo. */ function withoutExpiredToken(store, now = Date.now()) { const exp = store.claims?.exp; if (!store.accessToken || typeof exp !== "number" || exp * 1e3 > now) return null; const cleaned = { ...store }; delete cleaned.accessToken; delete cleaned.tokenSource; delete cleaned.claims; return cleaned; } /** Misma forma ignorando el sello temporal: sólo se escribe si algo cambió de verdad. */ function sameCapture(a, b) { const strip = (store) => { const copy = { ...store }; delete copy.capturedAt; return JSON.stringify(copy); }; return strip(a) === strip(b); } /** El perfil que se envía a VS Code, o null si todavía no hay token. */ function profileFromCapture(store, fallbackUserAgent) { if (!store.accessToken) return null; return { version: 1, capturedAt: store.capturedAt || (/* @__PURE__ */ new Date()).toISOString(), accessToken: store.accessToken, endpoint: store.endpoint || null, origin: store.origin || "https://m365.cloud.microsoft", userAgent: store.userAgent || fallbackUserAgent, invocationTemplate: store.invocationTemplate || null, invocationType: store.invocationType || 4, claims: store.claims || null, ...store.observedTones && store.observedTones.length > 0 ? { observedTones: [...store.observedTones] } : {} }; } /** Sin token: insistir. Con token fresco: vigilar de lejos. Cerca de caducar: apretar. */ const SCAN_MISSING_MS = 2e3; const SCAN_MISSING_MAX_MS = 15e3; const SCAN_FRESH_MS = 6e4; const SCAN_EXPIRING_MS = 5e3; /** A partir de aquí la web ya suele haber renovado su propio token. */ const EXPIRY_WATCH_MS = 9e5; /** * Cuándo volver a escanear la caché de MSAL. Se escanea SIEMPRE, haya token o * no: el token nuevo que la web renueva sola cada ~50 min sólo se recoge así. */ function nextScan(store, missingDelay, now = Date.now()) { if (!store.accessToken) { const grown = Math.min(Math.round(missingDelay * 1.5), SCAN_MISSING_MAX_MS); return { delay: grown, missingDelay: grown }; } const exp = store.claims?.exp; const left = typeof exp === "number" ? exp * 1e3 - now : null; return { delay: left !== null && left <= 9e5 ? SCAN_EXPIRING_MS : SCAN_FRESH_MS, missingDelay: SCAN_MISSING_MS }; } //#endregion //#region ../../packages/core/dist/sync.js /** No martillear al servidor local de VS Code cuando está apagado. */ const RESYNC_COOLDOWN_MS = 3e4; /** * ¿Hay que (re)enviar el perfil a VS Code? Cubre el caso más molesto: VS Code * arranca DESPUÉS de que el navegador capturara el token, y sin esto nadie se lo * vuelve a mandar hasta que el token cambia. */ function needsResync(context) { if (!context.hasToken) return false; if (context.lastSyncAttemptAt !== null && context.now - context.lastSyncAttemptAt >= 0 && context.now - context.lastSyncAttemptAt < 3e4) return false; if (context.syncedTokenExp === null) return true; if (context.currentTokenExp === null) return true; return context.currentTokenExp > context.syncedTokenExp; } //#endregion //#region ../../packages/core/dist/connectionArt.js /** * Ilustración «M365 → VS Code» del popup de la extensión de navegador y del * panel del userscript: el logo de M365 Copilot a la izquierda, el de VS Code * a la derecha y, entre ambos, una señal de tres anillos concéntricos (el * «wifi loader» de Uiverse.io por mobinkakei, licencia MIT, rehecho dentro * del SVG) que cuenta en qué punto está la conexión. * * - waiting: los dos logos apagados (en gris) y los anillos girando en gris: * está buscando el token; M365 «respira». * - captured: M365 se enciende y los anillos giran con el degradado de la * marca: tiene el token y está buscando a VS Code, que sigue apagado. * - connected: los anillos se paran y forman una señal de wifi que apunta a * VS Code, con una onda que viaja hacia él; VS Code se enciende. * - warning: token caducado o hace falta iniciar sesión: señal débil en * ámbar y aviso sobre M365. * * Los logos son los del proyecto (extensiones/vscode/logo/m365-vscode.svg y * vscode.svg), incrustados aquí como . Ojo: el icono de VS Code es una * marca de Microsoft y sus pautas de uso restringen usarlo en extensiones o * en versiones modificadas; su uso aquí es decisión del proyecto. * * El SVG es estático y lleva su propio ${use("m365")} ${use("m365")} ${rings} ${use("vscode")} ${use("vscode")} ${use("vscode")} ${left} ${right} `; } function escapeXml(text) { return text.replace(/[<>&"']/g, (char) => `&#${char.charCodeAt(0)};`); } //#endregion //#region userscript/main.ts /** * M365 Copilot → VS Code, Tampermonkey/Violentmonkey userscript. * * The alternative to the browser extension, with the same behaviour: it * captures the Copilot (Substrate/Sydney) token from the page — the WebSocket * URL, request headers and the MSAL cache — keeps scanning so the token the * web app renews by itself every ~50 min is picked up, and SENDS it to the VS * Code extension's local server, so there is nothing to copy and paste. * * Every decision (which token to accept, which frame is the chat, when to scan * again, when to re-sync with VS Code) comes from `@m365copilot/core`, the * same code the browser extension runs. This file is bundled into * `../m365copilot-token.user.js` by scripts/build-userscript.mjs — edit this * one, never the generated file. * * State lives in GM storage, shared by every tab and frame where the script * runs: any frame can capture, the top-level frames sync and draw the panel. */ const SYNC_KEY = "m365copilot.sync.v1"; const UI_KEY = "m365copilot.ui.v1"; const HEALTH_EVERY_MS = 3e4; const REQUEST_TIMEOUT_MS = 4e3; /** Reload a hidden tab whose token expired at most this often (shared by all tabs). */ const RELOAD_EVERY_MS = 6e5; /** Never reload a page that has been open for less than this (no reload loops). */ const MIN_PAGE_AGE_MS = 12e4; const EMPTY_SYNC = { syncedTokenExp: null, lastSyncAttemptAt: null, lastSyncedAt: null, lastError: null, vscodeReachable: null, lastReloadAt: null, syncedTones: [] }; const PAGE = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const IS_TOP = (() => { try { return window.top === window.self; } catch { return false; } })(); const LOADED_AT = Date.now(); const TEXTS = { en: { title: "M365 Copilot → VS Code", art: "Connection between Microsoft 365 Copilot and VS Code", minimize: "Minimize", expand: "Show the M365 Copilot panel", close: "Hide until the next page load", waitingTitle: "Waiting for the token", waitingSub: "Send a message in the chat to capture it.", capturedTitle: "Token ready", capturedSub: "VS Code is not answering. Open it with the M365 Copilot extension enabled.", connectedTitle: "Connected to VS Code", connectedSub: "The token is sent automatically whenever it is renewed.", warningTitle: "Token expired", warningSub: "Reload this page or sign in again to renew it.", token: "Token", tokenNone: "not captured", tokenLeft: "{0} min left", tokenExpired: "expired", vscode: "VS Code", vscodeOk: "connected", vscodeDown: "not answering", vscodeChecking: "checking…", sync: "Last sync", syncNever: "never", syncJustNow: "just now", syncAgo: "{0} min ago", send: "Send to VS Code", sent: "✓ Sent", sendFailed: "VS Code did not answer", copyToken: "Copy token", copyProfile: "Copy profile", copied: "✓ Copied", renew: "Reload to renew", menuShow: "Show panel", menuSend: "Send token to VS Code now", menuAutoReloadOn: "Auto-renew: reload this tab when hidden and expired — currently ON", menuAutoReloadOff: "Auto-renew: reload this tab when hidden and expired — currently OFF", openM365: "Open M365 Copilot" }, es: { title: "M365 Copilot → VS Code", art: "Conexión entre Microsoft 365 Copilot y VS Code", minimize: "Minimizar", expand: "Mostrar el panel de M365 Copilot", close: "Ocultar hasta la próxima carga de la página", waitingTitle: "Esperando el token", waitingSub: "Envía un mensaje en el chat para capturarlo.", capturedTitle: "Token listo", capturedSub: "VS Code no responde. Ábrelo con la extensión M365 Copilot activa.", connectedTitle: "Conectado con VS Code", connectedSub: "El token se envía solo cada vez que se renueva.", warningTitle: "Token caducado", warningSub: "Recarga esta página o vuelve a iniciar sesión para renovarlo.", token: "Token", tokenNone: "sin capturar", tokenLeft: "quedan {0} min", tokenExpired: "caducado", vscode: "VS Code", vscodeOk: "conectado", vscodeDown: "no responde", vscodeChecking: "comprobando…", sync: "Última sincronización", syncNever: "nunca", syncJustNow: "ahora mismo", syncAgo: "hace {0} min", send: "Enviar a VS Code", sent: "✓ Enviado", sendFailed: "VS Code no respondió", copyToken: "Copiar token", copyProfile: "Copiar perfil", copied: "✓ Copiado", renew: "Recargar para renovar", menuShow: "Mostrar el panel", menuSend: "Enviar el token a VS Code ahora", menuAutoReloadOn: "Auto-renovar: recargar esta pestaña oculta si caduca — ahora ACTIVADO", menuAutoReloadOff: "Auto-renovar: recargar esta pestaña oculta si caduca — ahora DESACTIVADO", openM365: "Abrir M365 Copilot" } }; const T = /^es\b/i.test(navigator.language || "") ? TEXTS.es : TEXTS.en; const fill = (template, value) => template.replace("{0}", String(value)); function readJson(key, fallback) { try { const raw = GM_getValue(key, ""); return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; } catch { return fallback; } } const readStore = () => readJson(CAPTURE_STORE_KEY, {}); const readSync = () => readJson(SYNC_KEY, EMPTY_SYNC); const readUi = () => readJson(UI_KEY, { minimized: false, autoReload: true }); const writeSync = (patch) => GM_setValue(SYNC_KEY, JSON.stringify({ ...readSync(), ...patch })); const writeUi = (patch) => GM_setValue(UI_KEY, JSON.stringify({ ...readUi(), ...patch })); /** Merge `patch` into the capture, writing only when something really changed. */ function writeStore(patch) { const current = readStore(); const merged = { ...current, ...patch }; if (sameCapture(current, merged)) return current; merged.capturedAt = (/* @__PURE__ */ new Date()).toISOString(); GM_setValue(CAPTURE_STORE_KEY, JSON.stringify(merged)); return merged; } function pruneExpiredToken() { const cleaned = withoutExpiredToken(readStore()); if (!cleaned) return; cleaned.capturedAt = (/* @__PURE__ */ new Date()).toISOString(); GM_setValue(CAPTURE_STORE_KEY, JSON.stringify(cleaned)); } function offerToken(token, source) { if (!token) return; try { const accepted = acceptToken(readStore(), token); if (!accepted) return; writeStore({ ...accepted, tokenSource: source }); if (IS_TOP) syncToVSCode(hasUnsentChanges()); } catch {} } function captureSocket(url, socket) { const raw = String(url); if (!isCopilotSocketUrl(raw)) return; offerToken(tokenInSocketUrl(raw), "websocket-url"); const endpoint = normalizeEndpoint(raw); if (endpoint && !readStore().endpointConfirmed) writeStore({ endpoint, origin: location.origin, userAgent: navigator.userAgent }); const originalSend = socket.send; socket.send = function(...args) { try { const frames = inspectOutgoingFrames(args[0]); if (frames.sawChat) { const tone = toneOfTemplate(frames.template); writeStore({ ...tone ? { observedTones: withObservedTone(readStore().observedTones, tone) } : {}, ...endpoint ? { endpoint, endpointConfirmed: true } : {}, origin: location.origin, userAgent: navigator.userAgent, ...frames.template ? { invocationTemplate: frames.template, invocationType: frames.invocationType } : {} }); } } catch {} return originalSend.apply(this, args); }; } function headerValue(headers, name) { if (!headers) return null; try { const wanted = name.toLowerCase(); if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(name); if (Array.isArray(headers)) { const hit = headers.find((entry) => String(entry[0]).toLowerCase() === wanted); return hit ? String(hit[1]) : null; } for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === wanted) return String(value); } catch {} return null; } function offerBearer(value, source) { if (value && /^bearer /i.test(value)) offerToken(value.slice(7), source); } function installHooks() { if (PAGE.WebSocket) { const NativeWebSocket = PAGE.WebSocket; PAGE.WebSocket = new Proxy(NativeWebSocket, { construct(target, args) { const socket = Reflect.construct(target, args); try { captureSocket(args[0], socket); } catch {} return socket; } }); } if (PAGE.fetch) { const nativeFetch = PAGE.fetch; PAGE.fetch = function(input, init) { try { let auth = headerValue(init?.headers, "authorization"); if (!auth && typeof Request !== "undefined" && input instanceof Request) auth = input.headers.get("authorization"); offerBearer(auth, "fetch-header"); } catch {} return nativeFetch.call(this, input, init); }; } if (PAGE.XMLHttpRequest) { const setHeader = PAGE.XMLHttpRequest.prototype.setRequestHeader; PAGE.XMLHttpRequest.prototype.setRequestHeader = function(name, value) { try { if (String(name).toLowerCase() === "authorization") offerBearer(String(value), "xhr-header"); } catch {} return setHeader.call(this, name, value); }; } } /** MSAL keeps access tokens in localStorage / sessionStorage. */ function scanStorages() { for (const storeName of ["localStorage", "sessionStorage"]) { let store; let length = 0; try { store = PAGE[storeName]; length = store.length; } catch { continue; } for (let index = 0; index < length; index += 1) try { const key = store.key(index); if (!key || key === "m365copilot.capture.v1") continue; for (const token of tokensInStorageValue(store.getItem(key))) offerToken(token, "msal-cache"); } catch {} } } function request(method, path, body) { return new Promise((resolve) => { try { GM_xmlhttpRequest({ method, url: `${TOKEN_SERVER_URL}${path}`, headers: body ? { "Content-Type": "application/json" } : void 0, data: body, timeout: REQUEST_TIMEOUT_MS, onload: (response) => resolve(response.status), onerror: () => resolve(0), ontimeout: () => resolve(0) }); } catch { resolve(0); } }); } let syncing = false; /** * The stored token is one VS Code has not received yet, and VS Code was not * down a moment ago: send it now instead of waiting out the re-sync cooldown, * which only exists not to hammer a VS Code that is not running. */ function hasUnsentToken() { const exp = readStore().claims?.exp; const sync = readSync(); return typeof exp === "number" && (sync.syncedTokenExp === null || exp > sync.syncedTokenExp) && sync.vscodeReachable !== false; } /** * The web app used a model (`tone`) VS Code has not been told about. The chat * frame that reveals it arrives AFTER the token was sent, so without this VS * Code would only learn it at the next token renewal, ~50 min later. */ function hasUnsentTone() { const sync = readSync(); return sync.vscodeReachable !== false && (readStore().observedTones ?? []).some((tone) => !sync.syncedTones.includes(tone)); } const hasUnsentChanges = () => hasUnsentToken() || hasUnsentTone(); /** * Send the profile to VS Code when it needs it: a token it does not have yet, * or VS Code started after the capture — the same rule (and cooldown) as the * browser extension. `force` is the panel's "Send to VS Code" button. */ async function syncToVSCode(force = false) { const store = readStore(); const profile = profileFromCapture(store, navigator.userAgent); if (!profile || !areClaimsUsable(store.claims) || syncing) return false; const sync = readSync(); const now = Date.now(); if (!force && !needsResync({ capturedAt: store.capturedAt ?? null, syncedTokenExp: sync.syncedTokenExp, currentTokenExp: store.claims?.exp ?? null, hasToken: true, lastSyncAttemptAt: sync.lastSyncAttemptAt, now })) return false; syncing = true; writeSync({ lastSyncAttemptAt: now }); try { const status = await request("POST", TOKEN_ENDPOINT_PATH, JSON.stringify(profile)); if (status >= 200 && status < 300) { writeSync({ syncedTokenExp: store.claims?.exp ?? null, syncedTones: profile.observedTones ?? [], lastSyncedAt: Date.now(), lastError: null, vscodeReachable: true }); return true; } writeSync({ lastError: status ? `HTTP ${status}` : "unreachable", vscodeReachable: status !== 0 }); return false; } finally { syncing = false; } } async function checkHealth() { const status = await request("GET", HEALTH_ENDPOINT_PATH); const reachable = status >= 200 && status < 300; if (readSync().vscodeReachable !== reachable) writeSync({ vscodeReachable: reachable }); if (reachable) await syncToVSCode(); } /** * The page renews its own token while it is in use; a tab left in the * background can sit on an expired one. Reloading it (only while hidden, at * most every 10 min across tabs, never right after it loaded) makes MSAL * fetch a new one — the same last resort the browser extension uses. */ function maybeReloadToRenew() { if (!IS_TOP || !readUi().autoReload || document.visibilityState !== "hidden") return; const store = readStore(); const hadSession = Boolean(store.endpoint || store.invocationTemplate); const expired = !store.accessToken || !areClaimsUsable(store.claims); if (!hadSession || !expired || Date.now() - LOADED_AT < MIN_PAGE_AGE_MS) return; const { lastReloadAt } = readSync(); if (lastReloadAt && Date.now() - lastReloadAt < RELOAD_EVERY_MS) return; writeSync({ lastReloadAt: Date.now() }); location.reload(); } let missingDelay = SCAN_MISSING_MS; function tick() { try { scanStorages(); pruneExpiredToken(); maybeReloadToRenew(); } catch {} const next = nextScan(readStore(), missingDelay); missingDelay = next.missingDelay; setTimeout(tick, next.delay); } const FONT = `'Segoe UI Variable Text', 'Segoe UI', -apple-system, system-ui, sans-serif`; const STYLE = ` :host { all: initial; } .panel, .mini { box-sizing: border-box; z-index: 2147483647; position: fixed; right: 16px; bottom: 16px; background: #fff; color: #242424; border: 1px solid #e0e0e0; border-radius: 8px; font: 13px/18px ${FONT}; -webkit-font-smoothing: antialiased; } .panel *, .mini * { box-sizing: border-box; } .panel { width: 296px; padding: 10px 16px 16px; box-shadow: 0 8px 16px rgba(0,0,0,.14), 0 0 2px rgba(0,0,0,.12); } .head { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 4px 0; color: #424242; font-size: 12px; font-weight: 600; } .icons { display: flex; gap: 2px; } .icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: #616161; cursor: pointer; } .icon:hover { background: #f5f5f5; color: #242424; } .icon:active { background: #e0e0e0; } .art { color: #616161; margin: 4px 8px; } .status { text-align: center; margin: 4px 0 12px; } .status-title { font-size: 15px; line-height: 20px; font-weight: 600; } .status-title[data-busy] { background: linear-gradient(90deg, #242424 0 35%, #0f6cbd 50%, #242424 65% 100%); background-size: 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: us-shimmer 2.6s ease-in-out infinite; } .status-sub { margin-top: 2px; color: #616161; font-size: 12px; line-height: 16px; } .rows { margin: 0 0 12px; padding: 0; border: 1px solid #ebebeb; border-radius: 8px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 0 12px; } .row + .row { border-top: 1px solid #ebebeb; } .row dt { color: #424242; } .row dd { margin: 0; display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; } .dot { width: 8px; height: 8px; border-radius: 50%; background: #bdbdbd; } .dot[data-tone="ok"] { background: #107c10; } .dot[data-tone="warn"] { background: #bc4b09; } .dot[data-tone="bad"] { background: #c50f1f; } .buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } button.act { min-height: 32px; padding: 5px 12px; border: 1px solid #d1d1d1; border-radius: 4px; background: #fff; color: #242424; font: 600 13px/18px ${FONT}; cursor: pointer; transition: background .1s, border-color .1s; } button.act:hover:enabled { background: #f5f5f5; border-color: #c7c7c7; } button.act:active:enabled { background: #e0e0e0; } button.primary { border-color: transparent; background: #0f6cbd; color: #fff; } button.primary:hover:enabled { background: #115ea3; border-color: transparent; } button.primary:active:enabled { background: #0c3b5e; } button.act:disabled { border-color: #f0f0f0; background: #f0f0f0; color: #bdbdbd; cursor: default; } button.wide { grid-column: 1 / -1; } button:focus-visible { outline: 2px solid #242424; outline-offset: 1px; } .mini { width: 136px; padding: 8px 10px 6px; color: #616161; cursor: pointer; box-shadow: 0 4px 8px rgba(0,0,0,.14), 0 0 2px rgba(0,0,0,.12); transition: box-shadow .15s; } .mini:hover { box-shadow: 0 8px 16px rgba(0,0,0,.14), 0 0 2px rgba(0,0,0,.12); } .hidden { display: none !important; } @keyframes us-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } } @media (prefers-reduced-motion: reduce) { .status-title[data-busy] { animation: none; background: none; color: #242424; } }`; const ICON_MINIMIZE = ""; const ICON_CLOSE = ""; function mountUi() { if (!IS_TOP || document.getElementById("m365copilot-grabber")) return; const host = document.createElement("div"); host.id = "m365copilot-grabber"; const root = host.attachShadow ? host.attachShadow({ mode: "open" }) : host; const style = document.createElement("style"); style.textContent = STYLE; const panel = document.createElement("div"); panel.className = "panel"; const mini = document.createElement("div"); mini.className = "mini"; panel.innerHTML = [ "
", ``, `
`, `
${connectionArtSvg({ idPrefix: "us-art", title: T.art })}
`, "
", "
", ...[ "token", "vscode", "sync" ].map((row) => `
`), "
", "
", "", "", "", "
" ].join(""); mini.innerHTML = connectionArtSvg({ idPrefix: "us-mini", title: T.expand }); mini.title = T.expand; const q = (selector) => panel.querySelector(selector); for (const node of Array.from(panel.querySelectorAll("[data-text]"))) node.textContent = T[node.getAttribute("data-text")]; for (const [action, label] of [["minimize", T.minimize], ["close", T.close]]) { const button = q(`[data-action="${action}"]`); button.title = label; button.setAttribute("aria-label", label); } const sendButton = q("[data-action=\"send\"]"); const tokenButton = q("[data-action=\"token\"]"); const profileButton = q("[data-action=\"profile\"]"); tokenButton.textContent = T.copyToken; profileButton.textContent = T.copyProfile; root.appendChild(style); root.appendChild(panel); root.appendChild(mini); (document.body || document.documentElement).appendChild(host); const flash = (button, text, restore) => { button.textContent = text; setTimeout(restore, 1600); }; function render() { const store = readStore(); const sync = readSync(); const hasToken = Boolean(store.accessToken); const usable = hasToken && areClaimsUsable(store.claims); const state = connectionState({ hasToken, expired: hasToken && !usable, vscodeConnected: sync.vscodeReachable === true }); panel.dataset.state = state; mini.dataset.state = state; const { minimized } = readUi(); panel.classList.toggle("hidden", minimized); mini.classList.toggle("hidden", !minimized); const [title, sub] = { waiting: [T.waitingTitle, T.waitingSub], captured: [T.capturedTitle, T.capturedSub], connected: [T.connectedTitle, T.connectedSub], warning: [T.warningTitle, T.warningSub] }[state]; q(".status-title").textContent = title; q(".status-title").toggleAttribute("data-busy", state === "waiting"); q(".status-sub").textContent = sub; const exp = store.claims?.exp; const minutes = typeof exp === "number" ? Math.round((exp * 1e3 - Date.now()) / 6e4) : null; q("[data-value=\"token\"]").textContent = !hasToken ? T.tokenNone : !usable ? T.tokenExpired : minutes === null ? "✓" : fill(T.tokenLeft, minutes); q("[data-value=\"vscode\"]").textContent = sync.vscodeReachable === null ? T.vscodeChecking : sync.vscodeReachable ? T.vscodeOk : T.vscodeDown; const ago = sync.lastSyncedAt ? Math.floor((Date.now() - sync.lastSyncedAt) / 6e4) : null; q("[data-value=\"sync\"]").textContent = ago === null ? T.syncNever : ago < 1 ? T.syncJustNow : fill(T.syncAgo, ago); const tones = { token: !hasToken ? "neutral" : usable ? "ok" : "warn", vscode: sync.vscodeReachable === null ? "neutral" : sync.vscodeReachable ? "ok" : "bad", sync: ago === null ? "neutral" : "ok" }; for (const [row, tone] of Object.entries(tones)) q(`[data-dot="${row}"]`).dataset.tone = tone; if (!sendButton.dataset.flashing) sendButton.textContent = state === "warning" ? T.renew : T.send; sendButton.disabled = !hasToken && state !== "warning"; tokenButton.disabled = !usable; profileButton.disabled = !usable; } q("[data-action=\"close\"]").addEventListener("click", () => host.remove()); q("[data-action=\"minimize\"]").addEventListener("click", () => { writeUi({ minimized: true }); render(); }); mini.addEventListener("click", () => { writeUi({ minimized: false }); render(); }); sendButton.addEventListener("click", async () => { if (panel.dataset.state === "warning") { location.reload(); return; } sendButton.dataset.flashing = "1"; const ok = await syncToVSCode(true); render(); flash(sendButton, ok ? T.sent : T.sendFailed, () => { delete sendButton.dataset.flashing; render(); }); }); tokenButton.addEventListener("click", () => { const token = readStore().accessToken; if (!token || !copy(token)) return; flash(tokenButton, T.copied, () => tokenButton.textContent = T.copyToken); }); profileButton.addEventListener("click", () => { const profile = profileFromCapture(readStore(), navigator.userAgent); if (!profile || !copy(JSON.stringify(profile, null, 2))) return; flash(profileButton, T.copied, () => profileButton.textContent = T.copyProfile); }); render(); for (const key of [ CAPTURE_STORE_KEY, SYNC_KEY, UI_KEY ]) try { GM_addValueChangeListener(key, () => render()); } catch {} setInterval(render, 15e3); } function copy(text) { try { GM_setClipboard(text, "text"); return true; } catch { try { navigator.clipboard.writeText(text); return true; } catch { return false; } } } function registerMenu() { if (!IS_TOP) return; try { GM_registerMenuCommand(T.menuShow, () => writeUi({ minimized: false })); GM_registerMenuCommand(T.menuSend, () => void syncToVSCode(true)); GM_registerMenuCommand(readUi().autoReload ? T.menuAutoReloadOn : T.menuAutoReloadOff, () => writeUi({ autoReload: !readUi().autoReload })); GM_registerMenuCommand(T.openM365, () => window.open(M365_CHAT_URL, "_blank")); } catch {} } installHooks(); scanStorages(); setTimeout(tick, missingDelay); for (const [target, event] of [ [document, "visibilitychange"], [window, "focus"], [PAGE, "storage"] ]) target.addEventListener(event, () => { if (event === "visibilitychange" && document.visibilityState !== "visible") return; try { scanStorages(); } catch {} }); if (IS_TOP) { registerMenu(); try { GM_addValueChangeListener(CAPTURE_STORE_KEY, () => void syncToVSCode(hasUnsentChanges())); } catch {} checkHealth(); setInterval(() => void checkHealth(), HEALTH_EVERY_MS); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mountUi, { once: true }); else mountUi(); } //#endregion })();