| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546 |
- import { app, shell, session, protocol, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, dialog, screen, type WebContents } from 'electron'
- import { basename, extname, join } from 'path'
- import { fileURLToPath } from 'url'
- import { autoUpdater } from 'electron-updater'
- import { electronApp, optimizer, is } from '@electron-toolkit/utils'
- import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs'
- import { writeFile } from 'fs/promises'
- // --- 日志管理 ---
- let logDir: string = ''
- function ensureLogDir(): void {
- if (!logDir) {
- logDir = join(app.getPath('userData'), 'logs')
- }
- if (!existsSync(logDir)) {
- mkdirSync(logDir, { recursive: true })
- }
- }
- function writeLog(message: string) {
- ensureLogDir()
- const date = new Date()
- const fileName = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}.log`
- const logPath = join(logDir, fileName)
- try {
- appendFileSync(logPath, message + '\n', 'utf-8')
- } catch (err) {
- console.error('Failed to write log:', err)
- }
- }
- /** 渲染进程 `<img>` 加载 userData 下启动台图标;须在 app.ready 前登记 */
- protocol.registerSchemesAsPrivileged([
- {
- scheme: 'launchpad-cache',
- privileges: {
- standard: true,
- secure: true,
- supportFetchAPI: true,
- corsEnabled: true
- }
- }
- ])
- ipcMain.on('log-message', (_, message) => {
- writeLog(message)
- })
- let mainWindow: BrowserWindow | null = null
- let tray: Tray | null = null
- let isQuitting = false
- let blinkInterval: NodeJS.Timeout | null = null
- let trayPopupWindow: BrowserWindow | null = null
- let trayPopupHideTimer: NodeJS.Timeout | null = null
- let trayHoverCheckTimer: NodeJS.Timeout | null = null
- interface UnreadInfo {
- name: string
- content: string
- count: number
- }
- const unreadMap = new Map<number, UnreadInfo>()
- /** 渲染进程通过 GET /messages/unread-count 同步的总未读数(标题/任务栏角标);未同步时回退为 unreadMap 累加 */
- let appUnreadTotalFromApi: number | null = null
- function isHttpUrl(url: string): boolean {
- try {
- const u = new URL(url.trim())
- return u.protocol === 'http:' || u.protocol === 'https:'
- } catch {
- return false
- }
- }
- /** 接口可能返回 number,与 IPC 载荷对齐为 string,供 appId 复用标签 */
- function normalizeInternalBrowserAppId(raw: unknown): string | undefined {
- if (typeof raw === 'string' && raw.length > 0) return raw
- if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw)
- return undefined
- }
- const LAUNCHPAD_ICON_SUBDIR = 'launchpad-icons'
- function getLaunchpadIconCacheDir(): string {
- return join(app.getPath('userData'), LAUNCHPAD_ICON_SUBDIR)
- }
- function toSafeLaunchpadAppId(raw: string): string | null {
- const s = String(raw || '').trim()
- if (s.length === 0 || s.length > 128) return null
- if (!/^[a-zA-Z0-9_-]+$/.test(s)) return null
- return s
- }
- interface LaunchpadIconMeta {
- icon_object_key: string | null
- last_icon_url: string
- imageBasename: string
- savedAt: string
- }
- function extFromContentType(ct: string | null | undefined): string {
- if (!ct) return '.bin'
- const low = ct.split(';')[0].trim().toLowerCase()
- if (low.includes('png')) return '.png'
- if (low.includes('jpeg') || low.includes('jpg')) return '.jpg'
- if (low.includes('gif')) return '.gif'
- if (low.includes('webp')) return '.webp'
- if (low.includes('svg')) return '.svg'
- return '.bin'
- }
- function extFromImageBuffer(buf: Buffer): string {
- if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return '.png'
- if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return '.jpg'
- const head = buf.length >= 6 ? buf.toString('ascii', 0, 6) : ''
- if (head === 'GIF87a' || head === 'GIF89a') return '.gif'
- if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return '.webp'
- if (buf.length >= 100) {
- const sniff = buf.toString('utf8', 0, Math.min(200, buf.length)).toLowerCase()
- if (sniff.includes('<svg')) return '.svg'
- }
- return '.bin'
- }
- function clearLaunchpadIconFilesSync(safeId: string): void {
- const dir = getLaunchpadIconCacheDir()
- if (!existsSync(dir)) return
- for (const f of readdirSync(dir)) {
- if (f === `${safeId}.meta.json` || (f.startsWith(`${safeId}.`) && f !== `${safeId}.meta.json`)) {
- try {
- unlinkSync(join(dir, f))
- } catch {
- // ignore
- }
- }
- }
- }
- function isLaunchpadCacheCurrent(
- meta: LaunchpadIconMeta | null,
- iconUrl: string,
- iconObjectKey: string | null
- ): boolean {
- if (!meta) return false
- if (iconObjectKey != null) {
- return (meta.icon_object_key || null) === iconObjectKey
- }
- return (meta.last_icon_url || '') === iconUrl
- }
- type LaunchpadIconResolveResult = { kind: 'custom'; url: string } | { kind: 'placeholder' }
- const launchpadIconInflight = new Map<string, Promise<LaunchpadIconResolveResult>>()
- function launchpadIconInflightKey(safeId: string, iconUrl: string, iconObjectKey: string | null): string {
- return `${safeId}\n${iconUrl}\n${iconObjectKey ?? ''}`
- }
- async function resolveLaunchpadIconOnce(
- safeId: string,
- iconUrl: string,
- iconObjectKey: string | null
- ): Promise<LaunchpadIconResolveResult> {
- const dir = getLaunchpadIconCacheDir()
- mkdirSync(dir, { recursive: true })
- const metaPath = join(dir, `${safeId}.meta.json`)
- let meta: LaunchpadIconMeta | null = null
- try {
- if (existsSync(metaPath)) {
- meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as LaunchpadIconMeta
- }
- } catch {
- meta = null
- }
- const base = meta?.imageBasename
- const imagePath =
- base && !base.includes('..') && !base.includes('/') && !base.includes('\\') && base.startsWith(`${safeId}.`)
- ? join(dir, base)
- : null
- if (imagePath && existsSync(imagePath) && isLaunchpadCacheCurrent(meta, iconUrl, iconObjectKey)) {
- return { kind: 'custom', url: `launchpad-cache://${safeId}/icon` }
- }
- clearLaunchpadIconFilesSync(safeId)
- const res = await fetch(iconUrl)
- if (!res.ok) {
- throw new Error(`download icon HTTP ${res.status}`)
- }
- const buf = Buffer.from(await res.arrayBuffer())
- let ext = extFromContentType(res.headers.get('content-type'))
- if (ext === '.bin') ext = extFromImageBuffer(buf)
- const imageBasename = `${safeId}${ext}`
- const outPath = join(dir, imageBasename)
- await writeFile(outPath, buf)
- const nextMeta: LaunchpadIconMeta = {
- icon_object_key: iconObjectKey,
- last_icon_url: iconUrl,
- imageBasename,
- savedAt: new Date().toISOString()
- }
- writeFileSync(metaPath, JSON.stringify(nextMeta), 'utf-8')
- return { kind: 'custom', url: `launchpad-cache://${safeId}/icon` }
- }
- async function launchpadIconResolveHandler(
- appId: string,
- iconUrlRaw: string | null | undefined,
- iconObjectKeyRaw: string | null | undefined
- ): Promise<LaunchpadIconResolveResult> {
- const iconUrl = typeof iconUrlRaw === 'string' ? iconUrlRaw.trim() : ''
- if (!iconUrl || !isHttpUrl(iconUrl)) {
- return { kind: 'placeholder' }
- }
- const safeId = toSafeLaunchpadAppId(appId)
- if (!safeId) {
- return { kind: 'placeholder' }
- }
- const iconObjectKey =
- typeof iconObjectKeyRaw === 'string' && iconObjectKeyRaw.trim() ? iconObjectKeyRaw.trim() : null
- const ikey = launchpadIconInflightKey(safeId, iconUrl, iconObjectKey)
- const existing = launchpadIconInflight.get(ikey)
- if (existing) return existing
- const p = resolveLaunchpadIconOnce(safeId, iconUrl, iconObjectKey).finally(() => {
- if (launchpadIconInflight.get(ikey) === p) {
- launchpadIconInflight.delete(ikey)
- }
- })
- launchpadIconInflight.set(ikey, p)
- return p
- }
- function registerLaunchpadIconProtocol(): void {
- const dir = getLaunchpadIconCacheDir()
- mkdirSync(dir, { recursive: true })
- try {
- if (session.defaultSession.protocol.isProtocolRegistered('launchpad-cache')) {
- session.defaultSession.protocol.unregisterProtocol('launchpad-cache')
- }
- } catch {
- // ignore
- }
- session.defaultSession.protocol.registerFileProtocol('launchpad-cache', (request, callback) => {
- try {
- const parsed = new URL(request.url)
- const safeId = parsed.hostname
- if (!toSafeLaunchpadAppId(safeId)) {
- callback({ error: -6 })
- return
- }
- const metaPath = join(dir, `${safeId}.meta.json`)
- if (!existsSync(metaPath)) {
- callback({ error: -6 })
- return
- }
- let meta: LaunchpadIconMeta
- try {
- meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as LaunchpadIconMeta
- } catch {
- callback({ error: -6 })
- return
- }
- const base = meta.imageBasename
- if (!base || base.includes('..') || base.includes('/') || base.includes('\\') || !base.startsWith(`${safeId}.`)) {
- callback({ error: -6 })
- return
- }
- const full = join(dir, base)
- if (!existsSync(full)) {
- callback({ error: -6 })
- return
- }
- callback({ path: full })
- } catch {
- callback({ error: -6 })
- }
- })
- }
- const STARTUP_PREFERENCE_FILE = 'startup-preference.json'
- interface StartupPreference {
- openAtLogin: boolean
- }
- function getStartupPreferencePath(): string {
- return join(app.getPath('userData'), STARTUP_PREFERENCE_FILE)
- }
- function readStartupPreference(): { openAtLogin: boolean; fileExists: boolean } {
- const p = getStartupPreferencePath()
- if (!existsSync(p)) {
- return { openAtLogin: true, fileExists: false }
- }
- try {
- const raw = readFileSync(p, 'utf-8')
- const j = JSON.parse(raw) as Partial<StartupPreference>
- return {
- openAtLogin: typeof j.openAtLogin === 'boolean' ? j.openAtLogin : true,
- fileExists: true
- }
- } catch {
- return { openAtLogin: true, fileExists: true }
- }
- }
- function writeStartupPreference(openAtLogin: boolean): void {
- try {
- const payload: StartupPreference = { openAtLogin }
- writeFileSync(getStartupPreferencePath(), JSON.stringify(payload, null, 0), 'utf-8')
- } catch (e) {
- console.error('writeStartupPreference', e)
- }
- }
- function applyOpenAtLoginToOS(openAtLogin: boolean): void {
- if (!app.isPackaged) return
- app.setLoginItemSettings({
- openAtLogin,
- path: app.getPath('exe')
- })
- }
- function initStartupFromPreference(): void {
- const { openAtLogin, fileExists } = readStartupPreference()
- if (!fileExists) {
- writeStartupPreference(true)
- applyOpenAtLoginToOS(true)
- } else {
- applyOpenAtLoginToOS(openAtLogin)
- }
- }
- function sanitizeFilename(name: string): string {
- return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 120) || 'file'
- }
- function extFromUrlOrData(url: string, fallback: string): string {
- if (url.startsWith('data:')) {
- const mime = /^data:([^;]+);/i.exec(url)?.[1]?.toLowerCase() || ''
- if (mime.includes('jpeg')) return '.jpg'
- if (mime.includes('png')) return '.png'
- if (mime.includes('gif')) return '.gif'
- if (mime.includes('webp')) return '.webp'
- if (mime.includes('mp4')) return '.mp4'
- if (mime.includes('webm')) return '.webm'
- return fallback
- }
- try {
- const pathname = new URL(url.trim()).pathname
- const e = extname(pathname).toLowerCase()
- if (e && e.length <= 8) return e
- } catch {
- // ignore
- }
- return fallback
- }
- /** 下载媒体到临时目录后用系统默认应用打开(避免内嵌 data: 预览阻塞主进程) */
- async function downloadMediaToTempAndOpen(url: string, suggestedName: string): Promise<void> {
- const base = sanitizeFilename(basename(suggestedName || 'media'))
- let ext = extname(base)
- if (!ext) {
- ext = extFromUrlOrData(url, '.bin')
- }
- const nameWithoutExt = base.replace(/\.[^.]+$/, '') || 'media'
- const fileName = `${nameWithoutExt}-${Date.now()}${ext}`
- const dir = join(app.getPath('temp'), 'yunzhu-im-open')
- mkdirSync(dir, { recursive: true })
- const filePath = join(dir, fileName)
- if (url.startsWith('data:')) {
- const comma = url.indexOf(',')
- if (comma === -1) throw new Error('无效的媒体数据')
- const header = url.slice(0, comma)
- const payload = url.slice(comma + 1)
- const isBase64 = /;base64/i.test(header)
- const buffer = isBase64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload), 'utf-8')
- await writeFile(filePath, buffer)
- } else if (url.startsWith('file://')) {
- const localPath = fileURLToPath(url)
- const err = await shell.openPath(localPath)
- if (err) throw new Error(err)
- return
- } else if (isHttpUrl(url)) {
- const res = await fetch(url)
- if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`)
- const buf = Buffer.from(await res.arrayBuffer())
- await writeFile(filePath, buf)
- } else {
- throw new Error('不支持的媒体地址')
- }
- const err = await shell.openPath(filePath)
- if (err) throw new Error(err)
- }
- /** 与统一登录/开放平台同一源,应用中心内嵌页使用的 localStorage 键为 `token` */
- const API_HNYUNZHU_ORIGIN = 'https://api.hnyunzhu.com'
- /** 应用中心内嵌标签页专用分区;与主窗口默认 session 隔离,登出时可整体 clearStorage 而不影响主应用 */
- const INTERNAL_BROWSER_PARTITION = 'persist:yunzhu-app-center'
- function getInternalBrowserSession() {
- return session.fromPartition(INTERNAL_BROWSER_PARTITION)
- }
- let browserApiHnyunzhuToken: string | null = null
- function isApiHnyunzhuUrl(url: string): boolean {
- if (!url || url === 'about:blank') return false
- try {
- return new URL(url).origin === API_HNYUNZHU_ORIGIN
- } catch {
- return false
- }
- }
- function injectTokenForApiOrigin(wc: WebContents): void {
- if (wc.isDestroyed()) return
- const url = wc.getURL()
- if (!isApiHnyunzhuUrl(url)) return
- if (browserApiHnyunzhuToken) {
- const safe = JSON.stringify(browserApiHnyunzhuToken)
- void wc.executeJavaScript(`try{localStorage.setItem('token',${safe});}catch(e){}`, true)
- } else {
- void wc.executeJavaScript(`try{localStorage.removeItem('token');}catch(e){}`, true)
- }
- }
- // --- 浏览器视图管理器 ---
- interface TabInfo {
- id: string
- url: string
- title: string
- view: WebContentsView
- /** 应用中心 SSO 打开时绑定,用于同一应用复用同一标签 */
- appId?: string
- }
- class ViewManager {
- private window: BrowserWindow
- private tabs: Map<string, TabInfo> = new Map()
- /** app_id → 标签 id(仅登记带 appId 的标签) */
- private appIdToTabId: Map<string, string> = new Map()
- private activeTabId: string | null = null
- private bounds: Electron.Rectangle = { x: 0, y: 78, width: 1024, height: 600 }
- constructor(window: BrowserWindow) {
- this.window = window
- }
- /**
- * 若提供 appId 且已有对应标签,则在该标签中加载 url;否则新建标签。
- */
- openOrNavigateTab(
- url: string,
- opts?: { appId?: string; activate?: boolean }
- ): { id: string; reused: boolean } {
- const activate = opts?.activate !== false
- const appId = opts?.appId
- if (appId) {
- const existingId = this.appIdToTabId.get(appId)
- if (existingId && this.tabs.has(existingId)) {
- if (activate) {
- this.switchTab(existingId)
- }
- try {
- this.tabs.get(existingId)!.view.webContents.loadURL(url)
- } catch (e) {
- console.error('Failed to load URL:', url, e)
- }
- return { id: existingId, reused: true }
- }
- if (existingId && !this.tabs.has(existingId)) {
- this.appIdToTabId.delete(appId)
- }
- }
- const id = this.createNewTab(url, activate, appId)
- if (appId) {
- this.appIdToTabId.set(appId, id)
- }
- return { id, reused: false }
- }
- createTab(url: string, active: boolean = true): string {
- return this.createNewTab(url, active, undefined)
- }
- private createNewTab(url: string, active: boolean, appId: string | undefined): string {
- const view = new WebContentsView({
- webPreferences: {
- partition: INTERNAL_BROWSER_PARTITION,
- sandbox: false,
- nodeIntegration: false,
- contextIsolation: true,
- }
- })
- const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
- view.webContents.on('did-start-loading', () => {
- if (!this.window.isDestroyed()) {
- this.window.webContents.send('tab-update', id, { isLoading: true })
- }
- })
- view.webContents.on('did-stop-loading', () => {
- if (!this.window.isDestroyed()) {
- this.window.webContents.send('tab-update', id, {
- isLoading: false,
- canGoBack: view.webContents.navigationHistory.canGoBack(),
- canGoForward: view.webContents.navigationHistory.canGoForward(),
- url: view.webContents.getURL(),
- title: view.webContents.getTitle()
- })
- }
- })
- view.webContents.on('page-title-updated', (_, title) => {
- if (!this.window.isDestroyed()) {
- this.window.webContents.send('tab-update', id, { title })
- }
- })
- view.webContents.on('did-navigate', (_, url) => {
- if (!this.window.isDestroyed()) {
- this.window.webContents.send('tab-update', id, { url })
- }
- })
- view.webContents.on('did-finish-load', () => {
- injectTokenForApiOrigin(view.webContents)
- })
- view.webContents.setWindowOpenHandler((details) => {
- if (!isHttpUrl(details.url)) {
- return { action: 'allow' }
- }
- const newId = this.createTab(details.url, true)
- if (!this.window.isDestroyed()) {
- this.window.webContents.send('tab-created', { id: newId, url: details.url, title: 'Loading...' })
- }
- return { action: 'deny' }
- })
- try {
- view.webContents.loadURL(url)
- } catch (e) {
- console.error('Failed to load URL:', url, e)
- }
- this.tabs.set(id, { id, url, title: 'Loading...', view, appId })
- if (active) {
- this.switchTab(id)
- }
- return id
- }
- switchTab(id: string) {
- const tab = this.tabs.get(id)
- if (!tab) return
- if (this.activeTabId) {
- const currentTab = this.tabs.get(this.activeTabId)
- if (currentTab) {
- this.window.contentView.removeChildView(currentTab.view)
- }
- }
- this.window.contentView.addChildView(tab.view)
- tab.view.setBounds(this.bounds)
- this.activeTabId = id
- }
- setBounds(bounds: Electron.Rectangle) {
- this.bounds = bounds
- if (this.activeTabId) {
- const tab = this.tabs.get(this.activeTabId)
- if (tab) {
- tab.view.setBounds(bounds)
- }
- }
- }
- goBack() {
- if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.navigationHistory.goBack()
- }
- goForward() {
- if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.navigationHistory.goForward()
- }
- reload() {
- if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.reload()
- }
- loadURL(id: string, url: string) {
- const tab = this.tabs.get(id)
- if (tab) {
- tab.view.webContents.loadURL(url)
- }
- }
- getTabSnapshotsForRenderer(): Array<{ id: string; url: string; title: string }> {
- return Array.from(this.tabs.values()).map((t) => ({
- id: t.id,
- url: t.view.webContents.getURL(),
- title: t.view.webContents.getTitle() || t.title || 'Loading...'
- }))
- }
- /** 对当前已打开且为 api 源的内嵌页同步/清除 `localStorage.token`(登入、换号、登出时调用) */
- syncTokenToAllTabs(): void {
- this.tabs.forEach((tab) => {
- try {
- if (!tab.view.webContents.isDestroyed()) {
- injectTokenForApiOrigin(tab.view.webContents)
- }
- } catch {
- // ignore
- }
- })
- }
- /** 全量清存储后刷新各标签,避免仍停留在旧页面内存态 */
- reloadAllTabs(): void {
- this.tabs.forEach((tab) => {
- try {
- if (!tab.view.webContents.isDestroyed()) {
- tab.view.webContents.reloadIgnoringCache()
- }
- } catch {
- // ignore
- }
- })
- }
- destroy() {
- this.tabs.forEach(tab => {
- try {
- this.window.contentView.removeChildView(tab.view)
- tab.view.webContents.close()
- } catch (e) { /* already destroyed */ }
- })
- this.tabs.clear()
- this.appIdToTabId.clear()
- }
- }
- function getResourcePath(fileName: string): string {
- if (app.isPackaged) {
- return join(process.resourcesPath, fileName)
- }
- return join(__dirname, '../../resources', fileName)
- }
- // 获取图标路径的函数 - Windows 优先使用 ICO 文件
- function getIconPath(): string {
- if (process.platform === 'win32') {
- // Windows 优先使用包含多尺寸的 ICO 文件
- const icoPath = getResourcePath('icon.ico')
- if (existsSync(icoPath)) {
- return icoPath
- }
- }
- // 其他平台或 Windows 没有 ICO 时使用 PNG
- return getResourcePath('logo.png')
- }
- function getTotalUnreadCount(): number {
- if (appUnreadTotalFromApi !== null) {
- return appUnreadTotalFromApi
- }
- let total = 0
- for (const info of unreadMap.values()) {
- total += info.count
- }
- return total
- }
- function updateTrayMenu(): void {
- if (!tray) return
- const totalUnread = getTotalUnreadCount()
- const contextMenu = Menu.buildFromTemplate([
- { label: totalUnread > 0 ? `未读消息 (${totalUnread})` : '无未读消息', enabled: false },
- { type: 'separator' },
- { label: '显示主界面', click: () => mainWindow?.show() },
- { label: '退出', click: () => { isQuitting = true; app.quit() } }
- ])
- tray.setContextMenu(contextMenu)
- tray.setToolTip('韫珠IM')
- }
- // --- 托盘悬浮弹窗 ---
- function getTrayPopupHTML(): string {
- return `<!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <style>
- *{margin:0;padding:0;box-sizing:border-box;}
- body{font-family:'Microsoft YaHei','PingFang SC',sans-serif;background:transparent;overflow:hidden;user-select:none;}
- .popup{background:#fff;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.18);overflow:hidden;display:flex;flex-direction:column;max-height:100vh;}
- .list{overflow-y:auto;max-height:calc(100vh - 40px);flex:1;}
- .list::-webkit-scrollbar{width:4px;}
- .list::-webkit-scrollbar-thumb{background:#ccc;border-radius:2px;}
- .msg-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;border-bottom:1px solid #f0f0f0;transition:background 0.15s;}
- .msg-item:last-child{border-bottom:none;}
- .msg-item:hover{background:#f5f5f5;}
- .msg-avatar-wrap{position:relative;width:40px;height:40px;flex-shrink:0;}
- .badge{position:absolute;top:-5px;right:-5px;background:#ff3b30;color:#fff;border-radius:10px;padding:0 5px;font-size:10px;min-width:16px;height:16px;line-height:16px;text-align:center;font-weight:normal;z-index:1;}
- .msg-info{margin-left:10px;overflow:hidden;flex:1;}
- .msg-name{font-size:14px;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;}
- .msg-content{font-size:12px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px;}
- .footer{text-align:center;padding:10px;color:#576b95;font-size:13px;cursor:pointer;border-top:1px solid #eee;flex-shrink:0;transition:background 0.15s;}
- .footer:hover{background:#f5f5f5;}
- .empty{padding:20px;text-align:center;color:#999;font-size:13px;}
- </style>
- </head>
- <body>
- <div class="popup">
- <div class="list" id="list"></div>
- <div class="footer" id="dismiss">暂不处理</div>
- </div>
- <script>
- (function(){
- const list = document.getElementById('list');
- const dismiss = document.getElementById('dismiss');
- const ipc = window.electron && window.electron.ipcRenderer;
- var GRADIENT_PAIRS = [
- ['#1e3a8a','#93c5fd'],['#166534','#86efac'],['#c2410c','#fdba74'],['#b91c1c','#fca5a5'],['#5b21b6','#c4b5fd'],
- ['#0f766e','#5eead4'],['#3730a3','#a5b4fc'],['#0d9488','#2dd4bf'],['#b45309','#fcd34d'],['#be123c','#fda4af'],
- ['#0369a1','#7dd3fc'],['#4d7c0f','#bef264'],['#86198f','#e879f9'],['#db2777','#fbcfe8'],['#047857','#6ee7b7'],
- ['#6d28d9','#ddd6fe'],['#1e40af','#93c5fd'],['#ea580c','#fed7aa'],['#0e7490','#99f6e4'],['#881337','#fbcfe8']
- ];
- function getAvatarText(name){
- var n = String(name || '').trim();
- if(!n) return '?';
- var cjk = (n.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
- var letterCount = n.replace(/\\s/g,'').length;
- var isCJK = letterCount > 0 && cjk >= letterCount / 2;
- if(isCJK){
- if(n.length <= 2) return n;
- return n.slice(-2);
- }
- var words = n.split(/\\s+/).filter(Boolean);
- if(words.length >= 2){
- var a = (words[0][0] || '').toUpperCase();
- var b = (words[1][0] || '').toUpperCase();
- return (a + b) || '?';
- }
- if(words.length === 1 && words[0].length >= 2) return words[0].slice(0,2).toUpperCase();
- if(words.length === 1 && words[0].length === 1) return words[0].toUpperCase();
- return '?';
- }
- function getGradientIndex(id, name){
- var s = String(id || '').trim() || String(name || '').trim();
- var hash = 0;
- for(var i = 0; i < s.length; i++){
- hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
- }
- return Math.abs(hash) % GRADIENT_PAIRS.length;
- }
- function getApplicationGradientIndex(name){
- var s = String(name || '').trim() || 'SYSTEM';
- var hash = 0;
- for(var i = 0; i < s.length; i++){
- hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
- }
- return Math.abs(hash) % GRADIENT_PAIRS.length;
- }
- function avatarBlockHtml(item){
- var displayName = item.name || '';
- var text = getAvatarText(displayName);
- var idx = item.id < 0 ? getApplicationGradientIndex(displayName) : getGradientIndex(item.id, displayName);
- var pair = GRADIENT_PAIRS[idx];
- var c1 = pair[0], c2 = pair[1];
- var style = 'width:40px;height:40px;border-radius:50%;overflow:hidden;background-color:'+c1+
- ';background-image:linear-gradient(135deg,'+c1+','+c2+');display:flex;align-items:center;justify-content:center;'+
- 'color:#fff;font-size:16px;font-weight:500;line-height:1;white-space:nowrap;font-family:\\'PingFang SC\\',Roboto,-apple-system,sans-serif;box-sizing:border-box;';
- var badgeText = item.count > 99 ? '99+' : String(item.count);
- return '<div class="msg-avatar-wrap">'+
- '<div style="'+style+'">'+escapeHtml(text)+'</div>'+
- '<div class="badge">'+escapeHtml(badgeText)+'</div>'+
- '</div>';
- }
- if(ipc){
- ipc.on('update-unread', function(_, items){
- list.innerHTML = '';
- if(!items || items.length === 0){
- list.innerHTML = '<div class="empty">暂无未读消息</div>';
- return;
- }
- items.forEach(function(item){
- var div = document.createElement('div');
- div.className = 'msg-item';
- div.innerHTML =
- avatarBlockHtml(item) +
- '<div class="msg-info">' +
- '<div class="msg-name">' + escapeHtml(item.name) + '</div>' +
- '<div class="msg-content">' + escapeHtml(item.content) + '</div>' +
- '</div>';
- div.addEventListener('click', function(){ ipc.send('tray-popup-click', item.id); });
- list.appendChild(div);
- });
- });
- }
- dismiss.addEventListener('click', function(){
- if(ipc) ipc.send('tray-popup-dismiss');
- });
- document.addEventListener('mouseleave', function(){
- if(ipc) ipc.send('tray-popup-mouse-leave');
- });
- document.addEventListener('mouseenter', function(){
- if(ipc) ipc.send('tray-popup-mouse-enter');
- });
- function escapeHtml(str){
- if(!str) return '';
- return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
- }
- })();
- </script>
- </body>
- </html>`
- }
- function createTrayPopup(): void {
- if (trayPopupWindow && !trayPopupWindow.isDestroyed()) return
- trayPopupWindow = new BrowserWindow({
- width: 320,
- height: 200,
- frame: false,
- transparent: true,
- alwaysOnTop: true,
- skipTaskbar: true,
- resizable: false,
- show: false,
- focusable: false,
- webPreferences: {
- preload: join(__dirname, '../preload/index.js'),
- sandbox: false,
- nodeIntegration: false,
- contextIsolation: true,
- }
- })
- trayPopupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(getTrayPopupHTML())}`)
- trayPopupWindow.on('closed', () => {
- trayPopupWindow = null
- })
- }
- function getUnreadListForPopup(): { id: number, name: string, content: string, count: number }[] {
- const list: { id: number, name: string, content: string, count: number }[] = []
- for (const [id, info] of unreadMap.entries()) {
- list.push({ id, name: info.name, content: info.content, count: info.count })
- }
- return list
- }
- function showTrayPopup(): void {
- if (!tray || unreadMap.size === 0) return
- if (!trayPopupWindow || trayPopupWindow.isDestroyed()) {
- createTrayPopup()
- }
- if (!trayPopupWindow) return
- if (trayPopupHideTimer) {
- clearTimeout(trayPopupHideTimer)
- trayPopupHideTimer = null
- }
- if (trayPopupWindow.isVisible()) {
- trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
- return
- }
- const trayBounds = tray.getBounds()
- const itemHeight = 61
- const footerHeight = 40
- const maxItems = 5
- const visibleItems = Math.min(unreadMap.size, maxItems)
- const popupHeight = visibleItems * itemHeight + footerHeight + 2
- const popupWidth = 320
- const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y })
- const workArea = display.workArea
- let x = Math.round(trayBounds.x - popupWidth / 2 + trayBounds.width / 2)
- let y: number
- if (trayBounds.y < workArea.y + workArea.height / 2) {
- y = trayBounds.y + trayBounds.height + 4
- } else {
- y = trayBounds.y - popupHeight - 4
- }
- if (x + popupWidth > workArea.x + workArea.width) {
- x = workArea.x + workArea.width - popupWidth - 4
- }
- if (x < workArea.x) {
- x = workArea.x + 4
- }
- trayPopupWindow.setSize(popupWidth, popupHeight)
- trayPopupWindow.setPosition(x, y)
- trayPopupWindow.webContents.once('did-finish-load', () => {
- if (trayPopupWindow && !trayPopupWindow.isDestroyed()) {
- trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
- }
- })
- if (trayPopupWindow.webContents.isLoading()) {
- // will send data in did-finish-load
- } else {
- trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
- }
- trayPopupWindow.showInactive()
- }
- function hideTrayPopup(): void {
- if (trayPopupHideTimer) {
- clearTimeout(trayPopupHideTimer)
- trayPopupHideTimer = null
- }
- if (trayHoverCheckTimer) {
- clearInterval(trayHoverCheckTimer)
- trayHoverCheckTimer = null
- }
- if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
- trayPopupWindow.hide()
- }
- }
- function scheduleTrayPopupHide(): void {
- if (trayPopupHideTimer) clearTimeout(trayPopupHideTimer)
- trayPopupHideTimer = setTimeout(() => {
- hideTrayPopup()
- }, 300)
- }
- function cancelTrayPopupHide(): void {
- if (trayPopupHideTimer) {
- clearTimeout(trayPopupHideTimer)
- trayPopupHideTimer = null
- }
- }
- function updateWindowTitle(): void {
- if (!mainWindow) return
- const totalUnread = getTotalUnreadCount()
- if (totalUnread > 0) {
- mainWindow.setTitle(`韫珠IM (${totalUnread} 条未读消息)`)
- } else {
- mainWindow.setTitle('韫珠IM')
- }
- }
- function updateTaskbarOverlay(): void {
- const totalUnread = getTotalUnreadCount()
- if (totalUnread > 0) {
- app.setBadgeCount(totalUnread)
- } else {
- app.setBadgeCount(0)
- }
- }
- function updateUnreadStatus(): void {
- updateTrayMenu()
- updateWindowTitle()
- updateTaskbarOverlay()
- if (unreadMap.size > 0) {
- startBlinking()
- } else {
- stopBlinking()
- hideTrayPopup()
- }
- if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
- trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
- }
- }
- function startBlinking(): void {
- if (blinkInterval) return
- let isEmpty = false
- const iconPath = getIconPath()
- const emptyIcon = nativeImage.createEmpty()
- const normalIcon = nativeImage.createFromPath(iconPath)
-
- blinkInterval = setInterval(() => {
- if (!tray) return
- if (isEmpty) {
- tray.setImage(normalIcon)
- } else {
- tray.setImage(emptyIcon)
- }
- isEmpty = !isEmpty
- }, 500)
- }
- function stopBlinking(): void {
- if (blinkInterval) {
- clearInterval(blinkInterval)
- blinkInterval = null
- }
- if (tray) {
- const iconPath = getIconPath()
- tray.setImage(nativeImage.createFromPath(iconPath))
- }
- }
- function createWindow(): void {
- // Create the browser window.
- const iconPath = getIconPath()
- mainWindow = new BrowserWindow({
- width: 1000,
- height: 700,
- show: false,
- resizable: true, // 与手动登录后一致,否则 Windows 标题栏不显示最大化;登录成功仍会 setSize/center
- title: '韫珠IM',
- icon: iconPath,
- autoHideMenuBar: true,
- titleBarStyle: 'hidden', // Hide title bar
- titleBarOverlay: {
- color: '#f7f9fc', // Match refreshed app shell
- symbolColor: '#64748b', // Match refreshed control color
- height: 30
- },
- webPreferences: {
- preload: join(__dirname, '../preload/index.js'),
- sandbox: false,
- nodeIntegration: false,
- contextIsolation: true,
- webSecurity: true
- }
- })
- mainWindow.on('ready-to-show', () => {
- mainWindow?.show()
- })
- mainWindow.on('close', (event) => {
- if (!isQuitting) {
- event.preventDefault()
- mainWindow?.hide()
- return false
- }
- return true
- })
-
- mainWindow.on('focus', () => {
- stopBlinking()
- // 主界面显示时清除任务栏徽章
- app.setBadgeCount(0)
- })
- mainWindow.on('show', () => {
- // 主界面显示时清除任务栏徽章
- app.setBadgeCount(0)
- })
- mainWindow.webContents.setWindowOpenHandler((details) => {
- // Intercept target="_blank" and open custom browser window
- if (isHttpUrl(details.url)) {
- createInternalBrowserWindow(details.url)
- return { action: 'deny' }
- }
- return { action: 'allow' }
- })
- // HMR for renderer base on electron-vite cli.
- // Load the remote URL for development or the local html file for production.
- if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
- mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
- } else {
- mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
- }
- }
- let browserWindow: BrowserWindow | null = null
- let viewManager: ViewManager | null = null
- /** 新建内置浏览器窗口时,首标签在渲染进程就绪后再同步,避免 tab-created 早于 listener */
- let pendingBrowserInitialUrl: { url: string; appId?: string } | null = null
- function sendBrowserTabOpenToRenderer(url: string, result: { id: string; reused: boolean }): void {
- if (!browserWindow || browserWindow.isDestroyed()) return
- if (result.reused) {
- browserWindow.webContents.send('tab-activate', result.id)
- } else {
- browserWindow.webContents.send('tab-created', { id: result.id, url, title: 'Loading...' })
- }
- }
- function flushPendingBrowserInitialTab(): void {
- if (!pendingBrowserInitialUrl || !viewManager || !browserWindow || browserWindow.isDestroyed()) return
- const { url, appId } = pendingBrowserInitialUrl
- pendingBrowserInitialUrl = null
- const result = viewManager.openOrNavigateTab(url, { appId, activate: true })
- sendBrowserTabOpenToRenderer(url, result)
- }
- function syncBrowserTabsToRenderer(): void {
- if (!viewManager || !browserWindow || browserWindow.isDestroyed()) return
- const snapshots = viewManager.getTabSnapshotsForRenderer()
- if (snapshots.length === 0) return
- browserWindow.webContents.send('browser-tabs-sync', snapshots)
- }
- // Function to create the custom browser window
- function createInternalBrowserWindow(targetUrl: string, appId?: string): void {
- if (browserWindow && !browserWindow.isDestroyed()) {
- if (browserWindow.isMinimized()) browserWindow.restore()
- browserWindow.show()
- browserWindow.focus()
- if (viewManager) {
- const result = viewManager.openOrNavigateTab(targetUrl, { appId, activate: true })
- sendBrowserTabOpenToRenderer(targetUrl, result)
- }
- return
- }
- const iconPath = getIconPath()
- browserWindow = new BrowserWindow({
- width: 1024,
- height: 768,
- title: '应用中心', // Built-in Browser
- icon: iconPath,
- autoHideMenuBar: true, // Show menu bar for navigation
- titleBarStyle: 'hidden',
- titleBarOverlay: {
- color: '#f7f9fc',
- symbolColor: '#64748b',
- height: 38
- },
- webPreferences: {
- preload: join(__dirname, '../preload/index.js'),
- sandbox: false,
- nodeIntegration: false,
- contextIsolation: true
- }
- })
- browserWindow.maximize()
- viewManager = new ViewManager(browserWindow)
- pendingBrowserInitialUrl = { url: targetUrl, appId }
- // Load the browser route with initial URL
- // We use hash parameter to avoid Vite dev server 403 error with query params on root
- const hash = `browser` // No url param here, we load it via ViewManager
-
- if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
- browserWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#${hash}`)
- } else {
- browserWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: hash })
- }
- browserWindow.on('closed', () => {
- void getInternalBrowserSession()
- .clearCache()
- .catch((e) => {
- console.error('clearCache (internal browser)', e)
- })
- pendingBrowserInitialUrl = null
- viewManager?.destroy()
- viewManager = null
- browserWindow = null
- })
- browserWindow.webContents.once('did-finish-load', () => {
- browserWindow?.setTitle('应用中心')
- // 渲染进程未就绪时的兜底:稍后再创建首标签并同步 UI
- setTimeout(() => {
- flushPendingBrowserInitialTab()
- syncBrowserTabsToRenderer()
- }, 400)
- })
- }
- // 统一处理来自渲染进程的 Browser Action
- ipcMain.on('browser-action', (event, action) => {
- if (!viewManager || !browserWindow || browserWindow.isDestroyed()) return
- // 简单的鉴权:只允许 browserWindow 发送控制指令
- if (event.sender.id !== browserWindow.webContents.id) return
- switch (action.type) {
- case 'browser-ui-ready':
- flushPendingBrowserInitialTab()
- syncBrowserTabsToRenderer()
- break
- case 'create-tab':
- const id = viewManager.createTab(action.url || 'about:blank', true)
- event.reply('tab-created', { id, url: action.url, title: 'Loading...' })
- break
- case 'switch-tab':
- viewManager.switchTab(action.id)
- break
- case 'go-back':
- viewManager.goBack()
- break
- case 'go-forward':
- viewManager.goForward()
- break
- case 'reload':
- viewManager.reload()
- break
- case 'load-url':
- viewManager.loadURL(action.id, action.url)
- break
- case 'resize-view':
- if (action.bounds) {
- viewManager.setBounds(action.bounds)
- }
- break
- }
- })
- const gotTheLock = app.requestSingleInstanceLock()
- if (!gotTheLock) {
- app.quit()
- } else {
- app.on('second-instance', () => {
- if (mainWindow) {
- if (mainWindow.isMinimized()) mainWindow.restore()
- if (!mainWindow.isVisible()) mainWindow.show()
- mainWindow.focus()
- }
- })
- app.whenReady().then(() => {
- electronApp.setAppUserModelId('com.hnyunzhu.im')
- initStartupFromPreference()
- app.on('browser-window-created', (_, window) => {
- optimizer.watchWindowShortcuts(window)
- })
- ipcMain.on('ping', () => console.log('pong'))
- ipcMain.handle('get-app-version', () => app.getVersion())
- ipcMain.handle('get-open-at-login', () => {
- const isPackaged = app.isPackaged
- if (isPackaged) {
- return {
- openAtLogin: app.getLoginItemSettings().openAtLogin,
- isPackaged: true
- }
- }
- return { openAtLogin: readStartupPreference().openAtLogin, isPackaged: false }
- })
- ipcMain.handle('set-open-at-login', (_, enabled: unknown) => {
- const on = enabled === true
- writeStartupPreference(on)
- applyOpenAtLoginToOS(on)
- return on
- })
- registerLaunchpadIconProtocol()
- ipcMain.handle('launchpad-icon-resolve', async (_, payload: unknown) => {
- const p = payload as {
- app_id?: unknown
- icon_url?: unknown
- icon_object_key?: unknown
- }
- const appId = typeof p?.app_id === 'string' ? p.app_id : ''
- try {
- return await launchpadIconResolveHandler(
- appId,
- p.icon_url as string | null | undefined,
- p.icon_object_key as string | null | undefined
- )
- } catch (e) {
- console.error('launchpad-icon-resolve', e)
- return { kind: 'placeholder' as const }
- }
- })
- // 生产环境自动更新(自建服务器)
- if (!is.dev) {
- autoUpdater.checkForUpdatesAndNotify()
- autoUpdater.on('update-available', (info) => {
- mainWindow?.webContents.send('update-available', info)
- })
- autoUpdater.on('update-downloaded', () => {
- mainWindow?.webContents.send('update-downloaded')
- })
- autoUpdater.on('download-progress', (info: { percent: number; transferred: number; total: number; bytesPerSecond: number }) => {
- mainWindow?.webContents.send('update-download-progress', {
- percent: info.percent,
- transferred: info.transferred,
- total: info.total,
- bytesPerSecond: info.bytesPerSecond
- })
- })
- autoUpdater.on('update-not-available', () => {
- mainWindow?.webContents.send('update-not-available')
- })
- autoUpdater.on('error', (err) => {
- mainWindow?.webContents.send('update-error', err.message)
- })
- // 运行时每 5 小时自动检测一次更新
- const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
- setInterval(() => {
- autoUpdater.checkForUpdates()
- }, FIVE_HOURS_MS)
- }
- ipcMain.on('check-for-updates', () => {
- if (!is.dev) autoUpdater.checkForUpdates()
- })
- ipcMain.on('quit-and-install', () => {
- autoUpdater.quitAndInstall(false, true)
- })
- ipcMain.on('sync-unread-total', (_, total: number) => {
- appUnreadTotalFromApi = typeof total === 'number' && !Number.isNaN(total) ? Math.max(0, Math.floor(total)) : 0
- updateUnreadStatus()
- })
- ipcMain.on('login-success', (event, payload?: { token?: string }) => {
- if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
- return
- }
- if (typeof payload?.token === 'string' && payload.token.length > 0) {
- browserApiHnyunzhuToken = payload.token
- viewManager?.syncTokenToAllTabs()
- }
- // 登录成功后清空消息中心未读,避免切换账号展示上个账号的残留
- unreadMap.clear()
- appUnreadTotalFromApi = null
- updateUnreadStatus()
- mainWindow.setSize(1000, 700)
- mainWindow.setResizable(true)
- mainWindow.center()
- mainWindow.setTitleBarOverlay({
- color: '#f5f5f5',
- symbolColor: '#747474',
- height: 30
- })
- })
- /** 主窗口退出登录:清主进程 token,并清除应用中心分区下全部站点存储后刷新内嵌页 */
- ipcMain.on('browser-auth-logout', (event) => {
- if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
- return
- }
- browserApiHnyunzhuToken = null
- void getInternalBrowserSession()
- .clearStorageData()
- .then(() => {
- viewManager?.reloadAllTabs()
- })
- .catch((e) => {
- console.error('clearStorageData (internal browser)', e)
- })
- })
- ipcMain.on('start-notification', (_, data: { id: number, name: string, content: string }) => {
- const { id, name, content } = data
- const existing = unreadMap.get(id)
- unreadMap.set(id, {
- name,
- content,
- count: existing ? existing.count + 1 : 1
- })
- updateUnreadStatus()
- })
- // 清除未读消息
- ipcMain.on('clear-unread', (_, contactId: number) => {
- if (unreadMap.has(contactId)) {
- unreadMap.delete(contactId)
- updateUnreadStatus()
- }
- })
- // 清除所有未读消息
- ipcMain.on('clear-all-unread', () => {
- unreadMap.clear()
- appUnreadTotalFromApi = null
- updateUnreadStatus()
- })
- // 主窗口专用:应用中心 SSO 带 app_id 打开内置浏览器(同 app 复用标签)
- ipcMain.on('internal-browser-open', (event, payload: unknown) => {
- if (!mainWindow || mainWindow.isDestroyed()) return
- if (event.sender.id !== mainWindow.webContents.id) return
- const p = payload as { url?: unknown; appId?: unknown }
- const url = typeof p.url === 'string' ? p.url : ''
- if (!isHttpUrl(url)) return
- const appId = normalizeInternalBrowserAppId(p.appId)
- createInternalBrowserWindow(url, appId)
- })
- // 处理打开 URL 请求(用于通知消息的 SSO 跳转等)
- ipcMain.on('open-url', (_, url: string) => {
- if (url && isHttpUrl(url)) {
- if (viewManager && browserWindow && !browserWindow.isDestroyed()) {
- const result = viewManager.openOrNavigateTab(url, { activate: true })
- sendBrowserTabOpenToRenderer(url, result)
- if (browserWindow.isMinimized()) browserWindow.restore()
- browserWindow.show()
- browserWindow.focus()
- } else {
- createInternalBrowserWindow(url)
- }
- }
- })
- // 使用系统默认浏览器打开 URL(应用中心「用系统浏览器打开」选项)
- ipcMain.on('open-url-external', (_, url: string) => {
- if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
- shell.openExternal(url)
- }
- })
- // 图片/视频:下载到临时目录后用系统默认应用打开(不再使用内嵌预览窗口)
- ipcMain.on('open-image-preview', (_, imageUrl: string) => {
- void downloadMediaToTempAndOpen(imageUrl, 'image.png').catch((e) => {
- console.error(e)
- dialog.showErrorBox('打开失败', e instanceof Error ? e.message : String(e))
- })
- })
- ipcMain.on('open-video-player', (_, videoUrl: string, videoTitle?: string) => {
- void downloadMediaToTempAndOpen(videoUrl, videoTitle || 'video.mp4').catch((e) => {
- console.error(e)
- dialog.showErrorBox('打开失败', e instanceof Error ? e.message : String(e))
- })
- })
- // Create tray
- const iconPath = getIconPath()
- const trayIcon = nativeImage.createFromPath(iconPath)
-
- // Windows 托盘图标设置
- if (process.platform === 'win32') {
- // 确保图标不是模板模式,这样图标会正常显示
- trayIcon.setTemplateImage(false)
- }
-
- tray = new Tray(trayIcon)
- tray.setToolTip('韫珠IM')
- tray.on('click', () => {
- hideTrayPopup()
- mainWindow?.show()
- })
- tray.on('mouse-move', () => {
- if (unreadMap.size > 0) {
- showTrayPopup()
- cancelTrayPopupHide()
- if (!trayHoverCheckTimer) {
- trayHoverCheckTimer = setInterval(() => {
- if (!tray) return
- const cursor = screen.getCursorScreenPoint()
- const tb = tray.getBounds()
- const isOverTray = cursor.x >= tb.x && cursor.x <= tb.x + tb.width &&
- cursor.y >= tb.y && cursor.y <= tb.y + tb.height
- let isOverPopup = false
- if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
- const pb = trayPopupWindow.getBounds()
- isOverPopup = cursor.x >= pb.x && cursor.x <= pb.x + pb.width &&
- cursor.y >= pb.y && cursor.y <= pb.y + pb.height
- }
- if (!isOverTray && !isOverPopup) {
- hideTrayPopup()
- }
- }, 300)
- }
- }
- })
- updateTrayMenu()
- ipcMain.on('tray-popup-click', (_, contactId: number) => {
- hideTrayPopup()
- if (mainWindow) {
- mainWindow.show()
- mainWindow.focus()
- mainWindow.webContents.send('switch-contact', contactId)
- }
- })
- ipcMain.on('tray-popup-dismiss', () => {
- // 「暂不处理」:清空主进程托盘未读 Map,停止闪烁;不改动 appUnreadTotalFromApi,避免干扰主界面 API 未读总数
- unreadMap.clear()
- updateUnreadStatus()
- })
- ipcMain.on('tray-popup-mouse-leave', () => {
- scheduleTrayPopupHide()
- })
- ipcMain.on('tray-popup-mouse-enter', () => {
- cancelTrayPopupHide()
- })
- createWindow()
- app.on('activate', function () {
- if (BrowserWindow.getAllWindows().length === 0) createWindow()
- })
- })
- app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- app.quit()
- }
- })
- } // end of gotTheLock else
|