Add desktop (Electron) packaging and release distribution (#1147)
* Add desktop packaging workflow * Add desktop package homepage * Fix desktop default credential prompt * Suppress default credential prompt on desktop * Publish desktop artifacts on release; reduce CI to PR smoke test Add desktop-release.yml triggered on release publish (mirroring docker-publish.yml) to build all platforms and upload .dmg/.exe/ .AppImage/.deb to the GitHub Release. Trim build.yml desktop job to a PR-only Linux x64 smoke test, since release artifacts are now produced by desktop-release.yml. This drops per-push and macOS/Windows packaging from regular CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix desktop Hermes data home on Windows --------- Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { app, BrowserWindow, Menu, shell, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
||||
import { hermesBinExists, hermesBin } from './paths'
|
||||
import { initAutoUpdater } from './updater'
|
||||
|
||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8648
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let serverUrl: string | null = null
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
title: 'Hermes Desktop',
|
||||
backgroundColor: '#1a1a1a',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '..', 'preload', 'index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
// External links → system browser
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
|
||||
return { action: 'allow' }
|
||||
}
|
||||
shell.openExternal(url).catch(() => undefined)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// If the Web UI server is already up (re-opening window after close on
|
||||
// macOS), go straight to it. Otherwise show a loading splash; bootstrap()
|
||||
// will swap in the real URL once the server is ready.
|
||||
if (serverUrl) {
|
||||
mainWindow.loadURL(serverUrl)
|
||||
} else {
|
||||
mainWindow.loadURL(splashHtml())
|
||||
}
|
||||
}
|
||||
|
||||
function splashHtml(): string {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Desktop</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
|
||||
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
|
||||
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
|
||||
.row{display:flex;gap:8px}
|
||||
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
|
||||
.label{font-size:14px;color:#999}
|
||||
h1{font-weight:500;margin:0;font-size:18px}
|
||||
</style></head><body><div class="wrap">
|
||||
<h1>Hermes Desktop</h1>
|
||||
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||
<div class="label">Starting local services…</div>
|
||||
</div></body></html>`
|
||||
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (!hermesBinExists()) {
|
||||
console.error(`hermes binary missing at ${hermesBin()}`)
|
||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await startWebUiServer(PORT)
|
||||
serverUrl = url
|
||||
if (mainWindow) await mainWindow.loadURL(url)
|
||||
} catch (err) {
|
||||
console.error('Failed to start Web UI server:', err)
|
||||
if (mainWindow) {
|
||||
const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '')
|
||||
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
|
||||
`<html><body style="font-family:system-ui;padding:32px;background:#1a1a1a;color:#eee">
|
||||
<h2>Failed to start local services</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
|
||||
</body></html>`,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes-desktop:get-token', () => getToken())
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Drop the default File/Edit/View/Window menu on Windows/Linux. The web
|
||||
// UI provides its own in-page controls, so the native menu bar is just
|
||||
// visual clutter. macOS keeps a menu (system requirement) but Electron's
|
||||
// default is fine there.
|
||||
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
|
||||
createWindow()
|
||||
bootstrap()
|
||||
initAutoUpdater()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
} else if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', async (e) => {
|
||||
e.preventDefault()
|
||||
await stopWebUiServer().catch(() => undefined)
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { homedir, platform, arch } from 'node:os'
|
||||
|
||||
const isWin = platform() === 'win32'
|
||||
const osLabel = isWin ? 'win' : platform() === 'darwin' ? 'mac' : platform() // mac | linux | win
|
||||
const archLabel = arch() // arm64 | x64
|
||||
|
||||
export function isPackaged() {
|
||||
return app.isPackaged
|
||||
}
|
||||
|
||||
// Bundled web-ui directory.
|
||||
// dev: <repo root> (or HERMES_WEB_UI_DIR)
|
||||
// prod: <resources>/webui
|
||||
export function webuiDir(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'webui')
|
||||
return process.env.HERMES_WEB_UI_DIR?.trim() || resolve(app.getAppPath(), '..', '..')
|
||||
}
|
||||
|
||||
export function webuiServerEntry(): string {
|
||||
return join(webuiDir(), 'dist', 'server', 'index.js')
|
||||
}
|
||||
|
||||
// Bundled Python directory.
|
||||
// dev: packages/desktop/resources/python/<os>-<arch>
|
||||
// prod: <resources>/python
|
||||
export function pythonDir(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'python')
|
||||
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
|
||||
}
|
||||
|
||||
export function hermesBin(): string {
|
||||
const dir = pythonDir()
|
||||
return isWin ? join(dir, 'Scripts', 'hermes.exe') : join(dir, 'bin', 'hermes')
|
||||
}
|
||||
|
||||
export function hermesBinExists(): boolean {
|
||||
return existsSync(hermesBin())
|
||||
}
|
||||
|
||||
export function webUiHome(): string {
|
||||
return process.env.HERMES_WEB_UI_HOME?.trim() || resolve(homedir(), '.hermes-web-ui')
|
||||
}
|
||||
|
||||
export function hermesHome(): string {
|
||||
const override = process.env.HERMES_HOME?.trim()
|
||||
if (override) return resolve(override)
|
||||
|
||||
if (isWin) {
|
||||
const localAppData = process.env.LOCALAPPDATA?.trim() || process.env.APPDATA?.trim()
|
||||
if (localAppData) return resolve(localAppData, 'hermes')
|
||||
}
|
||||
|
||||
return resolve(homedir(), '.hermes')
|
||||
}
|
||||
|
||||
export function tokenFile(): string {
|
||||
return join(webUiHome(), '.token')
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { app, dialog } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initAutoUpdater() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
if (!app.isPackaged) return // dev mode: skip
|
||||
if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'true') return
|
||||
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('update-available', info => {
|
||||
console.log(`[updater] update available: ${info.version}`)
|
||||
})
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('[updater] up to date')
|
||||
})
|
||||
autoUpdater.on('error', err => {
|
||||
console.error('[updater] error:', err)
|
||||
})
|
||||
autoUpdater.on('update-downloaded', async info => {
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: `Hermes Desktop ${info.version} is ready to install.`,
|
||||
detail: 'Restart now to apply the update, or it will be installed on next quit.',
|
||||
buttons: ['Restart now', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
if (response === 0) autoUpdater.quitAndInstall()
|
||||
})
|
||||
|
||||
autoUpdater.checkForUpdates().catch(err => {
|
||||
console.error('[updater] initial check failed:', err)
|
||||
})
|
||||
|
||||
// Recheck every 6h while app is running
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch(() => undefined)
|
||||
}, 6 * 60 * 60 * 1000)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ChildProcess, spawn } from 'node:child_process'
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, delimiter, join } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { app } from 'electron'
|
||||
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
|
||||
|
||||
const DEFAULT_PORT = 8648
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
|
||||
let serverProc: ChildProcess | null = null
|
||||
let cachedToken: string | null = null
|
||||
|
||||
function ensureToken(): string {
|
||||
if (cachedToken) return cachedToken
|
||||
const file = tokenFile()
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
if (existsSync(file)) {
|
||||
cachedToken = readFileSync(file, 'utf-8').trim()
|
||||
if (cachedToken) return cachedToken
|
||||
}
|
||||
cachedToken = randomBytes(32).toString('hex')
|
||||
writeFileSync(file, cachedToken + '\n', { mode: 0o600 })
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
// node-pty ships per-platform prebuilds with a `spawn-helper` binary that
|
||||
// loses its +x bit when copied across some filesystems. Restore it.
|
||||
function ensureNativeModules() {
|
||||
try {
|
||||
const helper = join(
|
||||
webuiDir(),
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'spawn-helper',
|
||||
)
|
||||
if (existsSync(helper)) chmodSync(helper, 0o755)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string {
|
||||
return ensureToken()
|
||||
}
|
||||
|
||||
export function getServerUrl(port = DEFAULT_PORT): string {
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
ensureNativeModules()
|
||||
const token = ensureToken()
|
||||
const entry = webuiServerEntry()
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Web UI server entry not found at ${entry}. Run: npm run build:webui`)
|
||||
}
|
||||
|
||||
const home = webUiHome()
|
||||
const agentHome = hermesHome()
|
||||
mkdirSync(home, { recursive: true })
|
||||
mkdirSync(agentHome, { recursive: true })
|
||||
|
||||
// Tell agent-bridge to use the bundled Python directly. Otherwise the
|
||||
// bridge auto-detects Python from HERMES_BIN's shebang — which on our
|
||||
// setup is a #!/bin/sh wrapper, not a python interpreter, so detection
|
||||
// resolves to /bin/sh and the bridge crashes (exit code 2) immediately.
|
||||
const isWin = process.platform === 'win32'
|
||||
const bundledPython = isWin
|
||||
? join(pythonDir(), 'python.exe')
|
||||
: join(pythonDir(), 'bin', 'python3')
|
||||
|
||||
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: 'production',
|
||||
HERMES_DESKTOP: 'true',
|
||||
HERMES_BIN: hermesBin(),
|
||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||
HERMES_AGENT_ROOT: pythonDir(),
|
||||
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
||||
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
|
||||
// identically and avoids the issue cross-platform.
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT: 'tcp://127.0.0.1:18765',
|
||||
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
|
||||
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
||||
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
|
||||
// And for preview-mode bridges spawned by the in-app update controller.
|
||||
HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT: 'tcp',
|
||||
// Suppress the npm-registry update prompt (upstream #1105). hermes-web-ui
|
||||
// is bundled here; users can't `npm i -g` to upgrade, they have to wait
|
||||
// for the wrapper app to ship a new release.
|
||||
HERMES_WEB_UI_DISABLE_UPDATE_CHECK: 'true',
|
||||
// Single-user desktop install: open the gateway's user allowlist by
|
||||
// default. Otherwise the gateway silently drops every inbound platform
|
||||
// message (DingTalk/Slack/Telegram) with a startup warning. Users can
|
||||
// still override by setting GATEWAY_ALLOW_ALL_USERS=false in their
|
||||
// HERMES_HOME/.env or by configuring per-platform allowlists.
|
||||
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
|
||||
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
|
||||
// on the same data directory. Native Windows uses %LOCALAPPDATA%\hermes;
|
||||
// macOS/Linux keep the standard ~/.hermes layout.
|
||||
HERMES_HOME: agentHome,
|
||||
HERMES_WEB_UI_HOME: home,
|
||||
AUTH_TOKEN: token,
|
||||
PORT: String(port),
|
||||
// Prepend bundled Python's bin to PATH so any incidental `python` resolution lands on ours
|
||||
PATH: [dirname(hermesBin()), process.env.PATH].filter(Boolean).join(delimiter),
|
||||
}
|
||||
|
||||
serverProc = spawn(process.execPath, [entry], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
serverProc.stdout?.on('data', (chunk: Buffer) => {
|
||||
process.stdout.write(`[webui] ${chunk}`)
|
||||
})
|
||||
serverProc.stderr?.on('data', (chunk: Buffer) => {
|
||||
process.stderr.write(`[webui] ${chunk}`)
|
||||
})
|
||||
serverProc.on('exit', (code, signal) => {
|
||||
console.error(`[webui] server exited code=${code} signal=${signal}`)
|
||||
serverProc = null
|
||||
if (!app.isReady() || code !== 0) {
|
||||
// Best-effort: if server dies abnormally during startup, surface to user
|
||||
}
|
||||
})
|
||||
|
||||
await waitForReady(port, READY_TIMEOUT_MS)
|
||||
return getServerUrl(port)
|
||||
}
|
||||
|
||||
async function waitForReady(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const url = `http://127.0.0.1:${port}/api/health`
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(1000) })
|
||||
if (res.ok || res.status === 401) return // 401 = up but auth-gated, server is alive
|
||||
} catch {
|
||||
/* not ready yet */
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
throw new Error(`Web UI server did not become ready within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
export function stopWebUiServer(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (!serverProc || serverProc.killed) return resolve()
|
||||
const proc = serverProc
|
||||
const timer = setTimeout(() => {
|
||||
try { proc.kill('SIGKILL') } catch { /* */ }
|
||||
resolve()
|
||||
}, 3000)
|
||||
proc.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
try { proc.kill('SIGTERM') } catch { resolve() }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'),
|
||||
platform: process.platform,
|
||||
isDesktop: true,
|
||||
})
|
||||
|
||||
const API_KEY_LS = 'hermes_api_key'
|
||||
const DEFAULT_USERNAME = 'admin'
|
||||
const DEFAULT_PASSWORD = '123456'
|
||||
|
||||
// Auto-login the bundled web UI so users don't see a login screen on launch.
|
||||
// We POST to /api/auth/login with the well-known default credentials, using
|
||||
// the server's AUTH_TOKEN as the bearer (the server requires *some* auth on
|
||||
// /api/auth/login from a packaged client). The returned JWT is dropped into
|
||||
// localStorage where the Vue client expects it.
|
||||
async function autoLogin(token: string): Promise<void> {
|
||||
if (localStorage.getItem(API_KEY_LS)) return
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const body = await res.json().catch(() => null) as { token?: string; jwt?: string } | null
|
||||
const jwt = body?.token || body?.jwt
|
||||
if (jwt) localStorage.setItem(API_KEY_LS, jwt)
|
||||
} catch {
|
||||
/* ignore — first-load race or server still starting */
|
||||
}
|
||||
}
|
||||
|
||||
// Silently strip the "你必须修改默认密码" flag from /api/auth/me responses on
|
||||
// desktop. Users on a single-machine install don't benefit from a managed
|
||||
// password. The Web UI client uses BOTH fetch and axios (which goes through
|
||||
// XMLHttpRequest), so we patch both code paths.
|
||||
function isAuthMeUrl(url: string): boolean {
|
||||
return /\/api\/auth\/me(?:\?|$)/.test(url)
|
||||
}
|
||||
|
||||
function stripCredentialFlag(text: string): string {
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
if (data?.user && data.user.requiresCredentialChange) {
|
||||
data.user.requiresCredentialChange = false
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return text
|
||||
}
|
||||
|
||||
function installFetchPatch(): void {
|
||||
const origFetch = window.fetch.bind(window)
|
||||
window.fetch = async (input, init) => {
|
||||
const res = await origFetch(input, init)
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : (input as Request).url
|
||||
if (url && isAuthMeUrl(url) && res.ok) {
|
||||
const text = await res.clone().text()
|
||||
const patched = stripCredentialFlag(text)
|
||||
if (patched !== text) {
|
||||
return new Response(patched, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return res
|
||||
}
|
||||
|
||||
const OrigXHR = window.XMLHttpRequest
|
||||
type XHRWithDesktop = XMLHttpRequest & { __hermesDesktopUrl?: string }
|
||||
const origOpen = OrigXHR.prototype.open
|
||||
OrigXHR.prototype.open = function (
|
||||
this: XHRWithDesktop,
|
||||
method: string,
|
||||
url: string | URL,
|
||||
...rest: unknown[]
|
||||
) {
|
||||
this.__hermesDesktopUrl = String(url)
|
||||
// @ts-expect-error — forwarding variadic
|
||||
return origOpen.call(this, method, url, ...rest)
|
||||
}
|
||||
const origGetResponse = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'response')
|
||||
const origGetResponseText = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'responseText')
|
||||
if (origGetResponse?.get && origGetResponseText?.get) {
|
||||
Object.defineProperty(OrigXHR.prototype, 'responseText', {
|
||||
configurable: true,
|
||||
get(this: XHRWithDesktop) {
|
||||
const raw = origGetResponseText.get!.call(this) as string
|
||||
if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl) && typeof raw === 'string') {
|
||||
return stripCredentialFlag(raw)
|
||||
}
|
||||
return raw
|
||||
},
|
||||
})
|
||||
Object.defineProperty(OrigXHR.prototype, 'response', {
|
||||
configurable: true,
|
||||
get(this: XHRWithDesktop) {
|
||||
const raw = origGetResponse.get!.call(this)
|
||||
if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl)) {
|
||||
if (typeof raw === 'string') return stripCredentialFlag(raw)
|
||||
if (raw && typeof raw === 'object' && (raw as { user?: { requiresCredentialChange?: boolean } }).user?.requiresCredentialChange) {
|
||||
return { ...(raw as object), user: { ...(raw as { user: object }).user, requiresCredentialChange: false } }
|
||||
}
|
||||
}
|
||||
return raw
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
installFetchPatch()
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const token = await ipcRenderer.invoke('hermes-desktop:get-token')
|
||||
if (token) {
|
||||
try { localStorage.setItem('AUTH_TOKEN', token) } catch { /* */ }
|
||||
await autoLogin(token)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user