index.ts 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546
  1. import { app, shell, session, protocol, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, dialog, screen, type WebContents } from 'electron'
  2. import { basename, extname, join } from 'path'
  3. import { fileURLToPath } from 'url'
  4. import { autoUpdater } from 'electron-updater'
  5. import { electronApp, optimizer, is } from '@electron-toolkit/utils'
  6. import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs'
  7. import { writeFile } from 'fs/promises'
  8. // --- 日志管理 ---
  9. let logDir: string = ''
  10. function ensureLogDir(): void {
  11. if (!logDir) {
  12. logDir = join(app.getPath('userData'), 'logs')
  13. }
  14. if (!existsSync(logDir)) {
  15. mkdirSync(logDir, { recursive: true })
  16. }
  17. }
  18. function writeLog(message: string) {
  19. ensureLogDir()
  20. const date = new Date()
  21. const fileName = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}.log`
  22. const logPath = join(logDir, fileName)
  23. try {
  24. appendFileSync(logPath, message + '\n', 'utf-8')
  25. } catch (err) {
  26. console.error('Failed to write log:', err)
  27. }
  28. }
  29. /** 渲染进程 `<img>` 加载 userData 下启动台图标;须在 app.ready 前登记 */
  30. protocol.registerSchemesAsPrivileged([
  31. {
  32. scheme: 'launchpad-cache',
  33. privileges: {
  34. standard: true,
  35. secure: true,
  36. supportFetchAPI: true,
  37. corsEnabled: true
  38. }
  39. }
  40. ])
  41. ipcMain.on('log-message', (_, message) => {
  42. writeLog(message)
  43. })
  44. let mainWindow: BrowserWindow | null = null
  45. let tray: Tray | null = null
  46. let isQuitting = false
  47. let blinkInterval: NodeJS.Timeout | null = null
  48. let trayPopupWindow: BrowserWindow | null = null
  49. let trayPopupHideTimer: NodeJS.Timeout | null = null
  50. let trayHoverCheckTimer: NodeJS.Timeout | null = null
  51. interface UnreadInfo {
  52. name: string
  53. content: string
  54. count: number
  55. }
  56. const unreadMap = new Map<number, UnreadInfo>()
  57. /** 渲染进程通过 GET /messages/unread-count 同步的总未读数(标题/任务栏角标);未同步时回退为 unreadMap 累加 */
  58. let appUnreadTotalFromApi: number | null = null
  59. function isHttpUrl(url: string): boolean {
  60. try {
  61. const u = new URL(url.trim())
  62. return u.protocol === 'http:' || u.protocol === 'https:'
  63. } catch {
  64. return false
  65. }
  66. }
  67. /** 接口可能返回 number,与 IPC 载荷对齐为 string,供 appId 复用标签 */
  68. function normalizeInternalBrowserAppId(raw: unknown): string | undefined {
  69. if (typeof raw === 'string' && raw.length > 0) return raw
  70. if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw)
  71. return undefined
  72. }
  73. const LAUNCHPAD_ICON_SUBDIR = 'launchpad-icons'
  74. function getLaunchpadIconCacheDir(): string {
  75. return join(app.getPath('userData'), LAUNCHPAD_ICON_SUBDIR)
  76. }
  77. function toSafeLaunchpadAppId(raw: string): string | null {
  78. const s = String(raw || '').trim()
  79. if (s.length === 0 || s.length > 128) return null
  80. if (!/^[a-zA-Z0-9_-]+$/.test(s)) return null
  81. return s
  82. }
  83. interface LaunchpadIconMeta {
  84. icon_object_key: string | null
  85. last_icon_url: string
  86. imageBasename: string
  87. savedAt: string
  88. }
  89. function extFromContentType(ct: string | null | undefined): string {
  90. if (!ct) return '.bin'
  91. const low = ct.split(';')[0].trim().toLowerCase()
  92. if (low.includes('png')) return '.png'
  93. if (low.includes('jpeg') || low.includes('jpg')) return '.jpg'
  94. if (low.includes('gif')) return '.gif'
  95. if (low.includes('webp')) return '.webp'
  96. if (low.includes('svg')) return '.svg'
  97. return '.bin'
  98. }
  99. function extFromImageBuffer(buf: Buffer): string {
  100. if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return '.png'
  101. if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return '.jpg'
  102. const head = buf.length >= 6 ? buf.toString('ascii', 0, 6) : ''
  103. if (head === 'GIF87a' || head === 'GIF89a') return '.gif'
  104. if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return '.webp'
  105. if (buf.length >= 100) {
  106. const sniff = buf.toString('utf8', 0, Math.min(200, buf.length)).toLowerCase()
  107. if (sniff.includes('<svg')) return '.svg'
  108. }
  109. return '.bin'
  110. }
  111. function clearLaunchpadIconFilesSync(safeId: string): void {
  112. const dir = getLaunchpadIconCacheDir()
  113. if (!existsSync(dir)) return
  114. for (const f of readdirSync(dir)) {
  115. if (f === `${safeId}.meta.json` || (f.startsWith(`${safeId}.`) && f !== `${safeId}.meta.json`)) {
  116. try {
  117. unlinkSync(join(dir, f))
  118. } catch {
  119. // ignore
  120. }
  121. }
  122. }
  123. }
  124. function isLaunchpadCacheCurrent(
  125. meta: LaunchpadIconMeta | null,
  126. iconUrl: string,
  127. iconObjectKey: string | null
  128. ): boolean {
  129. if (!meta) return false
  130. if (iconObjectKey != null) {
  131. return (meta.icon_object_key || null) === iconObjectKey
  132. }
  133. return (meta.last_icon_url || '') === iconUrl
  134. }
  135. type LaunchpadIconResolveResult = { kind: 'custom'; url: string } | { kind: 'placeholder' }
  136. const launchpadIconInflight = new Map<string, Promise<LaunchpadIconResolveResult>>()
  137. function launchpadIconInflightKey(safeId: string, iconUrl: string, iconObjectKey: string | null): string {
  138. return `${safeId}\n${iconUrl}\n${iconObjectKey ?? ''}`
  139. }
  140. async function resolveLaunchpadIconOnce(
  141. safeId: string,
  142. iconUrl: string,
  143. iconObjectKey: string | null
  144. ): Promise<LaunchpadIconResolveResult> {
  145. const dir = getLaunchpadIconCacheDir()
  146. mkdirSync(dir, { recursive: true })
  147. const metaPath = join(dir, `${safeId}.meta.json`)
  148. let meta: LaunchpadIconMeta | null = null
  149. try {
  150. if (existsSync(metaPath)) {
  151. meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as LaunchpadIconMeta
  152. }
  153. } catch {
  154. meta = null
  155. }
  156. const base = meta?.imageBasename
  157. const imagePath =
  158. base && !base.includes('..') && !base.includes('/') && !base.includes('\\') && base.startsWith(`${safeId}.`)
  159. ? join(dir, base)
  160. : null
  161. if (imagePath && existsSync(imagePath) && isLaunchpadCacheCurrent(meta, iconUrl, iconObjectKey)) {
  162. return { kind: 'custom', url: `launchpad-cache://${safeId}/icon` }
  163. }
  164. clearLaunchpadIconFilesSync(safeId)
  165. const res = await fetch(iconUrl)
  166. if (!res.ok) {
  167. throw new Error(`download icon HTTP ${res.status}`)
  168. }
  169. const buf = Buffer.from(await res.arrayBuffer())
  170. let ext = extFromContentType(res.headers.get('content-type'))
  171. if (ext === '.bin') ext = extFromImageBuffer(buf)
  172. const imageBasename = `${safeId}${ext}`
  173. const outPath = join(dir, imageBasename)
  174. await writeFile(outPath, buf)
  175. const nextMeta: LaunchpadIconMeta = {
  176. icon_object_key: iconObjectKey,
  177. last_icon_url: iconUrl,
  178. imageBasename,
  179. savedAt: new Date().toISOString()
  180. }
  181. writeFileSync(metaPath, JSON.stringify(nextMeta), 'utf-8')
  182. return { kind: 'custom', url: `launchpad-cache://${safeId}/icon` }
  183. }
  184. async function launchpadIconResolveHandler(
  185. appId: string,
  186. iconUrlRaw: string | null | undefined,
  187. iconObjectKeyRaw: string | null | undefined
  188. ): Promise<LaunchpadIconResolveResult> {
  189. const iconUrl = typeof iconUrlRaw === 'string' ? iconUrlRaw.trim() : ''
  190. if (!iconUrl || !isHttpUrl(iconUrl)) {
  191. return { kind: 'placeholder' }
  192. }
  193. const safeId = toSafeLaunchpadAppId(appId)
  194. if (!safeId) {
  195. return { kind: 'placeholder' }
  196. }
  197. const iconObjectKey =
  198. typeof iconObjectKeyRaw === 'string' && iconObjectKeyRaw.trim() ? iconObjectKeyRaw.trim() : null
  199. const ikey = launchpadIconInflightKey(safeId, iconUrl, iconObjectKey)
  200. const existing = launchpadIconInflight.get(ikey)
  201. if (existing) return existing
  202. const p = resolveLaunchpadIconOnce(safeId, iconUrl, iconObjectKey).finally(() => {
  203. if (launchpadIconInflight.get(ikey) === p) {
  204. launchpadIconInflight.delete(ikey)
  205. }
  206. })
  207. launchpadIconInflight.set(ikey, p)
  208. return p
  209. }
  210. function registerLaunchpadIconProtocol(): void {
  211. const dir = getLaunchpadIconCacheDir()
  212. mkdirSync(dir, { recursive: true })
  213. try {
  214. if (session.defaultSession.protocol.isProtocolRegistered('launchpad-cache')) {
  215. session.defaultSession.protocol.unregisterProtocol('launchpad-cache')
  216. }
  217. } catch {
  218. // ignore
  219. }
  220. session.defaultSession.protocol.registerFileProtocol('launchpad-cache', (request, callback) => {
  221. try {
  222. const parsed = new URL(request.url)
  223. const safeId = parsed.hostname
  224. if (!toSafeLaunchpadAppId(safeId)) {
  225. callback({ error: -6 })
  226. return
  227. }
  228. const metaPath = join(dir, `${safeId}.meta.json`)
  229. if (!existsSync(metaPath)) {
  230. callback({ error: -6 })
  231. return
  232. }
  233. let meta: LaunchpadIconMeta
  234. try {
  235. meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as LaunchpadIconMeta
  236. } catch {
  237. callback({ error: -6 })
  238. return
  239. }
  240. const base = meta.imageBasename
  241. if (!base || base.includes('..') || base.includes('/') || base.includes('\\') || !base.startsWith(`${safeId}.`)) {
  242. callback({ error: -6 })
  243. return
  244. }
  245. const full = join(dir, base)
  246. if (!existsSync(full)) {
  247. callback({ error: -6 })
  248. return
  249. }
  250. callback({ path: full })
  251. } catch {
  252. callback({ error: -6 })
  253. }
  254. })
  255. }
  256. const STARTUP_PREFERENCE_FILE = 'startup-preference.json'
  257. interface StartupPreference {
  258. openAtLogin: boolean
  259. }
  260. function getStartupPreferencePath(): string {
  261. return join(app.getPath('userData'), STARTUP_PREFERENCE_FILE)
  262. }
  263. function readStartupPreference(): { openAtLogin: boolean; fileExists: boolean } {
  264. const p = getStartupPreferencePath()
  265. if (!existsSync(p)) {
  266. return { openAtLogin: true, fileExists: false }
  267. }
  268. try {
  269. const raw = readFileSync(p, 'utf-8')
  270. const j = JSON.parse(raw) as Partial<StartupPreference>
  271. return {
  272. openAtLogin: typeof j.openAtLogin === 'boolean' ? j.openAtLogin : true,
  273. fileExists: true
  274. }
  275. } catch {
  276. return { openAtLogin: true, fileExists: true }
  277. }
  278. }
  279. function writeStartupPreference(openAtLogin: boolean): void {
  280. try {
  281. const payload: StartupPreference = { openAtLogin }
  282. writeFileSync(getStartupPreferencePath(), JSON.stringify(payload, null, 0), 'utf-8')
  283. } catch (e) {
  284. console.error('writeStartupPreference', e)
  285. }
  286. }
  287. function applyOpenAtLoginToOS(openAtLogin: boolean): void {
  288. if (!app.isPackaged) return
  289. app.setLoginItemSettings({
  290. openAtLogin,
  291. path: app.getPath('exe')
  292. })
  293. }
  294. function initStartupFromPreference(): void {
  295. const { openAtLogin, fileExists } = readStartupPreference()
  296. if (!fileExists) {
  297. writeStartupPreference(true)
  298. applyOpenAtLoginToOS(true)
  299. } else {
  300. applyOpenAtLoginToOS(openAtLogin)
  301. }
  302. }
  303. function sanitizeFilename(name: string): string {
  304. return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 120) || 'file'
  305. }
  306. function extFromUrlOrData(url: string, fallback: string): string {
  307. if (url.startsWith('data:')) {
  308. const mime = /^data:([^;]+);/i.exec(url)?.[1]?.toLowerCase() || ''
  309. if (mime.includes('jpeg')) return '.jpg'
  310. if (mime.includes('png')) return '.png'
  311. if (mime.includes('gif')) return '.gif'
  312. if (mime.includes('webp')) return '.webp'
  313. if (mime.includes('mp4')) return '.mp4'
  314. if (mime.includes('webm')) return '.webm'
  315. return fallback
  316. }
  317. try {
  318. const pathname = new URL(url.trim()).pathname
  319. const e = extname(pathname).toLowerCase()
  320. if (e && e.length <= 8) return e
  321. } catch {
  322. // ignore
  323. }
  324. return fallback
  325. }
  326. /** 下载媒体到临时目录后用系统默认应用打开(避免内嵌 data: 预览阻塞主进程) */
  327. async function downloadMediaToTempAndOpen(url: string, suggestedName: string): Promise<void> {
  328. const base = sanitizeFilename(basename(suggestedName || 'media'))
  329. let ext = extname(base)
  330. if (!ext) {
  331. ext = extFromUrlOrData(url, '.bin')
  332. }
  333. const nameWithoutExt = base.replace(/\.[^.]+$/, '') || 'media'
  334. const fileName = `${nameWithoutExt}-${Date.now()}${ext}`
  335. const dir = join(app.getPath('temp'), 'yunzhu-im-open')
  336. mkdirSync(dir, { recursive: true })
  337. const filePath = join(dir, fileName)
  338. if (url.startsWith('data:')) {
  339. const comma = url.indexOf(',')
  340. if (comma === -1) throw new Error('无效的媒体数据')
  341. const header = url.slice(0, comma)
  342. const payload = url.slice(comma + 1)
  343. const isBase64 = /;base64/i.test(header)
  344. const buffer = isBase64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload), 'utf-8')
  345. await writeFile(filePath, buffer)
  346. } else if (url.startsWith('file://')) {
  347. const localPath = fileURLToPath(url)
  348. const err = await shell.openPath(localPath)
  349. if (err) throw new Error(err)
  350. return
  351. } else if (isHttpUrl(url)) {
  352. const res = await fetch(url)
  353. if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`)
  354. const buf = Buffer.from(await res.arrayBuffer())
  355. await writeFile(filePath, buf)
  356. } else {
  357. throw new Error('不支持的媒体地址')
  358. }
  359. const err = await shell.openPath(filePath)
  360. if (err) throw new Error(err)
  361. }
  362. /** 与统一登录/开放平台同一源,应用中心内嵌页使用的 localStorage 键为 `token` */
  363. const API_HNYUNZHU_ORIGIN = 'https://api.hnyunzhu.com'
  364. /** 应用中心内嵌标签页专用分区;与主窗口默认 session 隔离,登出时可整体 clearStorage 而不影响主应用 */
  365. const INTERNAL_BROWSER_PARTITION = 'persist:yunzhu-app-center'
  366. function getInternalBrowserSession() {
  367. return session.fromPartition(INTERNAL_BROWSER_PARTITION)
  368. }
  369. let browserApiHnyunzhuToken: string | null = null
  370. function isApiHnyunzhuUrl(url: string): boolean {
  371. if (!url || url === 'about:blank') return false
  372. try {
  373. return new URL(url).origin === API_HNYUNZHU_ORIGIN
  374. } catch {
  375. return false
  376. }
  377. }
  378. function injectTokenForApiOrigin(wc: WebContents): void {
  379. if (wc.isDestroyed()) return
  380. const url = wc.getURL()
  381. if (!isApiHnyunzhuUrl(url)) return
  382. if (browserApiHnyunzhuToken) {
  383. const safe = JSON.stringify(browserApiHnyunzhuToken)
  384. void wc.executeJavaScript(`try{localStorage.setItem('token',${safe});}catch(e){}`, true)
  385. } else {
  386. void wc.executeJavaScript(`try{localStorage.removeItem('token');}catch(e){}`, true)
  387. }
  388. }
  389. // --- 浏览器视图管理器 ---
  390. interface TabInfo {
  391. id: string
  392. url: string
  393. title: string
  394. view: WebContentsView
  395. /** 应用中心 SSO 打开时绑定,用于同一应用复用同一标签 */
  396. appId?: string
  397. }
  398. class ViewManager {
  399. private window: BrowserWindow
  400. private tabs: Map<string, TabInfo> = new Map()
  401. /** app_id → 标签 id(仅登记带 appId 的标签) */
  402. private appIdToTabId: Map<string, string> = new Map()
  403. private activeTabId: string | null = null
  404. private bounds: Electron.Rectangle = { x: 0, y: 78, width: 1024, height: 600 }
  405. constructor(window: BrowserWindow) {
  406. this.window = window
  407. }
  408. /**
  409. * 若提供 appId 且已有对应标签,则在该标签中加载 url;否则新建标签。
  410. */
  411. openOrNavigateTab(
  412. url: string,
  413. opts?: { appId?: string; activate?: boolean }
  414. ): { id: string; reused: boolean } {
  415. const activate = opts?.activate !== false
  416. const appId = opts?.appId
  417. if (appId) {
  418. const existingId = this.appIdToTabId.get(appId)
  419. if (existingId && this.tabs.has(existingId)) {
  420. if (activate) {
  421. this.switchTab(existingId)
  422. }
  423. try {
  424. this.tabs.get(existingId)!.view.webContents.loadURL(url)
  425. } catch (e) {
  426. console.error('Failed to load URL:', url, e)
  427. }
  428. return { id: existingId, reused: true }
  429. }
  430. if (existingId && !this.tabs.has(existingId)) {
  431. this.appIdToTabId.delete(appId)
  432. }
  433. }
  434. const id = this.createNewTab(url, activate, appId)
  435. if (appId) {
  436. this.appIdToTabId.set(appId, id)
  437. }
  438. return { id, reused: false }
  439. }
  440. createTab(url: string, active: boolean = true): string {
  441. return this.createNewTab(url, active, undefined)
  442. }
  443. private createNewTab(url: string, active: boolean, appId: string | undefined): string {
  444. const view = new WebContentsView({
  445. webPreferences: {
  446. partition: INTERNAL_BROWSER_PARTITION,
  447. sandbox: false,
  448. nodeIntegration: false,
  449. contextIsolation: true,
  450. }
  451. })
  452. const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
  453. view.webContents.on('did-start-loading', () => {
  454. if (!this.window.isDestroyed()) {
  455. this.window.webContents.send('tab-update', id, { isLoading: true })
  456. }
  457. })
  458. view.webContents.on('did-stop-loading', () => {
  459. if (!this.window.isDestroyed()) {
  460. this.window.webContents.send('tab-update', id, {
  461. isLoading: false,
  462. canGoBack: view.webContents.navigationHistory.canGoBack(),
  463. canGoForward: view.webContents.navigationHistory.canGoForward(),
  464. url: view.webContents.getURL(),
  465. title: view.webContents.getTitle()
  466. })
  467. }
  468. })
  469. view.webContents.on('page-title-updated', (_, title) => {
  470. if (!this.window.isDestroyed()) {
  471. this.window.webContents.send('tab-update', id, { title })
  472. }
  473. })
  474. view.webContents.on('did-navigate', (_, url) => {
  475. if (!this.window.isDestroyed()) {
  476. this.window.webContents.send('tab-update', id, { url })
  477. }
  478. })
  479. view.webContents.on('did-finish-load', () => {
  480. injectTokenForApiOrigin(view.webContents)
  481. })
  482. view.webContents.setWindowOpenHandler((details) => {
  483. if (!isHttpUrl(details.url)) {
  484. return { action: 'allow' }
  485. }
  486. const newId = this.createTab(details.url, true)
  487. if (!this.window.isDestroyed()) {
  488. this.window.webContents.send('tab-created', { id: newId, url: details.url, title: 'Loading...' })
  489. }
  490. return { action: 'deny' }
  491. })
  492. try {
  493. view.webContents.loadURL(url)
  494. } catch (e) {
  495. console.error('Failed to load URL:', url, e)
  496. }
  497. this.tabs.set(id, { id, url, title: 'Loading...', view, appId })
  498. if (active) {
  499. this.switchTab(id)
  500. }
  501. return id
  502. }
  503. switchTab(id: string) {
  504. const tab = this.tabs.get(id)
  505. if (!tab) return
  506. if (this.activeTabId) {
  507. const currentTab = this.tabs.get(this.activeTabId)
  508. if (currentTab) {
  509. this.window.contentView.removeChildView(currentTab.view)
  510. }
  511. }
  512. this.window.contentView.addChildView(tab.view)
  513. tab.view.setBounds(this.bounds)
  514. this.activeTabId = id
  515. }
  516. setBounds(bounds: Electron.Rectangle) {
  517. this.bounds = bounds
  518. if (this.activeTabId) {
  519. const tab = this.tabs.get(this.activeTabId)
  520. if (tab) {
  521. tab.view.setBounds(bounds)
  522. }
  523. }
  524. }
  525. goBack() {
  526. if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.navigationHistory.goBack()
  527. }
  528. goForward() {
  529. if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.navigationHistory.goForward()
  530. }
  531. reload() {
  532. if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.reload()
  533. }
  534. loadURL(id: string, url: string) {
  535. const tab = this.tabs.get(id)
  536. if (tab) {
  537. tab.view.webContents.loadURL(url)
  538. }
  539. }
  540. getTabSnapshotsForRenderer(): Array<{ id: string; url: string; title: string }> {
  541. return Array.from(this.tabs.values()).map((t) => ({
  542. id: t.id,
  543. url: t.view.webContents.getURL(),
  544. title: t.view.webContents.getTitle() || t.title || 'Loading...'
  545. }))
  546. }
  547. /** 对当前已打开且为 api 源的内嵌页同步/清除 `localStorage.token`(登入、换号、登出时调用) */
  548. syncTokenToAllTabs(): void {
  549. this.tabs.forEach((tab) => {
  550. try {
  551. if (!tab.view.webContents.isDestroyed()) {
  552. injectTokenForApiOrigin(tab.view.webContents)
  553. }
  554. } catch {
  555. // ignore
  556. }
  557. })
  558. }
  559. /** 全量清存储后刷新各标签,避免仍停留在旧页面内存态 */
  560. reloadAllTabs(): void {
  561. this.tabs.forEach((tab) => {
  562. try {
  563. if (!tab.view.webContents.isDestroyed()) {
  564. tab.view.webContents.reloadIgnoringCache()
  565. }
  566. } catch {
  567. // ignore
  568. }
  569. })
  570. }
  571. destroy() {
  572. this.tabs.forEach(tab => {
  573. try {
  574. this.window.contentView.removeChildView(tab.view)
  575. tab.view.webContents.close()
  576. } catch (e) { /* already destroyed */ }
  577. })
  578. this.tabs.clear()
  579. this.appIdToTabId.clear()
  580. }
  581. }
  582. function getResourcePath(fileName: string): string {
  583. if (app.isPackaged) {
  584. return join(process.resourcesPath, fileName)
  585. }
  586. return join(__dirname, '../../resources', fileName)
  587. }
  588. // 获取图标路径的函数 - Windows 优先使用 ICO 文件
  589. function getIconPath(): string {
  590. if (process.platform === 'win32') {
  591. // Windows 优先使用包含多尺寸的 ICO 文件
  592. const icoPath = getResourcePath('icon.ico')
  593. if (existsSync(icoPath)) {
  594. return icoPath
  595. }
  596. }
  597. // 其他平台或 Windows 没有 ICO 时使用 PNG
  598. return getResourcePath('logo.png')
  599. }
  600. function getTotalUnreadCount(): number {
  601. if (appUnreadTotalFromApi !== null) {
  602. return appUnreadTotalFromApi
  603. }
  604. let total = 0
  605. for (const info of unreadMap.values()) {
  606. total += info.count
  607. }
  608. return total
  609. }
  610. function updateTrayMenu(): void {
  611. if (!tray) return
  612. const totalUnread = getTotalUnreadCount()
  613. const contextMenu = Menu.buildFromTemplate([
  614. { label: totalUnread > 0 ? `未读消息 (${totalUnread})` : '无未读消息', enabled: false },
  615. { type: 'separator' },
  616. { label: '显示主界面', click: () => mainWindow?.show() },
  617. { label: '退出', click: () => { isQuitting = true; app.quit() } }
  618. ])
  619. tray.setContextMenu(contextMenu)
  620. tray.setToolTip('韫珠IM')
  621. }
  622. // --- 托盘悬浮弹窗 ---
  623. function getTrayPopupHTML(): string {
  624. return `<!DOCTYPE html>
  625. <html>
  626. <head>
  627. <meta charset="UTF-8">
  628. <style>
  629. *{margin:0;padding:0;box-sizing:border-box;}
  630. body{font-family:'Microsoft YaHei','PingFang SC',sans-serif;background:transparent;overflow:hidden;user-select:none;}
  631. .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;}
  632. .list{overflow-y:auto;max-height:calc(100vh - 40px);flex:1;}
  633. .list::-webkit-scrollbar{width:4px;}
  634. .list::-webkit-scrollbar-thumb{background:#ccc;border-radius:2px;}
  635. .msg-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;border-bottom:1px solid #f0f0f0;transition:background 0.15s;}
  636. .msg-item:last-child{border-bottom:none;}
  637. .msg-item:hover{background:#f5f5f5;}
  638. .msg-avatar-wrap{position:relative;width:40px;height:40px;flex-shrink:0;}
  639. .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;}
  640. .msg-info{margin-left:10px;overflow:hidden;flex:1;}
  641. .msg-name{font-size:14px;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;}
  642. .msg-content{font-size:12px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px;}
  643. .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;}
  644. .footer:hover{background:#f5f5f5;}
  645. .empty{padding:20px;text-align:center;color:#999;font-size:13px;}
  646. </style>
  647. </head>
  648. <body>
  649. <div class="popup">
  650. <div class="list" id="list"></div>
  651. <div class="footer" id="dismiss">暂不处理</div>
  652. </div>
  653. <script>
  654. (function(){
  655. const list = document.getElementById('list');
  656. const dismiss = document.getElementById('dismiss');
  657. const ipc = window.electron && window.electron.ipcRenderer;
  658. var GRADIENT_PAIRS = [
  659. ['#1e3a8a','#93c5fd'],['#166534','#86efac'],['#c2410c','#fdba74'],['#b91c1c','#fca5a5'],['#5b21b6','#c4b5fd'],
  660. ['#0f766e','#5eead4'],['#3730a3','#a5b4fc'],['#0d9488','#2dd4bf'],['#b45309','#fcd34d'],['#be123c','#fda4af'],
  661. ['#0369a1','#7dd3fc'],['#4d7c0f','#bef264'],['#86198f','#e879f9'],['#db2777','#fbcfe8'],['#047857','#6ee7b7'],
  662. ['#6d28d9','#ddd6fe'],['#1e40af','#93c5fd'],['#ea580c','#fed7aa'],['#0e7490','#99f6e4'],['#881337','#fbcfe8']
  663. ];
  664. function getAvatarText(name){
  665. var n = String(name || '').trim();
  666. if(!n) return '?';
  667. var cjk = (n.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
  668. var letterCount = n.replace(/\\s/g,'').length;
  669. var isCJK = letterCount > 0 && cjk >= letterCount / 2;
  670. if(isCJK){
  671. if(n.length <= 2) return n;
  672. return n.slice(-2);
  673. }
  674. var words = n.split(/\\s+/).filter(Boolean);
  675. if(words.length >= 2){
  676. var a = (words[0][0] || '').toUpperCase();
  677. var b = (words[1][0] || '').toUpperCase();
  678. return (a + b) || '?';
  679. }
  680. if(words.length === 1 && words[0].length >= 2) return words[0].slice(0,2).toUpperCase();
  681. if(words.length === 1 && words[0].length === 1) return words[0].toUpperCase();
  682. return '?';
  683. }
  684. function getGradientIndex(id, name){
  685. var s = String(id || '').trim() || String(name || '').trim();
  686. var hash = 0;
  687. for(var i = 0; i < s.length; i++){
  688. hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
  689. }
  690. return Math.abs(hash) % GRADIENT_PAIRS.length;
  691. }
  692. function getApplicationGradientIndex(name){
  693. var s = String(name || '').trim() || 'SYSTEM';
  694. var hash = 0;
  695. for(var i = 0; i < s.length; i++){
  696. hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
  697. }
  698. return Math.abs(hash) % GRADIENT_PAIRS.length;
  699. }
  700. function avatarBlockHtml(item){
  701. var displayName = item.name || '';
  702. var text = getAvatarText(displayName);
  703. var idx = item.id < 0 ? getApplicationGradientIndex(displayName) : getGradientIndex(item.id, displayName);
  704. var pair = GRADIENT_PAIRS[idx];
  705. var c1 = pair[0], c2 = pair[1];
  706. var style = 'width:40px;height:40px;border-radius:50%;overflow:hidden;background-color:'+c1+
  707. ';background-image:linear-gradient(135deg,'+c1+','+c2+');display:flex;align-items:center;justify-content:center;'+
  708. '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;';
  709. var badgeText = item.count > 99 ? '99+' : String(item.count);
  710. return '<div class="msg-avatar-wrap">'+
  711. '<div style="'+style+'">'+escapeHtml(text)+'</div>'+
  712. '<div class="badge">'+escapeHtml(badgeText)+'</div>'+
  713. '</div>';
  714. }
  715. if(ipc){
  716. ipc.on('update-unread', function(_, items){
  717. list.innerHTML = '';
  718. if(!items || items.length === 0){
  719. list.innerHTML = '<div class="empty">暂无未读消息</div>';
  720. return;
  721. }
  722. items.forEach(function(item){
  723. var div = document.createElement('div');
  724. div.className = 'msg-item';
  725. div.innerHTML =
  726. avatarBlockHtml(item) +
  727. '<div class="msg-info">' +
  728. '<div class="msg-name">' + escapeHtml(item.name) + '</div>' +
  729. '<div class="msg-content">' + escapeHtml(item.content) + '</div>' +
  730. '</div>';
  731. div.addEventListener('click', function(){ ipc.send('tray-popup-click', item.id); });
  732. list.appendChild(div);
  733. });
  734. });
  735. }
  736. dismiss.addEventListener('click', function(){
  737. if(ipc) ipc.send('tray-popup-dismiss');
  738. });
  739. document.addEventListener('mouseleave', function(){
  740. if(ipc) ipc.send('tray-popup-mouse-leave');
  741. });
  742. document.addEventListener('mouseenter', function(){
  743. if(ipc) ipc.send('tray-popup-mouse-enter');
  744. });
  745. function escapeHtml(str){
  746. if(!str) return '';
  747. return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  748. }
  749. })();
  750. </script>
  751. </body>
  752. </html>`
  753. }
  754. function createTrayPopup(): void {
  755. if (trayPopupWindow && !trayPopupWindow.isDestroyed()) return
  756. trayPopupWindow = new BrowserWindow({
  757. width: 320,
  758. height: 200,
  759. frame: false,
  760. transparent: true,
  761. alwaysOnTop: true,
  762. skipTaskbar: true,
  763. resizable: false,
  764. show: false,
  765. focusable: false,
  766. webPreferences: {
  767. preload: join(__dirname, '../preload/index.js'),
  768. sandbox: false,
  769. nodeIntegration: false,
  770. contextIsolation: true,
  771. }
  772. })
  773. trayPopupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(getTrayPopupHTML())}`)
  774. trayPopupWindow.on('closed', () => {
  775. trayPopupWindow = null
  776. })
  777. }
  778. function getUnreadListForPopup(): { id: number, name: string, content: string, count: number }[] {
  779. const list: { id: number, name: string, content: string, count: number }[] = []
  780. for (const [id, info] of unreadMap.entries()) {
  781. list.push({ id, name: info.name, content: info.content, count: info.count })
  782. }
  783. return list
  784. }
  785. function showTrayPopup(): void {
  786. if (!tray || unreadMap.size === 0) return
  787. if (!trayPopupWindow || trayPopupWindow.isDestroyed()) {
  788. createTrayPopup()
  789. }
  790. if (!trayPopupWindow) return
  791. if (trayPopupHideTimer) {
  792. clearTimeout(trayPopupHideTimer)
  793. trayPopupHideTimer = null
  794. }
  795. if (trayPopupWindow.isVisible()) {
  796. trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
  797. return
  798. }
  799. const trayBounds = tray.getBounds()
  800. const itemHeight = 61
  801. const footerHeight = 40
  802. const maxItems = 5
  803. const visibleItems = Math.min(unreadMap.size, maxItems)
  804. const popupHeight = visibleItems * itemHeight + footerHeight + 2
  805. const popupWidth = 320
  806. const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y })
  807. const workArea = display.workArea
  808. let x = Math.round(trayBounds.x - popupWidth / 2 + trayBounds.width / 2)
  809. let y: number
  810. if (trayBounds.y < workArea.y + workArea.height / 2) {
  811. y = trayBounds.y + trayBounds.height + 4
  812. } else {
  813. y = trayBounds.y - popupHeight - 4
  814. }
  815. if (x + popupWidth > workArea.x + workArea.width) {
  816. x = workArea.x + workArea.width - popupWidth - 4
  817. }
  818. if (x < workArea.x) {
  819. x = workArea.x + 4
  820. }
  821. trayPopupWindow.setSize(popupWidth, popupHeight)
  822. trayPopupWindow.setPosition(x, y)
  823. trayPopupWindow.webContents.once('did-finish-load', () => {
  824. if (trayPopupWindow && !trayPopupWindow.isDestroyed()) {
  825. trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
  826. }
  827. })
  828. if (trayPopupWindow.webContents.isLoading()) {
  829. // will send data in did-finish-load
  830. } else {
  831. trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
  832. }
  833. trayPopupWindow.showInactive()
  834. }
  835. function hideTrayPopup(): void {
  836. if (trayPopupHideTimer) {
  837. clearTimeout(trayPopupHideTimer)
  838. trayPopupHideTimer = null
  839. }
  840. if (trayHoverCheckTimer) {
  841. clearInterval(trayHoverCheckTimer)
  842. trayHoverCheckTimer = null
  843. }
  844. if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
  845. trayPopupWindow.hide()
  846. }
  847. }
  848. function scheduleTrayPopupHide(): void {
  849. if (trayPopupHideTimer) clearTimeout(trayPopupHideTimer)
  850. trayPopupHideTimer = setTimeout(() => {
  851. hideTrayPopup()
  852. }, 300)
  853. }
  854. function cancelTrayPopupHide(): void {
  855. if (trayPopupHideTimer) {
  856. clearTimeout(trayPopupHideTimer)
  857. trayPopupHideTimer = null
  858. }
  859. }
  860. function updateWindowTitle(): void {
  861. if (!mainWindow) return
  862. const totalUnread = getTotalUnreadCount()
  863. if (totalUnread > 0) {
  864. mainWindow.setTitle(`韫珠IM (${totalUnread} 条未读消息)`)
  865. } else {
  866. mainWindow.setTitle('韫珠IM')
  867. }
  868. }
  869. function updateTaskbarOverlay(): void {
  870. const totalUnread = getTotalUnreadCount()
  871. if (totalUnread > 0) {
  872. app.setBadgeCount(totalUnread)
  873. } else {
  874. app.setBadgeCount(0)
  875. }
  876. }
  877. function updateUnreadStatus(): void {
  878. updateTrayMenu()
  879. updateWindowTitle()
  880. updateTaskbarOverlay()
  881. if (unreadMap.size > 0) {
  882. startBlinking()
  883. } else {
  884. stopBlinking()
  885. hideTrayPopup()
  886. }
  887. if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
  888. trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
  889. }
  890. }
  891. function startBlinking(): void {
  892. if (blinkInterval) return
  893. let isEmpty = false
  894. const iconPath = getIconPath()
  895. const emptyIcon = nativeImage.createEmpty()
  896. const normalIcon = nativeImage.createFromPath(iconPath)
  897. blinkInterval = setInterval(() => {
  898. if (!tray) return
  899. if (isEmpty) {
  900. tray.setImage(normalIcon)
  901. } else {
  902. tray.setImage(emptyIcon)
  903. }
  904. isEmpty = !isEmpty
  905. }, 500)
  906. }
  907. function stopBlinking(): void {
  908. if (blinkInterval) {
  909. clearInterval(blinkInterval)
  910. blinkInterval = null
  911. }
  912. if (tray) {
  913. const iconPath = getIconPath()
  914. tray.setImage(nativeImage.createFromPath(iconPath))
  915. }
  916. }
  917. function createWindow(): void {
  918. // Create the browser window.
  919. const iconPath = getIconPath()
  920. mainWindow = new BrowserWindow({
  921. width: 1000,
  922. height: 700,
  923. show: false,
  924. resizable: true, // 与手动登录后一致,否则 Windows 标题栏不显示最大化;登录成功仍会 setSize/center
  925. title: '韫珠IM',
  926. icon: iconPath,
  927. autoHideMenuBar: true,
  928. titleBarStyle: 'hidden', // Hide title bar
  929. titleBarOverlay: {
  930. color: '#f7f9fc', // Match refreshed app shell
  931. symbolColor: '#64748b', // Match refreshed control color
  932. height: 30
  933. },
  934. webPreferences: {
  935. preload: join(__dirname, '../preload/index.js'),
  936. sandbox: false,
  937. nodeIntegration: false,
  938. contextIsolation: true,
  939. webSecurity: true
  940. }
  941. })
  942. mainWindow.on('ready-to-show', () => {
  943. mainWindow?.show()
  944. })
  945. mainWindow.on('close', (event) => {
  946. if (!isQuitting) {
  947. event.preventDefault()
  948. mainWindow?.hide()
  949. return false
  950. }
  951. return true
  952. })
  953. mainWindow.on('focus', () => {
  954. stopBlinking()
  955. // 主界面显示时清除任务栏徽章
  956. app.setBadgeCount(0)
  957. })
  958. mainWindow.on('show', () => {
  959. // 主界面显示时清除任务栏徽章
  960. app.setBadgeCount(0)
  961. })
  962. mainWindow.webContents.setWindowOpenHandler((details) => {
  963. // Intercept target="_blank" and open custom browser window
  964. if (isHttpUrl(details.url)) {
  965. createInternalBrowserWindow(details.url)
  966. return { action: 'deny' }
  967. }
  968. return { action: 'allow' }
  969. })
  970. // HMR for renderer base on electron-vite cli.
  971. // Load the remote URL for development or the local html file for production.
  972. if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
  973. mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  974. } else {
  975. mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  976. }
  977. }
  978. let browserWindow: BrowserWindow | null = null
  979. let viewManager: ViewManager | null = null
  980. /** 新建内置浏览器窗口时,首标签在渲染进程就绪后再同步,避免 tab-created 早于 listener */
  981. let pendingBrowserInitialUrl: { url: string; appId?: string } | null = null
  982. function sendBrowserTabOpenToRenderer(url: string, result: { id: string; reused: boolean }): void {
  983. if (!browserWindow || browserWindow.isDestroyed()) return
  984. if (result.reused) {
  985. browserWindow.webContents.send('tab-activate', result.id)
  986. } else {
  987. browserWindow.webContents.send('tab-created', { id: result.id, url, title: 'Loading...' })
  988. }
  989. }
  990. function flushPendingBrowserInitialTab(): void {
  991. if (!pendingBrowserInitialUrl || !viewManager || !browserWindow || browserWindow.isDestroyed()) return
  992. const { url, appId } = pendingBrowserInitialUrl
  993. pendingBrowserInitialUrl = null
  994. const result = viewManager.openOrNavigateTab(url, { appId, activate: true })
  995. sendBrowserTabOpenToRenderer(url, result)
  996. }
  997. function syncBrowserTabsToRenderer(): void {
  998. if (!viewManager || !browserWindow || browserWindow.isDestroyed()) return
  999. const snapshots = viewManager.getTabSnapshotsForRenderer()
  1000. if (snapshots.length === 0) return
  1001. browserWindow.webContents.send('browser-tabs-sync', snapshots)
  1002. }
  1003. // Function to create the custom browser window
  1004. function createInternalBrowserWindow(targetUrl: string, appId?: string): void {
  1005. if (browserWindow && !browserWindow.isDestroyed()) {
  1006. if (browserWindow.isMinimized()) browserWindow.restore()
  1007. browserWindow.show()
  1008. browserWindow.focus()
  1009. if (viewManager) {
  1010. const result = viewManager.openOrNavigateTab(targetUrl, { appId, activate: true })
  1011. sendBrowserTabOpenToRenderer(targetUrl, result)
  1012. }
  1013. return
  1014. }
  1015. const iconPath = getIconPath()
  1016. browserWindow = new BrowserWindow({
  1017. width: 1024,
  1018. height: 768,
  1019. title: '应用中心', // Built-in Browser
  1020. icon: iconPath,
  1021. autoHideMenuBar: true, // Show menu bar for navigation
  1022. titleBarStyle: 'hidden',
  1023. titleBarOverlay: {
  1024. color: '#f7f9fc',
  1025. symbolColor: '#64748b',
  1026. height: 38
  1027. },
  1028. webPreferences: {
  1029. preload: join(__dirname, '../preload/index.js'),
  1030. sandbox: false,
  1031. nodeIntegration: false,
  1032. contextIsolation: true
  1033. }
  1034. })
  1035. browserWindow.maximize()
  1036. viewManager = new ViewManager(browserWindow)
  1037. pendingBrowserInitialUrl = { url: targetUrl, appId }
  1038. // Load the browser route with initial URL
  1039. // We use hash parameter to avoid Vite dev server 403 error with query params on root
  1040. const hash = `browser` // No url param here, we load it via ViewManager
  1041. if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
  1042. browserWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#${hash}`)
  1043. } else {
  1044. browserWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: hash })
  1045. }
  1046. browserWindow.on('closed', () => {
  1047. void getInternalBrowserSession()
  1048. .clearCache()
  1049. .catch((e) => {
  1050. console.error('clearCache (internal browser)', e)
  1051. })
  1052. pendingBrowserInitialUrl = null
  1053. viewManager?.destroy()
  1054. viewManager = null
  1055. browserWindow = null
  1056. })
  1057. browserWindow.webContents.once('did-finish-load', () => {
  1058. browserWindow?.setTitle('应用中心')
  1059. // 渲染进程未就绪时的兜底:稍后再创建首标签并同步 UI
  1060. setTimeout(() => {
  1061. flushPendingBrowserInitialTab()
  1062. syncBrowserTabsToRenderer()
  1063. }, 400)
  1064. })
  1065. }
  1066. // 统一处理来自渲染进程的 Browser Action
  1067. ipcMain.on('browser-action', (event, action) => {
  1068. if (!viewManager || !browserWindow || browserWindow.isDestroyed()) return
  1069. // 简单的鉴权:只允许 browserWindow 发送控制指令
  1070. if (event.sender.id !== browserWindow.webContents.id) return
  1071. switch (action.type) {
  1072. case 'browser-ui-ready':
  1073. flushPendingBrowserInitialTab()
  1074. syncBrowserTabsToRenderer()
  1075. break
  1076. case 'create-tab':
  1077. const id = viewManager.createTab(action.url || 'about:blank', true)
  1078. event.reply('tab-created', { id, url: action.url, title: 'Loading...' })
  1079. break
  1080. case 'switch-tab':
  1081. viewManager.switchTab(action.id)
  1082. break
  1083. case 'go-back':
  1084. viewManager.goBack()
  1085. break
  1086. case 'go-forward':
  1087. viewManager.goForward()
  1088. break
  1089. case 'reload':
  1090. viewManager.reload()
  1091. break
  1092. case 'load-url':
  1093. viewManager.loadURL(action.id, action.url)
  1094. break
  1095. case 'resize-view':
  1096. if (action.bounds) {
  1097. viewManager.setBounds(action.bounds)
  1098. }
  1099. break
  1100. }
  1101. })
  1102. const gotTheLock = app.requestSingleInstanceLock()
  1103. if (!gotTheLock) {
  1104. app.quit()
  1105. } else {
  1106. app.on('second-instance', () => {
  1107. if (mainWindow) {
  1108. if (mainWindow.isMinimized()) mainWindow.restore()
  1109. if (!mainWindow.isVisible()) mainWindow.show()
  1110. mainWindow.focus()
  1111. }
  1112. })
  1113. app.whenReady().then(() => {
  1114. electronApp.setAppUserModelId('com.hnyunzhu.im')
  1115. initStartupFromPreference()
  1116. app.on('browser-window-created', (_, window) => {
  1117. optimizer.watchWindowShortcuts(window)
  1118. })
  1119. ipcMain.on('ping', () => console.log('pong'))
  1120. ipcMain.handle('get-app-version', () => app.getVersion())
  1121. ipcMain.handle('get-open-at-login', () => {
  1122. const isPackaged = app.isPackaged
  1123. if (isPackaged) {
  1124. return {
  1125. openAtLogin: app.getLoginItemSettings().openAtLogin,
  1126. isPackaged: true
  1127. }
  1128. }
  1129. return { openAtLogin: readStartupPreference().openAtLogin, isPackaged: false }
  1130. })
  1131. ipcMain.handle('set-open-at-login', (_, enabled: unknown) => {
  1132. const on = enabled === true
  1133. writeStartupPreference(on)
  1134. applyOpenAtLoginToOS(on)
  1135. return on
  1136. })
  1137. registerLaunchpadIconProtocol()
  1138. ipcMain.handle('launchpad-icon-resolve', async (_, payload: unknown) => {
  1139. const p = payload as {
  1140. app_id?: unknown
  1141. icon_url?: unknown
  1142. icon_object_key?: unknown
  1143. }
  1144. const appId = typeof p?.app_id === 'string' ? p.app_id : ''
  1145. try {
  1146. return await launchpadIconResolveHandler(
  1147. appId,
  1148. p.icon_url as string | null | undefined,
  1149. p.icon_object_key as string | null | undefined
  1150. )
  1151. } catch (e) {
  1152. console.error('launchpad-icon-resolve', e)
  1153. return { kind: 'placeholder' as const }
  1154. }
  1155. })
  1156. // 生产环境自动更新(自建服务器)
  1157. if (!is.dev) {
  1158. autoUpdater.checkForUpdatesAndNotify()
  1159. autoUpdater.on('update-available', (info) => {
  1160. mainWindow?.webContents.send('update-available', info)
  1161. })
  1162. autoUpdater.on('update-downloaded', () => {
  1163. mainWindow?.webContents.send('update-downloaded')
  1164. })
  1165. autoUpdater.on('download-progress', (info: { percent: number; transferred: number; total: number; bytesPerSecond: number }) => {
  1166. mainWindow?.webContents.send('update-download-progress', {
  1167. percent: info.percent,
  1168. transferred: info.transferred,
  1169. total: info.total,
  1170. bytesPerSecond: info.bytesPerSecond
  1171. })
  1172. })
  1173. autoUpdater.on('update-not-available', () => {
  1174. mainWindow?.webContents.send('update-not-available')
  1175. })
  1176. autoUpdater.on('error', (err) => {
  1177. mainWindow?.webContents.send('update-error', err.message)
  1178. })
  1179. // 运行时每 5 小时自动检测一次更新
  1180. const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
  1181. setInterval(() => {
  1182. autoUpdater.checkForUpdates()
  1183. }, FIVE_HOURS_MS)
  1184. }
  1185. ipcMain.on('check-for-updates', () => {
  1186. if (!is.dev) autoUpdater.checkForUpdates()
  1187. })
  1188. ipcMain.on('quit-and-install', () => {
  1189. autoUpdater.quitAndInstall(false, true)
  1190. })
  1191. ipcMain.on('sync-unread-total', (_, total: number) => {
  1192. appUnreadTotalFromApi = typeof total === 'number' && !Number.isNaN(total) ? Math.max(0, Math.floor(total)) : 0
  1193. updateUnreadStatus()
  1194. })
  1195. ipcMain.on('login-success', (event, payload?: { token?: string }) => {
  1196. if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
  1197. return
  1198. }
  1199. if (typeof payload?.token === 'string' && payload.token.length > 0) {
  1200. browserApiHnyunzhuToken = payload.token
  1201. viewManager?.syncTokenToAllTabs()
  1202. }
  1203. // 登录成功后清空消息中心未读,避免切换账号展示上个账号的残留
  1204. unreadMap.clear()
  1205. appUnreadTotalFromApi = null
  1206. updateUnreadStatus()
  1207. mainWindow.setSize(1000, 700)
  1208. mainWindow.setResizable(true)
  1209. mainWindow.center()
  1210. mainWindow.setTitleBarOverlay({
  1211. color: '#f5f5f5',
  1212. symbolColor: '#747474',
  1213. height: 30
  1214. })
  1215. })
  1216. /** 主窗口退出登录:清主进程 token,并清除应用中心分区下全部站点存储后刷新内嵌页 */
  1217. ipcMain.on('browser-auth-logout', (event) => {
  1218. if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
  1219. return
  1220. }
  1221. browserApiHnyunzhuToken = null
  1222. void getInternalBrowserSession()
  1223. .clearStorageData()
  1224. .then(() => {
  1225. viewManager?.reloadAllTabs()
  1226. })
  1227. .catch((e) => {
  1228. console.error('clearStorageData (internal browser)', e)
  1229. })
  1230. })
  1231. ipcMain.on('start-notification', (_, data: { id: number, name: string, content: string }) => {
  1232. const { id, name, content } = data
  1233. const existing = unreadMap.get(id)
  1234. unreadMap.set(id, {
  1235. name,
  1236. content,
  1237. count: existing ? existing.count + 1 : 1
  1238. })
  1239. updateUnreadStatus()
  1240. })
  1241. // 清除未读消息
  1242. ipcMain.on('clear-unread', (_, contactId: number) => {
  1243. if (unreadMap.has(contactId)) {
  1244. unreadMap.delete(contactId)
  1245. updateUnreadStatus()
  1246. }
  1247. })
  1248. // 清除所有未读消息
  1249. ipcMain.on('clear-all-unread', () => {
  1250. unreadMap.clear()
  1251. appUnreadTotalFromApi = null
  1252. updateUnreadStatus()
  1253. })
  1254. // 主窗口专用:应用中心 SSO 带 app_id 打开内置浏览器(同 app 复用标签)
  1255. ipcMain.on('internal-browser-open', (event, payload: unknown) => {
  1256. if (!mainWindow || mainWindow.isDestroyed()) return
  1257. if (event.sender.id !== mainWindow.webContents.id) return
  1258. const p = payload as { url?: unknown; appId?: unknown }
  1259. const url = typeof p.url === 'string' ? p.url : ''
  1260. if (!isHttpUrl(url)) return
  1261. const appId = normalizeInternalBrowserAppId(p.appId)
  1262. createInternalBrowserWindow(url, appId)
  1263. })
  1264. // 处理打开 URL 请求(用于通知消息的 SSO 跳转等)
  1265. ipcMain.on('open-url', (_, url: string) => {
  1266. if (url && isHttpUrl(url)) {
  1267. if (viewManager && browserWindow && !browserWindow.isDestroyed()) {
  1268. const result = viewManager.openOrNavigateTab(url, { activate: true })
  1269. sendBrowserTabOpenToRenderer(url, result)
  1270. if (browserWindow.isMinimized()) browserWindow.restore()
  1271. browserWindow.show()
  1272. browserWindow.focus()
  1273. } else {
  1274. createInternalBrowserWindow(url)
  1275. }
  1276. }
  1277. })
  1278. // 使用系统默认浏览器打开 URL(应用中心「用系统浏览器打开」选项)
  1279. ipcMain.on('open-url-external', (_, url: string) => {
  1280. if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
  1281. shell.openExternal(url)
  1282. }
  1283. })
  1284. // 图片/视频:下载到临时目录后用系统默认应用打开(不再使用内嵌预览窗口)
  1285. ipcMain.on('open-image-preview', (_, imageUrl: string) => {
  1286. void downloadMediaToTempAndOpen(imageUrl, 'image.png').catch((e) => {
  1287. console.error(e)
  1288. dialog.showErrorBox('打开失败', e instanceof Error ? e.message : String(e))
  1289. })
  1290. })
  1291. ipcMain.on('open-video-player', (_, videoUrl: string, videoTitle?: string) => {
  1292. void downloadMediaToTempAndOpen(videoUrl, videoTitle || 'video.mp4').catch((e) => {
  1293. console.error(e)
  1294. dialog.showErrorBox('打开失败', e instanceof Error ? e.message : String(e))
  1295. })
  1296. })
  1297. // Create tray
  1298. const iconPath = getIconPath()
  1299. const trayIcon = nativeImage.createFromPath(iconPath)
  1300. // Windows 托盘图标设置
  1301. if (process.platform === 'win32') {
  1302. // 确保图标不是模板模式,这样图标会正常显示
  1303. trayIcon.setTemplateImage(false)
  1304. }
  1305. tray = new Tray(trayIcon)
  1306. tray.setToolTip('韫珠IM')
  1307. tray.on('click', () => {
  1308. hideTrayPopup()
  1309. mainWindow?.show()
  1310. })
  1311. tray.on('mouse-move', () => {
  1312. if (unreadMap.size > 0) {
  1313. showTrayPopup()
  1314. cancelTrayPopupHide()
  1315. if (!trayHoverCheckTimer) {
  1316. trayHoverCheckTimer = setInterval(() => {
  1317. if (!tray) return
  1318. const cursor = screen.getCursorScreenPoint()
  1319. const tb = tray.getBounds()
  1320. const isOverTray = cursor.x >= tb.x && cursor.x <= tb.x + tb.width &&
  1321. cursor.y >= tb.y && cursor.y <= tb.y + tb.height
  1322. let isOverPopup = false
  1323. if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
  1324. const pb = trayPopupWindow.getBounds()
  1325. isOverPopup = cursor.x >= pb.x && cursor.x <= pb.x + pb.width &&
  1326. cursor.y >= pb.y && cursor.y <= pb.y + pb.height
  1327. }
  1328. if (!isOverTray && !isOverPopup) {
  1329. hideTrayPopup()
  1330. }
  1331. }, 300)
  1332. }
  1333. }
  1334. })
  1335. updateTrayMenu()
  1336. ipcMain.on('tray-popup-click', (_, contactId: number) => {
  1337. hideTrayPopup()
  1338. if (mainWindow) {
  1339. mainWindow.show()
  1340. mainWindow.focus()
  1341. mainWindow.webContents.send('switch-contact', contactId)
  1342. }
  1343. })
  1344. ipcMain.on('tray-popup-dismiss', () => {
  1345. // 「暂不处理」:清空主进程托盘未读 Map,停止闪烁;不改动 appUnreadTotalFromApi,避免干扰主界面 API 未读总数
  1346. unreadMap.clear()
  1347. updateUnreadStatus()
  1348. })
  1349. ipcMain.on('tray-popup-mouse-leave', () => {
  1350. scheduleTrayPopupHide()
  1351. })
  1352. ipcMain.on('tray-popup-mouse-enter', () => {
  1353. cancelTrayPopupHide()
  1354. })
  1355. createWindow()
  1356. app.on('activate', function () {
  1357. if (BrowserWindow.getAllWindows().length === 0) createWindow()
  1358. })
  1359. })
  1360. app.on('window-all-closed', () => {
  1361. if (process.platform !== 'darwin') {
  1362. app.quit()
  1363. }
  1364. })
  1365. } // end of gotTheLock else