diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a64a42d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# Agent Instructions + +Before changing this project, read `ARCHITECTURE.md` at the repository root. + +Use that file as the source of truth for: + +- Electron main/preload/renderer/shared boundaries +- local JSON persistence and backup behavior +- overlay BrowserWindow architecture +- release and local install verification notes + +Do not treat `release`, `downloads`, `backups`, `coverage`, `out`, `node_modules`, or `sources` as normal source areas unless the user explicitly asks to work on those artifacts. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f74e21f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,222 @@ +# Timetable Architecture Notes + +This file is the local architecture handoff for future Codex sessions. Read it before changing the app. + +## Product Shape + +Timetable is a local-first Windows desktop planner built with Electron, React, TypeScript, electron-vite, Tailwind CSS, Zustand, and local JSON persistence. + +The app has no backend. The important runtime artifact is the Electron `userData` directory and its `app-data.json` file, not the source checkout itself. + +## Runtime Layers + +```text +Electron main process + -> AppStorage: local data, schema migration, atomic saves, backups + -> WindowManager facade: coordinates main window and desktop overlay BrowserWindows + -> windowing controllers: main window, overlay windows, pure geometry helpers + -> IPC handlers: bridge renderer requests to storage/windows/system APIs + -> BrowserUsageTracker: Windows active-browser and AI-tool usage sampling + -> Tray/startup helpers + +Preload + -> exposes window.timeable via contextBridge + -> renderer never calls Electron or Node APIs directly + +Renderer + -> React app using HashRouter + -> Zustand appStore wraps window.timeable calls + -> normal pages under src/renderer/pages + -> desktop widgets under src/renderer/overlay + +Shared + -> AppData types, IPC types, reducer, defaults, migrations, pure utilities + -> imported by both main and renderer where safe +``` + +## Key Entrypoints + +- `src/main/index.ts`: app bootstrap. Creates storage, windows, tray, IPC, and browser usage tracking. +- `src/main/storage.ts`: authoritative local data service. Handles load, normalize, migration, atomic save, backup, restore, export, and usage snapshots. +- `src/main/windows.ts`: public WindowManager facade used by bootstrap and IPC. +- `src/main/windowing/mainWindowController.ts`: owns the frameless main window. +- `src/main/windowing/overlayWindowController.ts`: owns separate transparent overlay windows. +- `src/main/windowing/geometry.ts`: pure window sizing, bounds, opacity, and edge-collapse helpers. +- `src/main/ipc.ts`: registers all IPC handlers. +- `src/preload/index.ts`: exposes the typed `window.timeable` API. +- `src/renderer/App.tsx`: route tree for normal pages and overlay routes. +- `src/renderer/store/appStore.ts`: renderer state and async actions around `window.timeable`. +- `src/shared/types/app.ts`: `AppData` and domain model types. +- `src/shared/ipc.ts`: shared renderer/main API contract. +- `src/shared/data/defaults.ts`: default `AppData`. +- `src/shared/data/migrations.ts`: `schemaVersion` migration entrypoint. +- `src/shared/data/reducer.ts`: pure mutations for courses, tasks, goals, memos, settings, and widgets. + +## Data Model And Persistence + +The main persisted object is `AppData`. + +Current important fields: + +- `schemaVersion`: top-level data schema version. +- `courses` +- `dailyTasks` +- `longTermGoals` +- `memos` +- `countdownItems`: user-created countdown targets. +- `principleCard` +- `countdownCard`: countdown widget settings plus optional `pinnedItemId`. +- `desktopSettings` +- `appSettings` +- `browserUsage` + +Persistence rules: + +- Data file path is `app.getPath('userData')/app-data.json`. +- Saves are atomic: write a temp file beside `app-data.json`, then rename it over the target. +- Corrupt JSON is preserved as `app-data.corrupt-.json`, then defaults are recreated. +- Legacy or missing schema data flows through `migrateAppData()` before normalization. +- Daily, migration, and manual backups live under `userData/backups`. +- Backup summaries use `DataBackupSummary` from `src/shared/ipc.ts`. +- The "Data and Startup" renderer page exposes backup listing and restore UI. + +Important local-machine constraint: + +- This machine has previously had both `AppData/Roaming/Timetable` and `AppData/Roaming/timeable`. +- Do not assume the lowercase or uppercase directory is authoritative. +- For upgrade/replacement work, validate JSON contents and the actual Electron `userData` path. + +## Window And Overlay Model + +The main app window is a frameless BrowserWindow loading the normal React route tree. + +Desktop widgets are not DOM overlays inside the main window. They are separate transparent BrowserWindows managed by `WindowManager`, each loading: + +```text +#/overlay/ +``` + +Known widget keys: + +- `mainPanel` +- `dailyTasks` +- `memo` +- `countdown` +- `principle` + +Overlay behavior owned by `OverlayWindowController` behind the `WindowManager` facade: + +- create/destroy overlay windows based on `desktopSettings.widgets` +- always-on-top and opacity +- drag/resize bounds persistence +- screen-bound constraints +- edge auto-hide and hover expansion + +Be careful changing overlay behavior. It couples renderer widget config, Electron window bounds, and persisted widget settings. + +Keep `WindowManager` public methods stable unless all callers are updated. `index.ts` and `ipc.ts` should not need to know whether main-window or overlay-window internals changed. + +## Renderer State Flow + +Normal update path: + +```text +page component + -> useAppStore action + -> window.timeable API + -> IPC handler + -> AppStorage update + -> WindowManager sync/broadcast + -> renderer receives data:changed +``` + +Full-data updates still use `data:changed`. + +High-frequency updates may use `data:patched`: + +- `widget/replace` patches update one desktop widget after overlay movement, resize, or widget config changes. +- `browserUsage/dayReplace` patches update one browser-usage day after background sampling. + +The full `data:changed` path remains the compatibility contract for startup, ordinary business mutations, settings, restore, export, and any change that is not explicitly patchable. Renderer code should apply patches only on top of already-loaded `AppData`; if data is not loaded yet, wait for `loadData()` or the next full `data:changed`. + +## Countdown Items + +The countdown page supports multiple user-created `countdownItems`. + +The desktop countdown overlay still uses the single `countdown` widget key and one BrowserWindow. It displays `countdownCard.pinnedItemId` when that item exists. If no item is pinned, the pinned item was deleted, or there are no custom countdowns, the overlay falls back to the original "today remaining" countdown plus task completion summary. + +## Browser Usage Tracking + +`src/main/browserUsageTracker.ts` is Windows-specific and uses PowerShell/UIAutomation to inspect the foreground window. + +It detects: + +- browser URLs +- AI web services such as ChatGPT, Claude, Gemini, DeepSeek, Kimi +- AI desktop/terminal tools by process/title heuristics + +This should remain isolated from app startup correctness. Failures should degrade to no usage sample, not prevent the app from running. + +## Build And Test + +Scripts: + +- `npm run dev`: electron-vite dev +- `npm run typecheck`: app and node TypeScript checks +- `npm test`: Vitest with coverage +- `npm run build`: typecheck plus electron-vite build +- `npm run pack:win`: unpacked Windows build +- `npm run dist:win`: NSIS installer +- `npm run dist:portable`: portable executable +- `npm run release:win`: installer plus portable + +`vitest.config.ts` excludes historical and generated folders such as `sources`, `release`, `downloads`, and `backups`. Do not remove those excludes unless the historical snapshots are intentionally brought under test. + +## Release And Local Install Notes + +For local replacement or upgrade tasks: + +- First verify the actual running executable: + +```powershell +Get-Process Timetable,Timeable -ErrorAction SilentlyContinue | Select-Object ProcessName,Id,Path +``` + +- Do not infer the active app from newest folder names. +- Verify the desktop shortcut target if the user launches through a shortcut. +- Preserve and validate local user data before switching builds. +- The active local release can be outside the current source build output. + +## Current Optimization State + +Implemented: + +- top-level `AppData.schemaVersion` +- migration entrypoint +- atomic `app-data.json` saves +- corrupt JSON preservation +- daily/migration/manual backups +- backup listing and restore API +- backup/restore UI on the Data and Startup page +- user-created countdown items with one pinned desktop countdown +- settings widget deep-merge behavior +- `WindowManager` split into main-window controller, overlay-window controller, and pure geometry helpers +- `data:patched` broadcasts for high-frequency widget and browser-usage updates +- tests for storage safety and reducer widget merge behavior +- tests for pure window geometry behavior + +Still good next steps: + +- isolate browser usage tracking further from storage/UI broadcast load +- clean repository hygiene around generated artifacts and historical source snapshots +- add renderer tests if a renderer test harness is introduced + +## Change Guidelines + +- Prefer existing patterns over new frameworks. +- Keep data migrations pure and testable. +- Keep renderer access to system APIs behind `window.timeable`. +- Never bypass `AppStorage` for persisted app data. +- When modifying user data behavior, add storage tests first. +- When modifying overlays, test bounds, resize, hide/show, and persisted widget settings. +- Keep release folders, backups, downloads, and source snapshots out of normal app logic. diff --git a/README.md b/README.md index 76ac51c..afc27f0 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,26 @@ # Timetable -Timetable 是一个本地优先的 Windows 桌面规划工具,用来管理课程表、每日任务、长期目标、备忘录、倒计时、道理卡片、桌面挂件和时间统计。 +Timetable 是一个本地 Windows 桌面应用,用来统一管理课程表、每日任务、长期目标、备忘录、倒计时卡片和桌面悬浮挂件。 -## 直接下载 +## 技术栈 -[点击下载 Windows 安装包 setup.exe](https://github.com/Evander764/Timetable/releases/download/v0.3.3/Timetable-0.3.3-x64-setup.exe) +- Electron +- React +- TypeScript +- electron-vite +- Tailwind CSS +- Zustand +- lucide-react +- 本地 JSON 持久化 -不想安装时,也可以下载解压版: - -[下载 win-unpacked 压缩包](https://github.com/Evander764/Timetable/releases/download/v0.3.3/Timetable-win-unpacked-v0.3.3.zip) - -## 运行提示 - -- 当前版本支持 Windows x64。 -- 安装包是普通用户安装,不需要管理员权限。 -- 当前安装包没有数字签名,Windows 可能显示“未知发布者”或 SmartScreen 提示。 -- 如果你信任这个仓库,可以在提示中点击“更多信息”,然后选择“仍要运行”。 -- Release 附带 `SHA256SUMS.txt`,可用于校验下载文件。 - -## 主要功能 - -- 课程表:支持学期开始日期、总周数、单/双周课程、自定义课表时间。 -- 今日行动中心:自动判断正在上课、下一节课、今日课程结束或今日无课。 -- 道理卡片:支持多张卡片、手动切换、自动轮换和翻页动画。 -- 桌面挂件:支持主面板、倒计时、备忘录、任务卡片和道理卡片。 -- 时间统计:按天统计前台网页和 AI 应用使用时间,AdsPower 不会被计入 Claude。 -- 托盘退出:可设置右上角关闭按钮是退出程序还是隐藏到托盘,也可开启“仅托盘退出”。 -- 数据备份:支持自动备份、手动备份、备份恢复和恢复前保护备份。 - -## 数据与隐私 - -- 这个仓库不包含任何个人应用数据。 -- 应用运行数据保存在 Electron 的 `userData/app-data.json`。 -- 备份文件保存在 `userData/backups`。 -- 更新应用包不会覆盖原来的 JSON 数据。 -- 导出的备份、本地构建产物和运行日志不会提交到 Git。 - -## 自动更新 - -打包后的应用启动时会检查 GitHub 最新 Release: - -```text -https://github.com/Evander764/Timetable/releases/latest -``` - -发现新版本后,应用会显示版本说明和确认按钮。用户确认后,程序会先备份当前用户数据,再下载 Release 中的 `app.asar`,校验 `SHA256SUMS.txt`,替换资源包并自动重启。用户数据仍然保留在本机 `userData` 目录,新版本会继续使用原来的数据。 - -## 本地开发 +## 开发命令 ```bash npm install npm run dev ``` -常用命令: +其他命令: ```bash npm run lint @@ -65,11 +32,39 @@ npm run dist:portable npm run release:win ``` -## 发布新版本 +## 数据存储 + +- 默认数据文件位于 Electron `userData` 目录下的 `app-data.json` +- 所有数据仅保存在本地 +- 修改后默认自动保存,带 500ms debounce +- 支持导出当前数据为 JSON 文件 + +## 主要功能 + +- 控制中心窗口:总览、桌面面板、课程表、每日任务、长期任务、备忘录、倒计时卡片、道理卡片、背景与设置、数据与启动 +- 桌面透明挂件:主面板、每日任务、进行中备忘、倒计时、道理卡片 +- 桌面卡片位置、尺寸、透明度和显隐状态持久化 +- 开机启动、本地背景图切换、数据导出 + +## Windows 打包 + +- `npm run pack:win`:生成未安装的目录版本,便于本地检查打包结果 +- `npm run dist:win`:生成 Windows `nsis` 安装包 +- `npm run dist:portable`:生成 Windows 便携版可执行文件 +- `npm run release:win`:一次性生成安装包和便携版 + +默认输出目录会带版本号和时间戳,避免覆盖旧产物: + +```text +release/-/ +``` + +## 测试覆盖 -1. 提高 `package.json` 和 `package-lock.json` 的版本号。 -2. 执行 `npm run release:win`。 -3. 上传 `app.asar`、`setup.exe`、便携版、win-unpacked zip 和 `SHA256SUMS.txt`。 -4. 创建 GitHub Release,例如 `v0.3.3`。 +当前包含以下自动化测试: -当前最新公开版本是 `v0.3.3`。 +- 课程显示规则与下一节课计算 +- 每日任务完成率、连续打卡与重复规则 +- 长期目标阶段推进 +- 数据 reducer 更新 +- JSON 默认数据创建与自动保存 diff --git a/package-lock.json b/package-lock.json index 2684575..e1caeba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "timetable", - "version": "0.3.3", + "name": "timeable", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "timetable", - "version": "0.3.3", + "name": "timeable", + "version": "0.1.0", "dependencies": { "chinese-days": "^1.5.7", "clsx": "^2.1.1", diff --git a/package.json b/package.json index ec4e6b2..86a3c71 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "timetable", + "name": "timeable", "productName": "Timetable", "private": false, - "version": "0.3.3", + "version": "0.2.0", "description": "A local Windows desktop planner for courses, tasks, goals, memos, countdowns, and desktop widgets.", "author": "Timetable Local Builder", "main": "out/main/index.js", diff --git a/scripts/import-desktop-planner-export.mjs b/scripts/import-desktop-planner-export.mjs new file mode 100644 index 0000000..df34589 --- /dev/null +++ b/scripts/import-desktop-planner-export.mjs @@ -0,0 +1,276 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { env } from 'node:process' + +const projectRoot = process.cwd() +const exportPath = join(projectRoot, 'desktop-planner-personal-data-export-20260424-165547.md') +const targetPath = join(env.APPDATA ?? join(env.USERPROFILE ?? projectRoot, 'AppData', 'Roaming'), 'Timetable', 'app-data.json') + +const dayMap = new Map([ + ['Monday', 1], + ['Tuesday', 2], + ['Wednesday', 3], + ['Thursday', 4], + ['Friday', 5], + ['Saturday', 6], + ['Sunday', 7], + ['周一', 1], + ['周二', 2], + ['周三', 3], + ['周四', 4], + ['周五', 5], + ['周六', 6], + ['周日', 7], + ['周天', 7], +]) + +const colorFallbacks = ['#3B82F6', '#14B8A6', '#8B5CF6', '#F97316', '#EAB308', '#06B6D4', '#EC4899'] + +function getJsonBlocks(markdown) { + const blocks = new Map() + const blockPattern = /^### (.+?)\r?\n\r?\n```json\r?\n([\s\S]*?)\r?\n```/gm + for (const match of markdown.matchAll(blockPattern)) { + blocks.set(match[1].trim(), JSON.parse(match[2])) + } + return blocks +} + +function stripSeconds(time) { + return String(time ?? '00:00').slice(0, 5) +} + +function mapRepeatType(value) { + const normalized = String(value ?? '').toLowerCase() + if (normalized.includes('odd') || normalized.includes('single') || normalized.includes('单')) { + return 'odd' + } + if (normalized.includes('even') || normalized.includes('double') || normalized.includes('双')) { + return 'even' + } + return 'weekly' +} + +function mapTaskRepeat(value) { + const normalized = String(value ?? '').toLowerCase() + if (normalized.includes('daily') || normalized.includes('每天')) { + return 'daily' + } + if (normalized.includes('weekly') || normalized.includes('每周')) { + return 'weekly' + } + if (normalized.includes('work') || normalized.includes('weekday') || normalized.includes('工作')) { + return 'workday' + } + if (normalized.includes('holiday') || normalized.includes('weekend') || normalized.includes('假期') || normalized.includes('周末')) { + return 'holiday' + } + return 'once' +} + +function mapWeeklyDays(task) { + const days = [] + if (task.repeatOnMonday) days.push(1) + if (task.repeatOnTuesday) days.push(2) + if (task.repeatOnWednesday) days.push(3) + if (task.repeatOnThursday) days.push(4) + if (task.repeatOnFriday) days.push(5) + if (task.repeatOnSaturday) days.push(6) + if (task.repeatOnSunday) days.push(7) + return days +} + +function completionMap(dates = []) { + return Object.fromEntries(dates.map((date) => [date, true])) +} + +function mapGoalStatus(value) { + const normalized = String(value ?? '').toLowerCase() + if (normalized.includes('complete') || normalized.includes('已完成')) { + return 'completed' + } + if (normalized.includes('pause') || normalized.includes('暂停')) { + return 'paused' + } + return 'active' +} + +function parsePositionPair(left, top, fallbackX, fallbackY) { + return { + x: Number.isFinite(Number(left)) ? Math.round(Number(left)) : fallbackX, + y: Number.isFinite(Number(top)) ? Math.round(Number(top)) : fallbackY, + } +} + +function widget(x, y, width, height, extra = {}) { + return { + enabled: true, + opacity: 0.86, + x, + y, + width, + height, + autoHide: false, + minimized: false, + ...extra, + } +} + +function makeImportedData(blocks) { + const config = blocks.get('config.json') ?? {} + const coursesSource = blocks.get('courses.json') ?? [] + const tasksSource = blocks.get('daily-tasks.json') ?? [] + const memosSource = blocks.get('memos.json') ?? [] + const goalsSource = blocks.get('long-tasks.json') ?? [] + const now = new Date().toISOString() + const courseColorByTitle = new Map() + + const courses = coursesSource.map((course) => { + if (!courseColorByTitle.has(course.title)) { + courseColorByTitle.set(course.title, course.accentColor || colorFallbacks[courseColorByTitle.size % colorFallbacks.length]) + } + return { + id: `course-${course.id}`, + name: course.title || 'Untitled course', + teacher: course.teacherName || '', + location: course.location || '', + dayOfWeek: dayMap.get(course.dayOfWeek) ?? 1, + startTime: stripSeconds(course.startTimeText || course.startTime), + endTime: stripSeconds(course.endTimeText || course.endTime), + repeatType: mapRepeatType(course.weekPattern), + color: courseColorByTitle.get(course.title), + } + }) + + const dailyTasks = tasksSource.map((task) => { + const repeatRule = mapTaskRepeat(task.recurrenceType || task.recurrenceSummary) + const noteParts = [] + if (task.notes) noteParts.push(task.notes) + if (Number.isFinite(Number(task.estimatedMinutes)) && Number(task.estimatedMinutes) > 0) { + noteParts.push(`Estimated minutes: ${task.estimatedMinutes}`) + } + return { + id: `task-${task.id}`, + title: task.title || 'Untitled task', + repeatRule, + weeklyDays: repeatRule === 'weekly' ? mapWeeklyDays(task) : undefined, + startDate: task.date || undefined, + endDate: task.hasEndDate && task.endDate ? task.endDate : undefined, + priority: Number(task.estimatedMinutes) >= 90 ? 'high' : Number(task.estimatedMinutes) <= 20 ? 'low' : 'medium', + completions: completionMap(task.completedDates), + note: noteParts.join('\n') || undefined, + createdAt: task.dateValue || (task.date ? `${task.date}T00:00:00.000Z` : now), + } + }) + + const longTermGoals = goalsSource.map((goal) => { + const status = mapGoalStatus(goal.status) + const stageStatus = status === 'completed' ? 'completed' : status === 'active' ? 'active' : 'pending' + return { + id: `goal-${goal.id}`, + title: goal.title || 'Untitled goal', + status, + progress: Math.round(Number(goal.progressPercentage) || 0), + targetDate: goal.targetDate || undefined, + currentStageId: `stage-${goal.id}`, + stages: [ + { + id: `stage-${goal.id}`, + title: 'Imported progress', + status: stageStatus, + }, + ], + subtasks: [], + note: goal.description || undefined, + createdAt: now, + } + }) + + const memos = memosSource.map((memo, index) => ({ + id: `memo-${memo.id}`, + title: memo.title || 'Untitled memo', + content: memo.content || '', + status: memo.isEnded ? 'ended' : 'active', + showOnDesktop: !memo.isEnded && index === 0, + createdAt: now, + endedAt: memo.isEnded ? now : undefined, + })) + + const mainPanelPosition = parsePositionPair(config.panelLeft, config.panelTop, 40, 42) + const countdownPosition = parsePositionPair(config.countdownLeft, config.countdownTop, 64, 640) + const principlePosition = parsePositionPair(config.corePrincipleLeft, config.corePrincipleTop, 520, 672) + + return { + courses, + dailyTasks, + longTermGoals, + memos, + principleCard: { + enabled: Boolean(config.corePrincipleText), + content: config.corePrincipleText || '', + author: '', + position: 'bottom-center', + opacity: 0.84, + autoHide: Boolean(config.corePrincipleEdgeAutoHideEnabled), + }, + countdownCard: { + enabled: config.countdownVisible !== false, + minimized: Boolean(config.countdownMinimized), + position: 'bottom-left', + opacity: 0.88, + }, + desktopSettings: { + overlayEnabled: true, + opacity: Number(config.panelOpacity) || 0.88, + scale: 1, + alwaysOnTop: true, + autoHide: Boolean(config.edgeAutoHideEnabled), + dragLocked: false, + overlayMode: 'floating', + widgets: { + mainPanel: widget(mainPanelPosition.x, mainPanelPosition.y, Math.round(Number(config.panelWidth) || 460), Math.round(Number(config.panelHeight) || 570)), + dailyTasks: widget(560, 72, 430, 430), + memo: widget(1030, 78, 420, 380), + countdown: widget(countdownPosition.x, countdownPosition.y, 340, 240, { + enabled: config.countdownVisible !== false, + minimized: Boolean(config.countdownMinimized), + }), + principle: widget(principlePosition.x, principlePosition.y, 400, 220, { + enabled: Boolean(config.corePrincipleText), + autoHide: Boolean(config.corePrincipleEdgeAutoHideEnabled), + }), + }, + }, + appSettings: { + autoSave: true, + launchAtStartup: true, + dataPath: targetPath, + termStartDate: config.semesterStartDate || '2026-03-09', + lastSavedAt: now, + }, + } +} + +const markdown = await readFile(exportPath, 'utf-8') +const blocks = getJsonBlocks(markdown) +const data = makeImportedData(blocks) + +await mkdir(dirname(targetPath), { recursive: true }) + +try { + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + await copyFile(targetPath, join(dirname(targetPath), `app-data.before-desktop-planner-import-${stamp}.json`)) +} catch { + // No existing data file to back up. +} + +await writeFile(targetPath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8') + +console.log(JSON.stringify({ + targetPath, + counts: { + courses: data.courses.length, + dailyTasks: data.dailyTasks.length, + longTermGoals: data.longTermGoals.length, + memos: data.memos.length, + }, +}, null, 2)) diff --git a/scripts/run-electron-builder-win.mjs b/scripts/run-electron-builder-win.mjs index 7c224c0..2ef1d7b 100644 --- a/scripts/run-electron-builder-win.mjs +++ b/scripts/run-electron-builder-win.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' const __dirname = dirname(fileURLToPath(import.meta.url)) const projectRoot = join(__dirname, '..') -const aliasRoot = join(tmpdir(), 'timetable-builder-link') +const aliasRoot = join(tmpdir(), 'timeable-builder-link') const args = process.argv.slice(2) const packageJson = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) const buildId = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14) diff --git a/scripts/set-timeable-login-item.cjs b/scripts/set-timeable-login-item.cjs new file mode 100644 index 0000000..1581b78 --- /dev/null +++ b/scripts/set-timeable-login-item.cjs @@ -0,0 +1,36 @@ +const { app } = require('electron') + +const targetPath = process.argv[2] +const legacyTargetPath = targetPath.replace(/Timetable\.exe$/i, 'Timeable.exe') + +if (!targetPath) { + console.error('Usage: electron scripts/set-timeable-login-item.cjs ') + process.exit(1) +} + +app.setAppUserModelId('com.timetable.app') + +app.whenReady().then(() => { + app.setAppUserModelId('com.timeable.app') + app.setLoginItemSettings({ + openAtLogin: false, + path: legacyTargetPath, + args: [], + }) + + app.setAppUserModelId('com.timetable.app') + app.setLoginItemSettings({ + openAtLogin: true, + openAsHidden: false, + path: targetPath, + args: [], + }) + + const settings = app.getLoginItemSettings({ + path: targetPath, + args: [], + }) + + console.log(JSON.stringify(settings, null, 2)) + app.quit() +}) diff --git a/src/main/browserUsageTracker.ts b/src/main/browserUsageTracker.ts index 7db44d6..f61eb01 100644 --- a/src/main/browserUsageTracker.ts +++ b/src/main/browserUsageTracker.ts @@ -1,7 +1,22 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import type { AppData, BrowserPageSample } from '@shared/types/app' -import { getAiUsageServiceForUrl, isAdsPowerProcess } from '@shared/utils/browserUsage' +import type { AppDataPatch } from '@shared/ipc' +import type { AppData } from '@shared/types/app' +import { normalizeBrowserPageSample, type NormalizedBrowserPageSample } from '@shared/utils/browserUsage' + +type BrowserUsageTrackerServices = { + getData: () => AppData + recordUsage: (sample: NormalizedBrowserPageSample, durationSeconds: number) => Promise + onDataPatched: (patch: AppDataPatch) => void +} + +type ActiveBrowserWindow = { + isBrowser?: boolean + processName?: string + browser?: string + title?: string + url?: string +} const execFileAsync = promisify(execFile) const POWERSHELL_TIMEOUT_MS = 3500 @@ -23,6 +38,27 @@ const BROWSER_LABELS: Record = { adspower_global: 'AdsPower', } +type AiAppDefinition = { + id: string + label: string + processNames?: string[] + titlePatterns?: RegExp[] +} + +type AiWebService = { + id: string + label: string + domains: string[] +} + +const AI_WEB_SERVICES: AiWebService[] = [ + { id: 'chatgpt', label: 'ChatGPT', domains: ['chatgpt.com', 'chat.openai.com'] }, + { id: 'kimi', label: 'Kimi', domains: ['kimi.moonshot.cn', 'kimi.com', 'moonshot.cn'] }, + { id: 'deepseek', label: 'DeepSeek', domains: ['chat.deepseek.com', 'deepseek.com'] }, + { id: 'gemini', label: 'Gemini', domains: ['gemini.google.com', 'bard.google.com'] }, + { id: 'claude', label: 'Claude', domains: ['claude.ai'] }, +] + const TERMINAL_PROCESS_NAMES = new Set([ 'windowsterminal', 'windows terminal', @@ -37,12 +73,7 @@ const TERMINAL_PROCESS_NAMES = new Set([ 'tabby', ]) -const AI_APP_DEFINITIONS: Array<{ - id: string - label: string - processNames?: string[] - titlePatterns?: RegExp[] -}> = [ +const AI_APP_DEFINITIONS: AiAppDefinition[] = [ { id: 'codex', label: 'Codex', processNames: ['codex'], titlePatterns: [/\bcodex\b/i] }, { id: 'claude-code', label: 'Claude Code', titlePatterns: [/\bclaude\s+code\b/i] }, { id: 'claude', label: 'Claude', processNames: ['claude desktop', 'claude'], titlePatterns: [/\bclaude\b/i] }, @@ -69,7 +100,7 @@ using System; using System.Runtime.InteropServices; using System.Text; -public static class TimetableWin32 { +public static class TimeableWin32 { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); @@ -81,13 +112,13 @@ public static class TimetableWin32 { } "@ -function Write-TimetableJson($payload) { +function Write-TimeableJson($payload) { $payload | ConvertTo-Json -Compress -Depth 5 } function Get-WindowTitle($handle) { $builder = New-Object System.Text.StringBuilder 1024 - [void][TimetableWin32]::GetWindowText($handle, $builder, $builder.Capacity) + [void][TimeableWin32]::GetWindowText($handle, $builder, $builder.Capacity) return $builder.ToString() } @@ -114,17 +145,17 @@ function Looks-LikeUrl($value) { } try { - $handle = [TimetableWin32]::GetForegroundWindow() + $handle = [TimeableWin32]::GetForegroundWindow() if ($handle -eq [IntPtr]::Zero) { - Write-TimetableJson @{ isBrowser = $false } + Write-TimeableJson @{ isBrowser = $false } exit 0 } [uint32]$processId = 0 - [void][TimetableWin32]::GetWindowThreadProcessId($handle, [ref]$processId) + [void][TimeableWin32]::GetWindowThreadProcessId($handle, [ref]$processId) $process = Get-Process -Id $processId -ErrorAction SilentlyContinue if ($null -eq $process) { - Write-TimetableJson @{ isBrowser = $false } + Write-TimeableJson @{ isBrowser = $false } exit 0 } @@ -143,7 +174,7 @@ try { } if (-not $browserMap.ContainsKey($processName)) { - Write-TimetableJson @{ + Write-TimeableJson @{ isBrowser = $false processName = $processName title = Get-WindowTitle $handle @@ -170,7 +201,7 @@ try { } } - Write-TimetableJson @{ + Write-TimeableJson @{ isBrowser = $true processName = $processName browser = $browserMap[$processName] @@ -178,31 +209,17 @@ try { url = $url } } catch { - Write-TimetableJson @{ + Write-TimeableJson @{ isBrowser = $false error = $_.Exception.Message } } ` -type ActiveWindowInfo = { - isBrowser?: boolean - processName?: string - browser?: string - title?: string - url?: string | null -} - -type BrowserUsageTrackerServices = { - getData: () => AppData - recordUsage: (sample: BrowserPageSample, durationSeconds: number) => Promise - onDataChanged: (data: AppData) => void -} - export class BrowserUsageTracker { private timer?: NodeJS.Timeout private running = false - private previousSample: BrowserPageSample | null = null + private previousSample: NormalizedBrowserPageSample | null = null private previousSampleAt = 0 constructor(private readonly services: BrowserUsageTrackerServices) {} @@ -244,10 +261,9 @@ export class BrowserUsageTracker { Math.max(intervalSeconds * 2.5, intervalSeconds + 3), MAX_RECORDED_SAMPLE_SECONDS, ) - if (elapsedSeconds >= 1) { - const nextData = await this.services.recordUsage(this.previousSample, elapsedSeconds) - this.services.onDataChanged(nextData) + const patch = await this.services.recordUsage(this.previousSample, elapsedSeconds) + this.services.onDataPatched(patch) } } @@ -270,22 +286,25 @@ export class BrowserUsageTracker { } } -async function readActiveBrowserSample(): Promise { +async function readActiveBrowserSample(): Promise { if (process.platform !== 'win32') { return null } try { - const { stdout } = await execFileAsync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ACTIVE_BROWSER_SCRIPT], - { - encoding: 'utf8', - timeout: POWERSHELL_TIMEOUT_MS, - windowsHide: true, - maxBuffer: 1024 * 128, - }, - ) + const { stdout } = await execFileAsync('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + ACTIVE_BROWSER_SCRIPT, + ], { + encoding: 'utf8', + timeout: POWERSHELL_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 1024 * 128, + }) const activeWindow = parseActiveBrowserWindow(stdout) if (!activeWindow) { return null @@ -294,26 +313,37 @@ async function readActiveBrowserSample(): Promise { const processName = activeWindow.processName?.toLowerCase() ?? '' if (activeWindow.isBrowser && activeWindow.url) { const browser = activeWindow.browser ?? BROWSER_LABELS[processName] ?? processName - const aiWebService = isAdsPowerProcess(processName) ? null : getAiUsageServiceForUrl(activeWindow.url) + const aiWebService = detectAiWebService(activeWindow.url) if (aiWebService) { - return { + return normalizeBrowserPageSample({ url: `app://ai/${aiWebService.id}`, title: aiWebService.label, browser: aiWebService.label, usageType: 'ai', processName, observedAt: new Date().toISOString(), - } + }) } - return { + return normalizeBrowserPageSample({ url: activeWindow.url, title: cleanBrowserTitle(activeWindow.title ?? '', browser), browser, usageType: 'web', processName, observedAt: new Date().toISOString(), - } + }) + } + + if (isAdsPowerProcess(processName)) { + return normalizeBrowserPageSample({ + url: 'app://ai/claude', + title: 'Claude', + browser: 'Claude', + usageType: 'ai', + processName, + observedAt: new Date().toISOString(), + }) } const aiApp = detectAiApp(activeWindow) @@ -321,20 +351,20 @@ async function readActiveBrowserSample(): Promise { return null } - return { + return normalizeBrowserPageSample({ url: `app://ai/${aiApp.id}`, title: aiApp.label, browser: aiApp.label, usageType: 'ai', processName, observedAt: new Date().toISOString(), - } + }) } catch { return null } } -function parseActiveBrowserWindow(output: string): ActiveWindowInfo | null { +function parseActiveBrowserWindow(output: string): ActiveBrowserWindow | null { const start = output.indexOf('{') const end = output.lastIndexOf('}') if (start === -1 || end === -1 || end <= start) { @@ -342,7 +372,7 @@ function parseActiveBrowserWindow(output: string): ActiveWindowInfo | null { } try { - return JSON.parse(output.slice(start, end + 1)) as ActiveWindowInfo + return JSON.parse(output.slice(start, end + 1)) as ActiveBrowserWindow } catch { return null } @@ -355,12 +385,8 @@ function cleanBrowserTitle(title: string, browser: string): string { .trim() } -function detectAiApp(activeWindow: ActiveWindowInfo): { id: string; label: string } | null { +function detectAiApp(activeWindow: ActiveBrowserWindow): AiAppDefinition | null { const processName = activeWindow.processName?.toLowerCase().trim() ?? '' - if (isAdsPowerProcess(processName)) { - return null - } - const title = activeWindow.title?.trim() ?? '' const haystack = `${processName} ${title}` const terminalWindow = TERMINAL_PROCESS_NAMES.has(processName) @@ -372,6 +398,7 @@ function detectAiApp(activeWindow: ActiveWindowInfo): { id: string; label: strin if (app.processNames?.some((name) => name === processName)) { return app } + if (app.titlePatterns?.some((pattern) => pattern.test(haystack))) { return app } @@ -380,11 +407,26 @@ function detectAiApp(activeWindow: ActiveWindowInfo): { id: string; label: strin return null } +function detectAiWebService(rawUrl: string): AiWebService | null { + const normalizedUrl = /^[a-z][a-z\d+.-]*:\/\//i.test(rawUrl.trim()) ? rawUrl.trim() : `https://${rawUrl.trim()}` + try { + const host = new URL(normalizedUrl).hostname.toLowerCase().replace(/^www\./, '') + return AI_WEB_SERVICES.find((service) => service.domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) ?? null + } catch { + return null + } +} + +function isAdsPowerProcess(processName: string): boolean { + return processName === 'adspower' || processName === 'adspower global' || processName === 'adspower_global' +} + function getSampleIntervalSeconds(data: AppData): number { const configured = data.appSettings.browserTrackingIntervalSeconds if (!Number.isFinite(configured)) { return DEFAULT_SAMPLE_INTERVAL_SECONDS } + return Math.min(MAX_SAMPLE_INTERVAL_SECONDS, Math.max(MIN_SAMPLE_INTERVAL_SECONDS, Math.round(configured))) } diff --git a/src/main/githubUpdate.ts b/src/main/githubUpdate.ts deleted file mode 100644 index bc078c4..0000000 --- a/src/main/githubUpdate.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { app, dialog, type BrowserWindow, type MessageBoxOptions } from 'electron' -import type { GithubUpdateInfo, GithubUpdateInstallResult } from '@shared/ipc' -import type { AppStorage } from './storage' - -const GITHUB_UPDATE_REPO = 'Evander764/Timetable' -const GITHUB_UPDATE_ENDPOINT = `https://api.github.com/repos/${GITHUB_UPDATE_REPO}/releases/latest` -const GITHUB_UPDATE_USER_AGENT = 'Timetable-Updater' -const SHA256_ASSET_NAME = 'SHA256SUMS.txt' - -type GithubReleaseAsset = { - name?: string - size?: number - browser_download_url?: string -} - -type GithubRelease = { - tag_name?: string - name?: string - published_at?: string - body?: string - assets?: GithubReleaseAsset[] -} - -type GithubUpdateCandidate = GithubUpdateInfo & { - asarDownloadUrl?: string - shaDownloadUrl?: string -} - -function parseVersionParts(version: string): number[] { - return String(version ?? '') - .trim() - .replace(/^v/i, '') - .split(/[.-]/) - .map((part) => { - const value = Number.parseInt(part, 10) - return Number.isFinite(value) ? value : 0 - }) -} - -function isNewerVersion(candidate: string, current: string): boolean { - const nextParts = parseVersionParts(candidate) - const currentParts = parseVersionParts(current) - const length = Math.max(nextParts.length, currentParts.length, 3) - - for (let index = 0; index < length; index += 1) { - const nextValue = nextParts[index] ?? 0 - const currentValue = currentParts[index] ?? 0 - - if (nextValue > currentValue) { - return true - } - - if (nextValue < currentValue) { - return false - } - } - - return false -} - -async function fetchWithTimeout(url: string, timeoutMs: number): Promise { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(url, { - headers: { - Accept: 'application/vnd.github+json', - 'User-Agent': GITHUB_UPDATE_USER_AGENT, - }, - signal: controller.signal, - }) - - if (!response.ok) { - throw new Error(`GitHub update request failed: ${response.status}`) - } - - return response - } finally { - clearTimeout(timer) - } -} - -function getPackagedAsarPath(): string | null { - if (!app.isPackaged) { - return null - } - - return join(process.resourcesPath, 'app.asar') -} - -function formatUpdateTimestamp(): string { - return new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14) -} - -function toPublicUpdateInfo(candidate: GithubUpdateCandidate): GithubUpdateInfo { - return { - available: candidate.available, - currentVersion: candidate.currentVersion, - latestVersion: candidate.latestVersion, - releaseName: candidate.releaseName, - publishedAt: candidate.publishedAt, - body: candidate.body, - assetName: candidate.assetName, - assetSize: candidate.assetSize, - error: candidate.error, - } -} - -async function getGithubUpdateCandidate(): Promise { - const currentVersion = app.getVersion() - if (!app.isPackaged) { - return { available: false, currentVersion } - } - - try { - const releaseResponse = await fetchWithTimeout(GITHUB_UPDATE_ENDPOINT, 8_000) - const release = await releaseResponse.json() as GithubRelease - const latestVersion = String(release?.tag_name ?? '').replace(/^v/i, '') - - if (!latestVersion || !isNewerVersion(latestVersion, currentVersion)) { - return { - available: false, - currentVersion, - latestVersion: latestVersion || undefined, - releaseName: release.name, - publishedAt: release.published_at, - body: release.body, - } - } - - const assets = Array.isArray(release.assets) ? release.assets : [] - const asarAsset = assets.find((asset) => asset?.name === 'app.asar') - ?? assets.find((asset) => String(asset?.name ?? '').toLowerCase().endsWith('.asar')) - const shaAsset = assets.find((asset) => asset?.name === SHA256_ASSET_NAME) - - if (!asarAsset?.browser_download_url) { - return { - available: true, - currentVersion, - latestVersion, - releaseName: release.name, - publishedAt: release.published_at, - body: release.body, - error: 'GitHub release 中没有 app.asar 资源。', - } - } - - return { - available: true, - currentVersion, - latestVersion, - releaseName: release.name, - publishedAt: release.published_at, - body: release.body, - assetName: asarAsset.name, - assetSize: asarAsset.size, - asarDownloadUrl: asarAsset.browser_download_url, - shaDownloadUrl: shaAsset?.browser_download_url, - } - } catch (error) { - return { - available: false, - currentVersion, - error: error instanceof Error ? error.message : String(error), - } - } -} - -export async function checkForGithubUpdate(): Promise { - return toPublicUpdateInfo(await getGithubUpdateCandidate()) -} - -export async function promptForGithubUpdate(storage: AppStorage, window: BrowserWindow | null): Promise { - const data = storage.getData() - if (!data.appSettings.autoCheckForUpdates) { - return - } - - await storage.updateSettings({ appSettings: { lastUpdateCheckAt: new Date().toISOString() } }) - const update = await getGithubUpdateCandidate() - if (!update.available || update.error) { - return - } - - const sizeLabel = update.assetSize ? `\n下载大小:${Math.round(update.assetSize / 1024 / 1024)} MB` : '' - const body = update.body ? `\n\n${update.body.slice(0, 900)}` : '' - const messageBoxOptions: MessageBoxOptions = { - type: 'info', - buttons: ['立即更新', '稍后'], - defaultId: 0, - cancelId: 1, - title: '发现 Timetable 新版本', - message: `发现新版本 v${update.latestVersion}`, - detail: `当前版本:v${update.currentVersion}${sizeLabel}${body}`, - } - const result = window ? await dialog.showMessageBox(window, messageBoxOptions) : await dialog.showMessageBox(messageBoxOptions) - - if (result.response !== 0) { - return - } - - await installGithubUpdate(storage, update) -} - -export async function installGithubUpdate(storage: AppStorage, candidate?: GithubUpdateCandidate): Promise { - const update = candidate ?? await getGithubUpdateCandidate() - if (!update.available) { - return { started: false, error: '当前已经是最新版本。' } - } - if (update.error) { - return { started: false, error: update.error } - } - if (!update.asarDownloadUrl || !update.latestVersion) { - return { started: false, error: '新版资源不完整。' } - } - - try { - await storage.createBackup('pre-update', true) - - const updatesDir = join(app.getPath('userData'), 'updates') - await mkdir(updatesDir, { recursive: true }) - - const downloadedAsarPath = join(updatesDir, `app-${update.latestVersion}.asar`) - const assetResponse = await fetchWithTimeout(update.asarDownloadUrl, 120_000) - const assetBuffer = Buffer.from(await assetResponse.arrayBuffer()) - await verifySha256(update, assetBuffer) - await writeFile(downloadedAsarPath, assetBuffer) - - return { started: await installDownloadedAsar(downloadedAsarPath, update.latestVersion) } - } catch (error) { - return { - started: false, - error: error instanceof Error ? error.message : String(error), - } - } -} - -async function verifySha256(update: GithubUpdateCandidate, buffer: Buffer): Promise { - if (!update.shaDownloadUrl || !update.assetName) { - throw new Error('新版缺少 SHA256SUMS.txt,已取消安装。') - } - - const shaResponse = await fetchWithTimeout(update.shaDownloadUrl, 20_000) - const text = await shaResponse.text() - const expected = parseSha256(text, update.assetName) - if (!expected) { - throw new Error(`SHA256SUMS.txt 中没有 ${update.assetName}。`) - } - - const actual = createHash('sha256').update(buffer).digest('hex').toLowerCase() - if (actual !== expected.toLowerCase()) { - throw new Error('新版资源校验失败,已取消安装。') - } -} - -function parseSha256(text: string, assetName: string): string | null { - for (const line of text.split(/\r?\n/)) { - const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/) - if (match && match[2].trim() === assetName) { - return match[1] - } - } - return null -} - -async function installDownloadedAsar(downloadedAsarPath: string, latestVersion: string): Promise { - const targetAsar = getPackagedAsarPath() - - if (!targetAsar) { - return false - } - - const updatesDir = dirname(downloadedAsarPath) - const backupAsar = join(dirname(targetAsar), `app.asar.bak_update_${formatUpdateTimestamp()}`) - const scriptPath = join(updatesDir, 'install-github-update.ps1') - const script = [ - 'param(', - ' [int]$ProcessId,', - ' [string]$Source,', - ' [string]$Target,', - ' [string]$Backup,', - ' [string]$ExePath', - ')', - "$ErrorActionPreference = 'Stop'", - 'Wait-Process -Id $ProcessId -ErrorAction SilentlyContinue', - 'Start-Sleep -Seconds 1', - 'Copy-Item -LiteralPath $Target -Destination $Backup -Force', - 'Move-Item -LiteralPath $Source -Destination $Target -Force', - '$workDir = Split-Path -Parent $ExePath', - 'Start-Process -FilePath $ExePath -WorkingDirectory $workDir', - ].join('\n') - - await writeFile(scriptPath, script, 'utf8') - - const child = spawn( - 'powershell.exe', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-File', - scriptPath, - '-ProcessId', - String(process.pid), - '-Source', - downloadedAsarPath, - '-Target', - targetAsar, - '-Backup', - backupAsar, - '-ExePath', - process.execPath, - ], - { - detached: true, - stdio: 'ignore', - windowsHide: true, - }, - ) - - child.unref() - console.info(`Installing Timetable update ${latestVersion} from GitHub release.`) - app.quit() - - return true -} diff --git a/src/main/index.ts b/src/main/index.ts index d7d3d9a..45d6213 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,19 +1,22 @@ -import { join } from 'node:path' +import { access, copyFile, mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' import { app } from 'electron' import type { OverlayWidgetUpdatePayload } from '@shared/ipc' -import { BrowserUsageTracker } from './browserUsageTracker' -import { promptForGithubUpdate } from './githubUpdate' import { registerIpcHandlers } from './ipc' +import { BrowserUsageTracker } from './browserUsageTracker' import { AppStorage } from './storage' import { getLaunchAtStartup } from './startup' +import { AppTray } from './tray' import { WindowManager } from './windows' let storage: AppStorage let windows: WindowManager -let browserUsageTracker: BrowserUsageTracker +let browserUsageTracker: BrowserUsageTracker | undefined +let appTray: AppTray | undefined async function bootstrap(): Promise { const dataPath = join(app.getPath('userData'), 'app-data.json') + await migrateLegacyTimeableData(dataPath) storage = new AppStorage(dataPath) await storage.initialize() await storage.updateSettings({ @@ -23,14 +26,17 @@ async function bootstrap(): Promise { }) windows = new WindowManager(async (payload: OverlayWidgetUpdatePayload) => { - const next = await storage.updateWidget(payload) - windows.broadcastData(next) - }, () => storage.getData()) + const patch = await storage.updateWidgetPatch(payload) + windows.broadcastPatch(patch) + }) - browserUsageTracker = new BrowserUsageTracker({ - getData: () => storage.getData(), - recordUsage: (sample, durationSeconds) => storage.recordBrowserUsage(sample, durationSeconds), - onDataChanged: (next) => windows.broadcastData(next), + appTray = new AppTray({ + showMainWindow: () => windows.showMainWindow(), + hideMainWindow: () => windows.hideMainWindow(), + quitApplication: async () => { + await storage.flush() + windows.quitApplication() + }, }) registerIpcHandlers({ @@ -42,8 +48,35 @@ async function bootstrap(): Promise { await windows.createMainWindow() await windows.syncOverlayWindows(storage.getData()) windows.broadcastData(storage.getData()) + + browserUsageTracker = new BrowserUsageTracker({ + getData: () => storage.getData(), + recordUsage: (sample, durationSeconds) => storage.recordBrowserUsagePatch(sample, durationSeconds), + onDataPatched: (patch) => windows.broadcastPatch(patch), + }) browserUsageTracker.start() - void promptForGithubUpdate(storage, windows.getMainWindow()).then(() => windows.broadcastData(storage.getData())) +} + +async function migrateLegacyTimeableData(dataPath: string): Promise { + try { + await access(dataPath) + return + } catch { + // Continue and try the previous product-name directory. + } + + const legacyDataPath = join(app.getPath('appData'), 'Timeable', 'app-data.json') + if (legacyDataPath === dataPath) { + return + } + + try { + await access(legacyDataPath) + await mkdir(dirname(dataPath), { recursive: true }) + await copyFile(legacyDataPath, dataPath) + } catch { + // If there is no previous data, normal initialization will create a fresh file. + } } app.whenReady().then(async () => { @@ -59,6 +92,7 @@ app.whenReady().then(async () => { app.on('before-quit', async () => { browserUsageTracker?.stop() + appTray?.destroy() await storage?.flush() }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0a0c9fb..f2f027b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,9 +1,8 @@ import { stat } from 'node:fs/promises' import { pathToFileURL } from 'node:url' -import { BrowserWindow, dialog, ipcMain, screen, shell, type OpenDialogOptions } from 'electron' +import { BrowserWindow, dialog, ipcMain, screen, type OpenDialogOptions } from 'electron' import type { AppData, WidgetPosition } from '@shared/types/app' import type { DataAction, OverlaySnapPositionPayload, OverlayWidgetUpdatePayload, SelectBackgroundResult, SettingsUpdatePayload, WindowControlAction } from '@shared/ipc' -import { checkForGithubUpdate, installGithubUpdate } from './githubUpdate' import { getLaunchAtStartup, setLaunchAtStartup } from './startup' import type { AppStorage } from './storage' import type { WindowManager } from './windows' @@ -26,13 +25,13 @@ export function registerIpcHandlers({ storage, windows, getData }: IpcServices): const next = await storage.updateSettings(payload) await windows.syncOverlayWindows(next) windows.broadcastData(next) - windows.refreshTrayMenu() return next }) ipcMain.handle('overlay:update', async (_event, payload: OverlayWidgetUpdatePayload) => { - const next = await storage.updateWidget(payload) + const patch = await storage.updateWidgetPatch(payload) + const next = storage.getData() await windows.syncOverlayWindows(next) - windows.broadcastData(next) + windows.broadcastPatch(patch) return next }) ipcMain.handle('overlay:snapPosition', async (_event, payload: OverlaySnapPositionPayload) => { @@ -84,53 +83,29 @@ export function registerIpcHandlers({ storage, windows, getData }: IpcServices): windows.broadcastData(storage.getData()) return result }) - ipcMain.handle('data:createBackup', async () => { - const result = await storage.createBackup('manual') - windows.broadcastData(storage.getData()) - return result - }) - ipcMain.handle('data:listBackups', async () => storage.listBackups()) - ipcMain.handle('data:restoreBackup', async (_event, filePath?: string) => { - const result = filePath - ? { canceled: false, filePath, data: await storage.restoreBackup(filePath) } - : await storage.restoreBackupFromDialog(windows.getMainWindow() ?? undefined) - if (!result.canceled && result.data) { - await windows.syncOverlayWindows(result.data) - windows.broadcastData(result.data) - } - return result - }) - ipcMain.handle('data:openBackupDir', async () => { - await shell.openPath(storage.getBackupDir()) - }) - ipcMain.handle('browserUsage:saveDay', async (_event, date: string) => storage.saveBrowserUsageDay(date, windows.getMainWindow() ?? undefined)) - ipcMain.handle('update:check', async () => { - const next = await storage.updateSettings({ appSettings: { lastUpdateCheckAt: new Date().toISOString() } }) + ipcMain.handle('data:listBackups', async () => storage.listDataBackups()) + ipcMain.handle('data:restoreBackup', async (_event, id: string) => { + const next = await storage.restoreDataBackup(id) + await windows.syncOverlayWindows(next) windows.broadcastData(next) - return checkForGithubUpdate() + return next }) - ipcMain.handle('update:install', async () => installGithubUpdate(storage)) + ipcMain.handle('browserUsage:saveDay', async (_event, date: string) => storage.saveBrowserUsageDay(date, windows.getMainWindow() ?? undefined)) ipcMain.handle('window:control', async (event, action: WindowControlAction) => { + const window = BrowserWindow.fromWebContents(event.sender) if (action === 'close') { + if (window && windows.shouldCloseToTray()) { + windows.hideMainWindow() + return + } + await storage.flush() - await windows.handleMainWindowCloseIntent(getData()) + windows.quitApplication() return } - if (action === 'quit') { - await windows.quitApplication() - return - } - if (action === 'hide') { - windows.hideMainWindow() - return - } - if (action === 'show') { - await windows.showMainWindow() - return - } - const window = BrowserWindow.fromWebContents(event.sender) + if (window) { - await windows.controlCurrentWindow(window, action) + windows.controlCurrentWindow(window, action) } }) ipcMain.handle('file:pathToUrl', async (_event, filePath: string) => pathToFileURL(filePath).toString()) diff --git a/src/main/storage.test.ts b/src/main/storage.test.ts index 57c8420..1b1aa88 100644 --- a/src/main/storage.test.ts +++ b/src/main/storage.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import * as fs from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDefaultAppData } from '@shared/data/defaults' vi.mock('electron', () => ({ dialog: { @@ -13,21 +14,27 @@ describe('AppStorage', () => { let tempDir: string beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'timetable-')) + vi.resetModules() + tempDir = await fs.mkdtemp(join(tmpdir(), 'timeable-')) vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-29T08:00:00.000Z')) }) afterEach(async () => { vi.useRealTimers() - await rm(tempDir, { recursive: true, force: true }) + vi.doUnmock('node:fs/promises') + vi.restoreAllMocks() + await fs.rm(tempDir, { recursive: true, force: true }) }) - it('creates default data on first boot and auto-saves debounced updates', async () => { + it('creates default data with a schema version and auto-saves debounced updates', async () => { const { AppStorage } = await import('./storage') const filePath = join(tempDir, 'app-data.json') const storage = new AppStorage(filePath) const initial = await storage.initialize() + expect(initial.schemaVersion).toBe(2) + expect(initial.countdownItems).toEqual([]) expect(initial.appSettings.dataPath).toBe(filePath) await storage.updateWithAction({ @@ -40,8 +47,253 @@ describe('AppStorage', () => { await vi.advanceTimersByTimeAsync(600) await storage.flush() - const saved = JSON.parse(await readFile(filePath, 'utf-8')) as typeof initial + const saved = JSON.parse(await fs.readFile(filePath, 'utf-8')) as typeof initial + expect(saved.schemaVersion).toBe(2) expect(saved.memos[0].status).toBe('ended') expect(saved.appSettings.lastSavedAt).toBeTruthy() + + const backups = await storage.listDataBackups() + expect(backups).toHaveLength(1) + expect(backups[0].reason).toBe('daily') + }) + + it('keeps the original file when an atomic rename fails', async () => { + const actualFs = await vi.importActual('node:fs/promises') + let failNextRename = false + const renameMock = vi.fn(async (oldPath, newPath) => { + if (failNextRename) { + failNextRename = false + throw new Error('simulated rename failure') + } + return actualFs.rename(oldPath, newPath) + }) + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + rename: renameMock, + })) + const { AppStorage } = await import('./storage') + const filePath = join(tempDir, 'app-data.json') + const original = createDefaultAppData(filePath) + await actualFs.writeFile(filePath, `${JSON.stringify(original, null, 2)}\n`, 'utf-8') + const storage = new AppStorage(filePath) + await storage.initialize() + + failNextRename = true + await storage.updateWithAction({ + type: 'memo/end', + payload: { + id: original.memos[0].id, + endedAt: '2026-04-29T08:01:00.000Z', + }, + }) + + await expect(storage.flush()).rejects.toThrow('simulated rename failure') + const saved = JSON.parse(await actualFs.readFile(filePath, 'utf-8')) as typeof original + expect(saved.memos[0].status).toBe('active') + expect(renameMock).toHaveBeenCalled() + vi.doUnmock('node:fs/promises') + }) + + it('returns a widget patch and still persists widget changes through debounce', async () => { + const { AppStorage } = await import('./storage') + const filePath = join(tempDir, 'app-data.json') + const storage = new AppStorage(filePath) + + await storage.initialize() + const patch = await storage.updateWidgetPatch({ + key: 'memo', + changes: { + x: 640, + y: 360, + width: 420, + }, + }) + + expect(patch).toMatchObject({ + type: 'widget/replace', + payload: { + key: 'memo', + widget: { + x: 640, + y: 360, + width: 420, + }, + }, + }) + if (patch.type !== 'widget/replace') { + throw new Error('Expected widget patch') + } + expect(patch.payload.widget.height).toBeGreaterThan(0) + + await vi.advanceTimersByTimeAsync(600) + await storage.flush() + const saved = JSON.parse(await fs.readFile(filePath, 'utf-8')) as ReturnType + expect(saved.desktopSettings.widgets.memo).toMatchObject({ + x: 640, + y: 360, + width: 420, + }) + }) + + it('preserves corrupt JSON and recreates default data', async () => { + const { AppStorage } = await import('./storage') + const filePath = join(tempDir, 'app-data.json') + await fs.writeFile(filePath, '{ broken json', 'utf-8') + const storage = new AppStorage(filePath) + + const data = await storage.initialize() + expect(data.schemaVersion).toBe(2) + const saved = JSON.parse(await fs.readFile(filePath, 'utf-8')) as typeof data + expect(saved.schemaVersion).toBe(2) + + const files = await fs.readdir(tempDir) + const corruptFiles = files.filter((file) => file.startsWith('app-data.corrupt-') && file.endsWith('.json')) + expect(corruptFiles).toHaveLength(1) + await expect(fs.readFile(join(tempDir, corruptFiles[0]), 'utf-8')).resolves.toBe('{ broken json') + }) + + it('backs up and normalizes legacy data through the migration path', async () => { + const { AppStorage } = await import('./storage') + const filePath = join(tempDir, 'app-data.json') + const legacy = createDefaultAppData(filePath) + const { + schemaVersion: _schemaVersion, + browserUsage: _browserUsage, + countdownItems: _countdownItems, + ...legacyWithoutVersionUsageAndCountdowns + } = legacy + await fs.writeFile(filePath, `${JSON.stringify(legacyWithoutVersionUsageAndCountdowns, null, 2)}\n`, 'utf-8') + const storage = new AppStorage(filePath) + + const data = await storage.initialize() + expect(data.schemaVersion).toBe(2) + expect(data.browserUsage).toEqual({}) + expect(data.countdownItems).toEqual([]) + + const saved = JSON.parse(await fs.readFile(filePath, 'utf-8')) as typeof data + expect(saved.schemaVersion).toBe(2) + expect(saved.browserUsage).toEqual({}) + expect(saved.countdownItems).toEqual([]) + + const backups = await storage.listDataBackups() + expect(backups).toHaveLength(1) + expect(backups[0].reason).toBe('migration') + }) + + it('migrates schema v1 data to v2 with countdown items', async () => { + const { AppStorage } = await import('./storage') + const filePath = join(tempDir, 'app-data.json') + const { countdownItems: _countdownItems, ...v1Data } = createDefaultAppData(filePath) + await fs.writeFile(filePath, `${JSON.stringify({ ...v1Data, schemaVersion: 1 }, null, 2)}\n`, 'utf-8') + + const storage = new AppStorage(filePath) + const data = await storage.initialize() + + expect(data.schemaVersion).toBe(2) + expect(data.countdownItems).toEqual([]) + + const saved = JSON.parse(await fs.readFile(filePath, 'utf-8')) as typeof data + expect(saved.schemaVersion).toBe(2) + expect(saved.countdownItems).toEqual([]) + + const backups = await storage.listDataBackups() + expect(backups.some((backup) => backup.reason === 'migration')).toBe(true) + }) + + it('lists backups and restores a selected backup', async () => { + const { AppStorage } = await import('./storage') + const filePath = join(tempDir, 'app-data.json') + const storage = new AppStorage(filePath) + const initial = await storage.initialize() + await storage.updateWithAction({ + type: 'memo/end', + payload: { + id: initial.memos[0].id, + endedAt: '2026-04-29T08:02:00.000Z', + }, + }) + await storage.flush() + + const backups = await storage.listDataBackups() + const dailyBackup = backups.find((backup) => backup.reason === 'daily') + expect(dailyBackup).toBeTruthy() + + const restored = await storage.restoreDataBackup(dailyBackup!.id) + expect(restored.memos[0].status).toBe('active') + + const afterRestoreBackups = await storage.listDataBackups() + expect(afterRestoreBackups.some((backup) => backup.reason === 'manual')).toBe(true) + }) + + it('auto-saves daily browser usage snapshots when usage is recorded', async () => { + const { AppStorage } = await import('./storage') + const { normalizeBrowserPageSample } = await import('@shared/utils/browserUsage') + const filePath = join(tempDir, 'app-data.json') + const storage = new AppStorage(filePath) + + await storage.initialize() + const sample = normalizeBrowserPageSample({ + url: 'https://chatgpt.com/c/private-conversation', + title: 'Private conversation title', + browser: 'Google Chrome', + observedAt: '2026-04-25T09:00:00', + }) + + expect(sample).not.toBeNull() + await storage.recordBrowserUsage(sample!, 75) + + const snapshotPath = join(tempDir, 'daily-usage', 'timetable-usage-2026-04-25.json') + const snapshot = JSON.parse(await fs.readFile(snapshotPath, 'utf-8')) as { + date: string + totalSeconds: number + aiSeconds: number + entries: Array<{ title: string; url?: string }> + } + + expect(snapshot.date).toBe('2026-04-25') + expect(snapshot.totalSeconds).toBe(75) + expect(snapshot.aiSeconds).toBe(75) + expect(snapshot.entries[0].title).toBe('ChatGPT') + expect(snapshot.entries[0]).not.toHaveProperty('url') + }) + + it('returns a browser usage day patch while auto-saving the daily snapshot', async () => { + const { AppStorage } = await import('./storage') + const { normalizeBrowserPageSample } = await import('@shared/utils/browserUsage') + const filePath = join(tempDir, 'app-data.json') + const storage = new AppStorage(filePath) + + await storage.initialize() + const sample = normalizeBrowserPageSample({ + url: 'https://example.com/docs', + title: 'Docs', + browser: 'Microsoft Edge', + observedAt: '2026-04-25T09:00:00.000Z', + }) + + expect(sample).not.toBeNull() + const patch = await storage.recordBrowserUsagePatch(sample!, 45) + + expect(patch).toMatchObject({ + type: 'browserUsage/dayReplace', + payload: { + date: '2026-04-25', + day: { + date: '2026-04-25', + totalSeconds: 45, + }, + }, + }) + if (patch.type !== 'browserUsage/dayReplace') { + throw new Error('Expected browser usage patch') + } + expect(patch.payload.day.pages['https://example.com/docs']).toMatchObject({ + title: 'Docs', + totalSeconds: 45, + }) + + const snapshotPath = join(tempDir, 'daily-usage', 'timetable-usage-2026-04-25.json') + const snapshot = JSON.parse(await fs.readFile(snapshotPath, 'utf-8')) as { totalSeconds: number } + expect(snapshot.totalSeconds).toBe(45) }) }) diff --git a/src/main/storage.ts b/src/main/storage.ts index 3a129c5..b95415e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,17 +1,30 @@ -import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import * as fs from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { dialog, type BrowserWindow } from 'electron' -import type { AppData, BrowserPageSample } from '@shared/types/app' -import type { BackupInfo, DataAction, ExportDataResult, OverlayWidgetUpdatePayload, SettingsUpdatePayload } from '@shared/ipc' import { createDefaultAppData } from '@shared/data/defaults' +import { migrateAppData, type MigratableAppData } from '@shared/data/migrations' import { applyDataAction, applyOverlayWidgetUpdate, applySettingsUpdate } from '@shared/data/reducer' +import { APP_DATA_SCHEMA_VERSION, type AppData } from '@shared/types/app' +import type { + AppDataPatch, + DataAction, + DataBackupReason, + DataBackupSummary, + ExportDataResult, + OverlayWidgetUpdatePayload, + SettingsUpdatePayload, +} from '@shared/ipc' +import { createBrowserUsageDaySnapshot, recordBrowserUsageSample, type NormalizedBrowserPageSample } from '@shared/utils/browserUsage' import { formatDateKey } from '@shared/utils/date' -import { createBrowserUsageDaySnapshot, normalizeBrowserPageSample, recordBrowserUsageSample } from '@shared/utils/browserUsage' -import { normalizeCourseTimeSlots, normalizeTermWeekCount } from '@shared/utils/course' -import { normalizePrincipleCard } from '@shared/utils/principle' -import { migrateDesktopThreePieceLayout, migrateLegacyDesktopScale, migrateOverlayOpacity, normalizeCountdownStripWidget } from '@shared/utils/widgets' +import { migrateDesktopThreePieceLayout, migrateLegacyDesktopScale, normalizeCountdownStripWidget, raiseOverlayOpacity } from '@shared/utils/widgets' -const AUTO_BACKUP_LIMIT = 30 +const BACKUP_LIMIT = 14 +const BACKUP_FILE_PATTERN = /^app-data\.(daily|migration|manual)\.(.+)\.json$/ + +type SaveOptions = { + skipDailyBackup?: boolean +} export class AppStorage { private readonly filePath: string @@ -24,24 +37,41 @@ export class AppStorage { } async initialize(): Promise { - await mkdir(dirname(this.filePath), { recursive: true }) - let loadedExistingData = false + await fs.mkdir(dirname(this.filePath), { recursive: true }) + await this.cleanupStaleTempFiles() try { - const raw = await readFile(this.filePath, 'utf-8') - const parsed = JSON.parse(raw) as Partial - this.data = this.normalizeData(parsed) - loadedExistingData = true - } catch { - this.data = createDefaultAppData(this.filePath) - await this.saveNow() + const raw = await fs.readFile(this.filePath, 'utf-8') + const parsed = parseAppDataJson(raw) + assertMinimumAppDataShape(parsed) + + const migrated = migrateAppData(parsed, this.filePath) + if (migrated.migrated) { + await this.createBackup('migration') + } + + this.data = this.normalizeData(migrated.data) + this.data.appSettings.dataPath = this.filePath + + if (migrated.migrated) { + await this.saveNow({ skipDailyBackup: true }) + } else if (shouldPersistNormalizedData(parsed, this.data)) { + this.dirty = true + } + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + this.data = createDefaultAppData(this.filePath) + await this.saveNow({ skipDailyBackup: true }) + } else if (isUnsupportedSchemaError(error)) { + throw error + } else { + await this.preserveCorruptData() + this.data = createDefaultAppData(this.filePath) + await this.saveNow({ skipDailyBackup: true }) + } } - this.data.appSettings.dataPath = this.filePath await this.autoSaveKnownBrowserUsageDays() - if (loadedExistingData && this.data.appSettings.autoBackupEnabled) { - await this.createBackup('startup', true) - } return this.getData() } @@ -63,21 +93,44 @@ export class AppStorage { } async updateWidget(payload: OverlayWidgetUpdatePayload): Promise { + await this.updateWidgetPatch(payload) + return this.getData() + } + + async updateWidgetPatch(payload: OverlayWidgetUpdatePayload): Promise { this.data = applyOverlayWidgetUpdate(this.data, payload) this.markDirty() + return { + type: 'widget/replace', + payload: { + key: payload.key, + widget: { ...this.data.desktopSettings.widgets[payload.key] }, + }, + } + } + + async recordBrowserUsage(sample: NormalizedBrowserPageSample, durationSeconds: number): Promise { + await this.recordBrowserUsagePatch(sample, durationSeconds) return this.getData() } - async recordBrowserUsage(sample: BrowserPageSample, durationSeconds: number): Promise { - const normalizedSample = normalizeBrowserPageSample(sample) - if (!normalizedSample) { - return this.getData() + async recordBrowserUsagePatch(sample: NormalizedBrowserPageSample, durationSeconds: number): Promise { + const date = getSampleDateKey(sample.observedAt) + this.data = recordBrowserUsageSample(this.data, sample, durationSeconds) + this.markDirty() + await this.autoSaveBrowserUsageDay(date) + const day = this.data.browserUsage[date] + if (!day) { + throw new Error(`Browser usage day was not recorded: ${date}`) } - this.data = recordBrowserUsageSample(this.data, normalizedSample, durationSeconds) - this.markDirty() - await this.autoSaveBrowserUsageDay(getSampleDateKey(sample.observedAt)) - return this.getData() + return { + type: 'browserUsage/dayReplace', + payload: { + date, + day: structuredClone(day), + }, + } } async setLaunchAtStartup(enabled: boolean): Promise { @@ -92,14 +145,19 @@ export class AppStorage { return this.getData() } - async saveNow(): Promise { + async saveNow(options: SaveOptions = {}): Promise { if (!this.data) { return } clearTimeout(this.saveTimer) + if (!options.skipDailyBackup) { + await this.ensureDailyBackupBeforeSave() + } + this.data = { ...this.data, + schemaVersion: APP_DATA_SCHEMA_VERSION, appSettings: { ...this.data.appSettings, dataPath: this.filePath, @@ -107,9 +165,9 @@ export class AppStorage { }, } - await writeFile(this.filePath, `${JSON.stringify(this.data, null, 2)}\n`, 'utf-8') + await this.writeJsonFileAtomic(this.filePath, this.data) + await this.cleanupStaleTempFiles() this.dirty = false - await this.ensureDailyBackup() } async flush(): Promise { @@ -120,9 +178,9 @@ export class AppStorage { async exportData(window?: BrowserWindow): Promise { const options = { - title: '导出 Timetable 数据', + title: 'Export Timetable data', defaultPath: 'timetable-backup.json', - filters: [{ name: 'JSON 文件', extensions: ['json'] }], + filters: [{ name: 'JSON file', extensions: ['json'] }], } const result = window ? await dialog.showSaveDialog(window, options) : await dialog.showSaveDialog(options) @@ -130,7 +188,7 @@ export class AppStorage { return { canceled: true } } - await writeFile(result.filePath, `${JSON.stringify(this.data, null, 2)}\n`, 'utf-8') + await this.writeJsonFileAtomic(result.filePath, this.data) this.data = { ...this.data, appSettings: { @@ -146,103 +204,41 @@ export class AppStorage { } } - async createBackup(reason: 'startup' | 'daily' | 'manual' | 'pre-update' | 'before-restore' = 'manual', automatic = false): Promise { - await mkdir(this.getBackupDir(), { recursive: true }) - const timestamp = formatBackupTimestamp(new Date()) - const prefix = automatic || reason === 'startup' || reason === 'daily' || reason === 'pre-update' ? 'timetable-backup' : `timetable-${reason}` - const filePath = join(this.getBackupDir(), `${prefix}-${timestamp}.json`) - await writeFile(filePath, `${JSON.stringify(this.data, null, 2)}\n`, 'utf-8') + async listDataBackups(): Promise { + await fs.mkdir(this.getBackupDir(), { recursive: true }) + const entries = await fs.readdir(this.getBackupDir(), { withFileTypes: true }) + const backups = await Promise.all(entries + .filter((entry) => entry.isFile()) + .map((entry) => this.getBackupSummary(entry.name))) - const createdAt = new Date().toISOString() - this.data = { - ...this.data, - appSettings: { - ...this.data.appSettings, - lastBackupAt: createdAt, - lastBackupPath: filePath, - lastAutoBackupDate: automatic || reason === 'startup' || reason === 'daily' ? formatDateKey(new Date()) : this.data.appSettings.lastAutoBackupDate, - }, - } + return backups + .filter((backup): backup is DataBackupSummary => backup !== null) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + } - if (automatic || reason === 'startup' || reason === 'daily' || reason === 'pre-update') { - await this.cleanupAutomaticBackups() + async restoreDataBackup(id: string): Promise { + const backup = await this.findBackupById(id) + if (!backup) { + throw new Error(`Backup not found: ${id}`) } - this.markDirty() - return { - name: basename(filePath), - filePath, - createdAt, - reason, - size: (await stat(filePath)).size, - } - } + const raw = await fs.readFile(backup.filePath, 'utf-8') + const parsed = parseAppDataJson(raw) + assertMinimumAppDataShape(parsed) - async listBackups(): Promise { - await mkdir(this.getBackupDir(), { recursive: true }) - const entries = await readdir(this.getBackupDir(), { withFileTypes: true }) - const backups = await Promise.all(entries - .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json')) - .map(async (entry) => { - const filePath = join(this.getBackupDir(), entry.name) - const fileStat = await stat(filePath) - return { - name: entry.name, - filePath, - createdAt: fileStat.birthtime.toISOString(), - reason: inferBackupReason(entry.name), - size: fileStat.size, - } satisfies BackupInfo - })) - - return backups.sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - } - - async restoreBackup(filePath: string): Promise { - await this.createBackup('before-restore') - const raw = await readFile(filePath, 'utf-8') - const parsed = JSON.parse(raw) as Partial - this.data = this.normalizeData(parsed) + await this.createBackup('manual') + const migrated = migrateAppData(parsed, this.filePath) + this.data = this.normalizeData(migrated.data) this.data.appSettings.dataPath = this.filePath - await this.saveNow() + await this.saveNow({ skipDailyBackup: true }) return this.getData() } - async restoreBackupFromDialog(window?: BrowserWindow): Promise<{ canceled: boolean; data?: AppData; filePath?: string }> { - const result = window - ? await dialog.showOpenDialog(window, { - title: '从备份恢复 Timetable 数据', - properties: ['openFile'], - filters: [{ name: 'JSON 文件', extensions: ['json'] }], - defaultPath: this.getBackupDir(), - }) - : await dialog.showOpenDialog({ - title: '从备份恢复 Timetable 数据', - properties: ['openFile'], - filters: [{ name: 'JSON 文件', extensions: ['json'] }], - defaultPath: this.getBackupDir(), - }) - - if (result.canceled || result.filePaths.length === 0) { - return { canceled: true } - } - - return { - canceled: false, - filePath: result.filePaths[0], - data: await this.restoreBackup(result.filePaths[0]), - } - } - - getBackupDir(): string { - return join(dirname(this.filePath), 'backups') - } - async saveBrowserUsageDay(date: string, window?: BrowserWindow): Promise { const options = { - title: '保存每日时间统计', + title: 'Save daily usage statistics', defaultPath: `timetable-usage-${date}.json`, - filters: [{ name: 'JSON 文件', extensions: ['json'] }], + filters: [{ name: 'JSON file', extensions: ['json'] }], } const result = window ? await dialog.showSaveDialog(window, options) : await dialog.showSaveDialog(options) @@ -251,13 +247,14 @@ export class AppStorage { } await this.writeBrowserUsageDaySnapshot(date, result.filePath) + return { canceled: false, filePath: result.filePath, } } - async autoSaveBrowserUsageDay(date: string): Promise { + private async autoSaveBrowserUsageDay(date: string): Promise { try { await this.writeBrowserUsageDaySnapshot(date, this.getBrowserUsageAutoSavePath(date)) } catch (error) { @@ -265,10 +262,20 @@ export class AppStorage { } } - async autoSaveKnownBrowserUsageDays(): Promise { + private async autoSaveKnownBrowserUsageDays(): Promise { await Promise.all(Object.keys(this.data.browserUsage).map((date) => this.autoSaveBrowserUsageDay(date))) } + private async writeBrowserUsageDaySnapshot(date: string, filePath: string): Promise { + await fs.mkdir(dirname(filePath), { recursive: true }) + const snapshot = createBrowserUsageDaySnapshot(this.data, date) + await this.writeJsonFileAtomic(filePath, snapshot) + } + + private getBrowserUsageAutoSavePath(date: string): string { + return join(dirname(this.filePath), 'daily-usage', `timetable-usage-${date}.json`) + } + private markDirty(): void { this.dirty = true clearTimeout(this.saveTimer) @@ -282,21 +289,22 @@ export class AppStorage { }, 500) } - private normalizeData(raw: Partial): AppData { + private normalizeData(raw: MigratableAppData): AppData { const defaults = createDefaultAppData(this.filePath) const previousLayoutVersion = raw.appSettings?.desktopLayoutVersion - const previousOpacityVersion = raw.appSettings?.opacityVersion const normalized = normalizeCountdownStripWidget(migrateLegacyDesktopScale({ ...defaults, ...raw, + schemaVersion: APP_DATA_SCHEMA_VERSION, courses: raw.courses ?? defaults.courses, dailyTasks: raw.dailyTasks ?? defaults.dailyTasks, longTermGoals: raw.longTermGoals ?? defaults.longTermGoals, memos: raw.memos ?? defaults.memos, - principleCard: normalizePrincipleCard({ + countdownItems: raw.countdownItems ?? defaults.countdownItems, + principleCard: { ...defaults.principleCard, ...raw.principleCard, - }), + }, countdownCard: { ...defaults.countdownCard, ...raw.countdownCard, @@ -313,62 +321,220 @@ export class AppStorage { ...defaults.appSettings, ...raw.appSettings, dataPath: this.filePath, - termWeekCount: normalizeTermWeekCount(raw.appSettings?.termWeekCount ?? defaults.appSettings.termWeekCount), - timetableSlots: normalizeCourseTimeSlots(raw.appSettings?.timetableSlots ?? defaults.appSettings.timetableSlots), }, browserUsage: raw.browserUsage ?? defaults.browserUsage, })) - return migrateOverlayOpacity(migrateDesktopThreePieceLayout(normalized, previousLayoutVersion), previousOpacityVersion) + const migratedLayout = migrateDesktopThreePieceLayout(normalized, previousLayoutVersion) + if (previousLayoutVersion && previousLayoutVersion >= 3) { + return migratedLayout + } + + const raisedOpacity = raiseOverlayOpacity(migratedLayout) + return { + ...raisedOpacity, + schemaVersion: APP_DATA_SCHEMA_VERSION, + appSettings: { + ...raisedOpacity.appSettings, + desktopLayoutVersion: 3, + }, + } } - private async ensureDailyBackup(): Promise { - if (!this.data.appSettings.autoBackupEnabled) { + private async preserveCorruptData(): Promise { + if (!(await fileExists(this.filePath))) { return } - const todayKey = formatDateKey(new Date()) - if (this.data.appSettings.lastAutoBackupDate === todayKey) { + const corruptPath = join(dirname(this.filePath), `app-data.corrupt-${createFileTimestamp()}.json`) + await fs.copyFile(this.filePath, corruptPath) + } + + private async ensureDailyBackupBeforeSave(): Promise { + if (!(await fileExists(this.filePath))) { return } - await this.createBackup('daily', true) + try { + const today = new Date().toISOString().slice(0, 10) + const backups = await this.listDataBackups() + const alreadyBackedUpToday = backups.some((backup) => backup.reason === 'daily' && backup.id.includes(today)) + if (!alreadyBackedUpToday) { + await this.createBackup('daily') + } + } catch (error) { + console.error('Failed to create daily app data backup.', error) + } } - private async cleanupAutomaticBackups(): Promise { - const backups = (await this.listBackups()) - .filter((backup) => backup.name.startsWith('timetable-backup-')) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - const extra = backups.slice(AUTO_BACKUP_LIMIT) - await Promise.all(extra.map((backup) => unlink(backup.filePath).catch(() => undefined))) + private async createBackup(reason: DataBackupReason): Promise { + if (!(await fileExists(this.filePath))) { + return null + } + + await fs.mkdir(this.getBackupDir(), { recursive: true }) + const fileName = `app-data.${reason}.${createFileTimestamp()}.json` + const filePath = join(this.getBackupDir(), fileName) + await fs.copyFile(this.filePath, filePath) + await this.pruneBackups() + return this.getBackupSummary(fileName) } - private async writeBrowserUsageDaySnapshot(date: string, filePath: string): Promise { - await mkdir(dirname(filePath), { recursive: true }) - const snapshot = createBrowserUsageDaySnapshot(this.data, date) - await writeFile(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf-8') + private async pruneBackups(): Promise { + const backups = await this.listDataBackups() + const staleBackups = backups.slice(BACKUP_LIMIT) + await Promise.all(staleBackups.map((backup) => fs.rm(backup.filePath, { force: true }))) } - private getBrowserUsageAutoSavePath(date: string): string { - return join(dirname(this.filePath), 'daily-usage', `timetable-usage-${date}.json`) + private async findBackupById(id: string): Promise { + const backups = await this.listDataBackups() + return backups.find((backup) => backup.id === id) ?? null + } + + private async getBackupSummary(fileName: string): Promise { + const match = fileName.match(BACKUP_FILE_PATTERN) + if (!match) { + return null + } + + const filePath = join(this.getBackupDir(), fileName) + const stats = await fs.stat(filePath) + return { + id: fileName.replace(/\.json$/, ''), + createdAt: stats.mtime.toISOString(), + reason: match[1] as DataBackupReason, + filePath, + size: stats.size, + } + } + + private getBackupDir(): string { + return join(dirname(this.filePath), 'backups') + } + + private async writeJsonFileAtomic(filePath: string, value: unknown): Promise { + await fs.mkdir(dirname(filePath), { recursive: true }) + const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}` + + try { + await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8') + try { + await fs.rename(tempPath, filePath) + } catch (error) { + if (!isRetryableReplaceError(error) || !(await fileExists(filePath))) { + throw error + } + + await this.replaceExistingFileWithBackup(tempPath, filePath) + } + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined) + throw error + } + } + + private async replaceExistingFileWithBackup(tempPath: string, filePath: string): Promise { + const backupPath = `${filePath}.replace-${process.pid}-${Date.now()}-${randomUUID()}` + let oldFileMoved = false + + try { + await fs.rename(filePath, backupPath) + oldFileMoved = true + await fs.rename(tempPath, filePath) + await fs.rm(backupPath, { force: true }) + } catch (error) { + if (oldFileMoved && (await fileExists(backupPath)) && !(await fileExists(filePath))) { + await fs.rename(backupPath, filePath).catch(() => undefined) + } + throw error + } + } + + private async cleanupStaleTempFiles(): Promise { + const directory = dirname(this.filePath) + const filePrefix = `${basename(this.filePath)}.tmp-` + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []) + await Promise.all(entries + .filter((entry) => entry.isFile() && entry.name.startsWith(filePrefix)) + .map((entry) => fs.rm(join(directory, entry.name), { force: true }))) } } -function formatBackupTimestamp(date: Date): string { - const pad = (value: number) => value.toString().padStart(2, '0') - return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` +function parseAppDataJson(raw: string): MigratableAppData { + const parsed = JSON.parse(raw) as unknown + assertRecord(parsed, 'app data root') + return parsed as MigratableAppData } -function inferBackupReason(name: string): BackupInfo['reason'] { - if (name.includes('before-restore')) { - return 'before-restore' +function assertMinimumAppDataShape(value: MigratableAppData): void { + assertOptionalArray(value, 'courses') + assertOptionalArray(value, 'dailyTasks') + assertOptionalArray(value, 'longTermGoals') + assertOptionalArray(value, 'memos') + assertOptionalArray(value, 'countdownItems') + assertOptionalRecord(value, 'principleCard') + assertOptionalRecord(value, 'countdownCard') + assertOptionalRecord(value, 'desktopSettings') + assertOptionalRecord(value, 'appSettings') + assertOptionalRecord(value, 'browserUsage') + if (value.schemaVersion !== undefined && typeof value.schemaVersion !== 'number') { + throw new Error('Invalid schemaVersion') } - if (name.includes('pre-update')) { - return 'pre-update' +} + +function assertOptionalArray(value: Record, key: string): void { + if (value[key] !== undefined && !Array.isArray(value[key])) { + throw new Error(`Invalid ${key}`) } - if (name.includes('manual')) { - return 'manual' +} + +function assertOptionalRecord(value: Record, key: string): void { + if (value[key] !== undefined) { + assertRecord(value[key], key) + } +} + +function assertRecord(value: unknown, label: string): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid ${label}`) } - return 'daily' +} + +function shouldPersistNormalizedData(raw: MigratableAppData, normalized: AppData): boolean { + return raw.schemaVersion !== normalized.schemaVersion + || !Array.isArray(raw.countdownItems) + || raw.appSettings?.dataPath !== normalized.appSettings.dataPath + || raw.appSettings?.desktopLayoutVersion !== normalized.appSettings.desktopLayoutVersion +} + +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath) + return true + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + return false + } + throw error + } +} + +function isNodeError(error: unknown, code: string): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error as NodeJS.ErrnoException).code === code +} + +function isUnsupportedSchemaError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith('Unsupported app data schema version:') +} + +function isRetryableReplaceError(error: unknown): boolean { + return isNodeError(error, 'EPERM') || isNodeError(error, 'EBUSY') || isNodeError(error, 'EACCES') +} + +function createFileTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, '-') } function getSampleDateKey(observedAt: string): string { diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..c9ea99c --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,54 @@ +import { Menu, Tray, nativeImage } from 'electron' + +type AppTrayOptions = { + showMainWindow: () => Promise + hideMainWindow: () => void + quitApplication: () => Promise +} + +const TRAY_ICON_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAG8SURBVFhH7VahUsNAEEXiYIa5Kw4kEonsJ1QikTgq2luqwCH5BGRnapBoFH/QSmRlZN0us5cJzO0ml1wucbyZJzLZ2/c2t7e5o6N/9IRZ0PUEaFqRn2XMoLh4omMDdGcA18bhwQJRHQ3gu13i/ckDncocvcEJLeBeisWJhXU4Z+MyX2dwFQbwSydPoMPdBOhS5m4FLzIOv1XCXsSC+0RqNMJXPph4RSzOVnQltRTKZsv87A3kolqbkxtHLqy42lI7tnpdYALwVWr+gquPdfsgBhwezILOpbZHedz0olpu/jQ/NzXvY3T4IrU9LOCHCm5inoGd1K46v3HCKeYYACJ1IqzDGxkUZaYBu6RZaGBJMxUUY64Bh3NhIKEBmZkGDNBzYGDi8FYGRZlv4DE0ADSVQVFmGuCCAwM8HGRQlJkGuOkDA95Eyj8gywDupbYHN4YOHoEO36S2B29D0jDqyej9kf9WcsGQ5Duj1AxQNmPzHzGH/HXVCK4Dd+gYW6GOXgwcPKQJNfm6oBxOWMhkKeQikiqX8CcDcC0TdyE3XKc97wI+Onx+2xsUCzacdA1Phb87OJzzvlbk51FFx8IPLEGiuwqPuOUAAAAASUVORK5CYII=' + +export class AppTray { + private tray: Tray + + constructor(private readonly options: AppTrayOptions) { + const icon = nativeImage.createFromDataURL(TRAY_ICON_DATA_URL) + icon.setTemplateImage(false) + this.tray = new Tray(icon) + this.tray.setToolTip('Timetable') + this.tray.setContextMenu(this.createMenu()) + this.tray.on('click', () => { + void this.options.showMainWindow() + }) + this.tray.on('double-click', () => { + void this.options.showMainWindow() + }) + } + + destroy(): void { + this.tray.destroy() + } + + private createMenu(): Electron.Menu { + return Menu.buildFromTemplate([ + { + label: '显示 Timetable', + click: () => { + void this.options.showMainWindow() + }, + }, + { + label: '隐藏到托盘', + click: () => this.options.hideMainWindow(), + }, + { type: 'separator' }, + { + label: '彻底退出', + click: () => { + void this.options.quitApplication() + }, + }, + ]) + } +} diff --git a/src/main/windowing/geometry.test.ts b/src/main/windowing/geometry.test.ts new file mode 100644 index 0000000..a4d1e2e --- /dev/null +++ b/src/main/windowing/geometry.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + AUTO_HIDE_STRIP, + AUTO_HIDE_THRESHOLD, + clampOpacity, + constrainBoundsToDisplay, + getCollapsedBounds, + getCollapsibleEdge, + getDefaultMainWindowSize, + sameBounds, + snapBoundsToEdge, + type WindowBounds, +} from './geometry' + +const display: WindowBounds = { x: 0, y: 0, width: 1000, height: 700 } + +describe('window geometry', () => { + it('sizes the main window within large and small work areas', () => { + expect(getDefaultMainWindowSize({ x: 0, y: 0, width: 1920, height: 1080 })).toEqual({ + width: 1180, + height: 760, + minWidth: 1024, + minHeight: 660, + }) + + expect(getDefaultMainWindowSize({ x: 0, y: 0, width: 800, height: 500 })).toEqual({ + width: 800, + height: 500, + minWidth: 800, + minHeight: 500, + }) + }) + + it('constrains overlay bounds to the display work area', () => { + expect(constrainBoundsToDisplay({ x: -50, y: -20, width: 1200, height: 900 }, display)).toEqual(display) + expect(constrainBoundsToDisplay({ x: 950.8, y: 680.2, width: 100.2, height: 80.8 }, display)).toEqual({ + x: 900, + y: 619, + width: 100, + height: 81, + }) + }) + + it('detects collapsible edges near each display side', () => { + expect(getCollapsibleEdge({ x: 20, y: 200, width: 160, height: 120 }, display, AUTO_HIDE_THRESHOLD)).toBe('left') + expect(getCollapsibleEdge({ x: 820, y: 200, width: 160, height: 120 }, display, AUTO_HIDE_THRESHOLD)).toBe('right') + expect(getCollapsibleEdge({ x: 300, y: 20, width: 160, height: 120 }, display, AUTO_HIDE_THRESHOLD)).toBe('top') + expect(getCollapsibleEdge({ x: 300, y: 560, width: 160, height: 120 }, display, AUTO_HIDE_THRESHOLD)).toBe('bottom') + expect(getCollapsibleEdge({ x: 300, y: 300, width: 160, height: 120 }, display, AUTO_HIDE_THRESHOLD)).toBeUndefined() + }) + + it('snaps expanded bounds to an edge and keeps only a strip while collapsed', () => { + const bounds = { x: 16, y: 20, width: 200, height: 100 } + const expanded = snapBoundsToEdge(bounds, display, 'left') + expect(expanded).toEqual({ x: 0, y: 20, width: 200, height: 100 }) + expect(getCollapsedBounds(expanded, display, 'left')).toEqual({ + x: -200 + AUTO_HIDE_STRIP, + y: 20, + width: 200, + height: 100, + }) + + const rightExpanded = snapBoundsToEdge({ x: 820, y: 20, width: 200, height: 100 }, display, 'right') + expect(rightExpanded.x).toBe(800) + expect(getCollapsedBounds(rightExpanded, display, 'right').x).toBe(1000 - AUTO_HIDE_STRIP) + }) + + it('clamps opacity and compares bounds exactly', () => { + expect(clampOpacity(0)).toBe(0.2) + expect(clampOpacity(0.75)).toBe(0.75) + expect(clampOpacity(2)).toBe(1) + expect(sameBounds(display, { ...display })).toBe(true) + expect(sameBounds(display, { ...display, x: 1 })).toBe(false) + }) +}) diff --git a/src/main/windowing/geometry.ts b/src/main/windowing/geometry.ts new file mode 100644 index 0000000..b317bcc --- /dev/null +++ b/src/main/windowing/geometry.ts @@ -0,0 +1,115 @@ +export type WindowBounds = { + x: number + y: number + width: number + height: number +} + +export type CollapsibleEdge = 'left' | 'right' | 'top' | 'bottom' + +export const AUTO_HIDE_THRESHOLD = 40 +export const AUTO_HIDE_STRIP = 22 +export const AUTO_HIDE_DELAY_MS = 180 +export const PROGRAMMATIC_BOUNDS_SUPPRESS_MS = 180 + +const MAIN_WINDOW_DEFAULT_WIDTH = 1180 +const MAIN_WINDOW_DEFAULT_HEIGHT = 760 +const MAIN_WINDOW_MIN_WIDTH = 1024 +const MAIN_WINDOW_MIN_HEIGHT = 660 +const MAIN_WINDOW_SCREEN_MARGIN = 64 + +export function clampOpacity(value: number): number { + return Math.max(0.2, Math.min(1, value)) +} + +export function clamp(value: number, min: number, max: number): number { + if (max < min) { + return min + } + return Math.max(min, Math.min(max, value)) +} + +export function getDefaultMainWindowSize(workArea: WindowBounds): { + width: number + height: number + minWidth: number + minHeight: number +} { + const widthFloor = Math.min(MAIN_WINDOW_MIN_WIDTH, workArea.width) + const heightFloor = Math.min(MAIN_WINDOW_MIN_HEIGHT, workArea.height) + const width = Math.min(MAIN_WINDOW_DEFAULT_WIDTH, Math.max(widthFloor, workArea.width - MAIN_WINDOW_SCREEN_MARGIN)) + const height = Math.min(MAIN_WINDOW_DEFAULT_HEIGHT, Math.max(heightFloor, workArea.height - MAIN_WINDOW_SCREEN_MARGIN)) + + return { + width, + height, + minWidth: Math.min(MAIN_WINDOW_MIN_WIDTH, width), + minHeight: Math.min(MAIN_WINDOW_MIN_HEIGHT, height), + } +} + +export function constrainBoundsToDisplay(bounds: WindowBounds, display: WindowBounds): WindowBounds { + const width = Math.min(Math.max(1, Math.round(bounds.width)), display.width) + const height = Math.min(Math.max(1, Math.round(bounds.height)), display.height) + return { + x: clamp(Math.round(bounds.x), display.x, display.x + display.width - width), + y: clamp(Math.round(bounds.y), display.y, display.y + display.height - height), + width, + height, + } +} + +export function sameBounds(a: WindowBounds, b: WindowBounds): boolean { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + +export function pointInBounds(point: { x: number; y: number }, bounds: WindowBounds, margin = 0): boolean { + return point.x >= bounds.x - margin + && point.x <= bounds.x + bounds.width + margin + && point.y >= bounds.y - margin + && point.y <= bounds.y + bounds.height + margin +} + +export function snapBoundsToEdge(bounds: WindowBounds, display: WindowBounds, edge: CollapsibleEdge): WindowBounds { + const snapped = constrainBoundsToDisplay(bounds, display) + if (edge === 'left') { + return { ...snapped, x: display.x } + } + if (edge === 'right') { + return { ...snapped, x: display.x + display.width - snapped.width } + } + if (edge === 'top') { + return { ...snapped, y: display.y } + } + return { ...snapped, y: display.y + display.height - snapped.height } +} + +export function getCollapsedBounds(bounds: WindowBounds, display: WindowBounds, edge: CollapsibleEdge): WindowBounds { + const collapsed = { ...bounds } + if (edge === 'left') { + collapsed.x = display.x - bounds.width + AUTO_HIDE_STRIP + } else if (edge === 'right') { + collapsed.x = display.x + display.width - AUTO_HIDE_STRIP + } else if (edge === 'top') { + collapsed.y = display.y - bounds.height + AUTO_HIDE_STRIP + } else { + collapsed.y = display.y + display.height - AUTO_HIDE_STRIP + } + return collapsed +} + +export function getCollapsibleEdge(bounds: WindowBounds, display: WindowBounds, threshold: number): CollapsibleEdge | undefined { + if (Math.abs(bounds.x - display.x) <= threshold) { + return 'left' + } + if (Math.abs(display.x + display.width - (bounds.x + bounds.width)) <= threshold) { + return 'right' + } + if (Math.abs(bounds.y - display.y) <= threshold) { + return 'top' + } + if (Math.abs(display.y + display.height - (bounds.y + bounds.height)) <= threshold) { + return 'bottom' + } + return undefined +} diff --git a/src/main/windowing/mainWindowController.ts b/src/main/windowing/mainWindowController.ts new file mode 100644 index 0000000..6dc629d --- /dev/null +++ b/src/main/windowing/mainWindowController.ts @@ -0,0 +1,112 @@ +import { BrowserWindow, screen, shell } from 'electron' +import type { AppData } from '@shared/types/app' +import type { AppDataPatch, WindowControlAction, WindowStatePayload } from '@shared/ipc' +import { getDefaultMainWindowSize } from './geometry' + +type RouteLoader = (window: BrowserWindow, hash: string) => Promise + +type MainWindowControllerOptions = { + preloadPath: string + loadRoute: RouteLoader + isQuitting: () => boolean + shouldCloseToTray: () => boolean + quitApplication: () => void +} + +export class MainWindowController { + private mainWindow: BrowserWindow | null = null + + constructor(private readonly options: MainWindowControllerOptions) {} + + async createMainWindow(): Promise { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + return this.mainWindow + } + + const window = new BrowserWindow({ + ...getDefaultMainWindowSize(screen.getPrimaryDisplay().workArea), + frame: false, + titleBarStyle: 'hidden', + backgroundColor: '#EDF4FF', + show: false, + webPreferences: { + preload: this.options.preloadPath, + contextIsolation: true, + sandbox: false, + }, + }) + + window.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url) + return { action: 'deny' } + }) + + window.on('closed', () => { + this.mainWindow = null + }) + window.on('close', (event) => { + if (!this.options.isQuitting() && this.options.shouldCloseToTray()) { + event.preventDefault() + window.hide() + } + }) + window.on('maximize', () => this.sendWindowState(window, { isMaximized: true })) + window.on('unmaximize', () => this.sendWindowState(window, { isMaximized: false })) + window.once('ready-to-show', () => window.show()) + + await this.options.loadRoute(window, 'overview') + this.mainWindow = window + return window + } + + broadcastData(data: AppData): void { + this.mainWindow?.webContents.send('data:changed', data) + } + + broadcastPatch(patch: AppDataPatch): void { + this.mainWindow?.webContents.send('data:patched', patch) + } + + async showMainWindow(): Promise { + const window = await this.createMainWindow() + if (window.isMinimized()) { + window.restore() + } + window.show() + window.focus() + } + + hideMainWindow(): void { + this.mainWindow?.hide() + } + + controlCurrentWindow(window: BrowserWindow, action: WindowControlAction): void { + if (action === 'minimize') { + window.minimize() + return + } + if (action === 'maximize') { + if (window.isMaximized()) { + window.unmaximize() + } else { + window.maximize() + } + return + } + + if (this.options.shouldCloseToTray()) { + window.hide() + return + } + + this.options.quitApplication() + } + + getMainWindow(): BrowserWindow | null { + return this.mainWindow + } + + private sendWindowState(window: BrowserWindow, payload: WindowStatePayload): void { + window.webContents.send('window:state', payload) + } +} diff --git a/src/main/windowing/overlayWindowController.ts b/src/main/windowing/overlayWindowController.ts new file mode 100644 index 0000000..16093ab --- /dev/null +++ b/src/main/windowing/overlayWindowController.ts @@ -0,0 +1,349 @@ +import { BrowserWindow, screen } from 'electron' +import type { Rectangle } from 'electron/main' +import { applyDataPatch } from '@shared/data/reducer' +import type { AppData, WidgetKey } from '@shared/types/app' +import type { AppDataPatch, OverlayWidgetUpdatePayload } from '@shared/ipc' +import { + AUTO_HIDE_DELAY_MS, + AUTO_HIDE_THRESHOLD, + PROGRAMMATIC_BOUNDS_SUPPRESS_MS, + clampOpacity, + constrainBoundsToDisplay, + getCollapsedBounds, + getCollapsibleEdge, + pointInBounds, + sameBounds, + snapBoundsToEdge, + type CollapsibleEdge, + type WindowBounds, +} from './geometry' + +type RouteLoader = (window: BrowserWindow, hash: string) => Promise + +type OverlayRuntimeState = { + hidden: boolean + collapsedEdge?: CollapsibleEdge + expandedBounds?: Rectangle + suppressBoundsSync: boolean + syncTimer?: NodeJS.Timeout + hideTimer?: NodeJS.Timeout + suppressTimer?: NodeJS.Timeout +} + +type OverlayWindowControllerOptions = { + preloadPath: string + loadRoute: RouteLoader + onOverlayBoundsChanged: (payload: OverlayWidgetUpdatePayload) => Promise +} + +export class OverlayWindowController { + private readonly overlayWindows = new Map() + private readonly overlayState = new Map() + private latestData: AppData | null = null + + constructor(private readonly options: OverlayWindowControllerOptions) {} + + async syncOverlayWindows(data: AppData): Promise { + this.latestData = data + if (!data.desktopSettings.overlayEnabled) { + this.hideOverlayWindows() + return + } + + const keys = Object.keys(data.desktopSettings.widgets) as WidgetKey[] + for (const key of keys) { + const config = data.desktopSettings.widgets[key] + if (!config.enabled) { + this.destroyOverlayWindow(key) + continue + } + + const window = this.overlayWindows.get(key) ?? (await this.createOverlayWindow(key, data)) + this.applyOverlayWindowState(window, key, data) + } + + for (const key of [...this.overlayWindows.keys()]) { + if (!keys.includes(key) || !data.desktopSettings.widgets[key].enabled) { + this.destroyOverlayWindow(key) + } + } + } + + hideOverlayWindows(): void { + for (const key of [...this.overlayWindows.keys()]) { + this.destroyOverlayWindow(key) + } + } + + broadcastData(data: AppData): void { + for (const window of this.overlayWindows.values()) { + window.webContents.send('data:changed', data) + } + } + + broadcastPatch(patch: AppDataPatch): void { + if (this.latestData) { + this.latestData = applyDataPatch(this.latestData, patch) + } + + for (const window of this.overlayWindows.values()) { + window.webContents.send('data:patched', patch) + } + } + + handleOverlayHover(key: WidgetKey, hovering: boolean, data: AppData): void { + const window = this.overlayWindows.get(key) + const runtime = this.overlayState.get(key) + if (!window || !runtime) { + return + } + + clearTimeout(runtime.hideTimer) + if (hovering) { + this.expandOverlayWindow(key) + return + } + + if (!this.shouldAutoHide(key, data)) { + return + } + + runtime.hideTimer = setTimeout(() => { + this.collapseOverlayWindow(key, data) + }, AUTO_HIDE_DELAY_MS) + } + + private async createOverlayWindow(key: WidgetKey, data: AppData): Promise { + const config = data.desktopSettings.widgets[key] + const initialBounds = this.constrainToMatchingDisplay({ + x: Math.round(config.x), + y: Math.round(config.y), + width: Math.round(config.width), + height: Math.round(config.height), + }) + const window = new BrowserWindow({ + ...initialBounds, + minWidth: getOverlayMinWidth(key), + minHeight: getOverlayMinHeight(key), + frame: false, + transparent: true, + hasShadow: false, + resizable: isOverlayUserAdjustable(key), + skipTaskbar: true, + show: false, + backgroundColor: '#00000000', + webPreferences: { + preload: this.options.preloadPath, + contextIsolation: true, + sandbox: false, + }, + }) + + window.on('will-move', (event, newBounds) => { + const runtime = this.overlayState.get(key) + if (runtime?.hidden) { + event.preventDefault() + return + } + + const constrained = this.constrainToMatchingDisplay(newBounds) + if (!sameBounds(newBounds, constrained)) { + event.preventDefault() + this.setOverlayBounds(key, constrained) + this.queueOverlayBoundsSync(key, constrained, true) + } + }) + window.on('moved', () => this.queueOverlayBoundsSync(key)) + window.on('resized', () => this.queueOverlayBoundsSync(key)) + window.on('closed', () => { + const runtime = this.overlayState.get(key) + clearTimeout(runtime?.hideTimer) + clearTimeout(runtime?.syncTimer) + clearTimeout(runtime?.suppressTimer) + this.overlayWindows.delete(key) + this.overlayState.delete(key) + }) + window.once('ready-to-show', () => window.showInactive()) + + this.overlayWindows.set(key, window) + this.overlayState.set(key, { hidden: false, suppressBoundsSync: false }) + await this.options.loadRoute(window, `overlay/${key}`) + return window + } + + private applyOverlayWindowState(window: BrowserWindow, key: WidgetKey, data: AppData): void { + const config = data.desktopSettings.widgets[key] + const runtime = this.overlayState.get(key) + if (!runtime) { + return + } + + const configuredBounds = this.constrainToMatchingDisplay({ + x: Math.round(config.x), + y: Math.round(config.y), + width: Math.round(config.width), + height: Math.round(config.height), + }) + + window.setIgnoreMouseEvents(false) + window.setAlwaysOnTop(data.desktopSettings.overlayMode === 'floating' && data.desktopSettings.alwaysOnTop, 'screen-saver') + window.setOpacity(clampOpacity(config.opacity * data.desktopSettings.opacity)) + + if (runtime.hidden && runtime.collapsedEdge && this.shouldAutoHide(key, data)) { + const display = screen.getDisplayMatching(configuredBounds).workArea + const expandedBounds = snapBoundsToEdge(configuredBounds, display, runtime.collapsedEdge) + + runtime.expandedBounds = expandedBounds + window.setResizable(false) + window.setMovable(false) + this.setOverlayBounds(key, getCollapsedBounds(expandedBounds, display, runtime.collapsedEdge), false) + return + } + + runtime.hidden = false + runtime.expandedBounds = undefined + runtime.collapsedEdge = undefined + window.setResizable(isOverlayUserAdjustable(key)) + window.setMovable(isOverlayUserAdjustable(key) && !this.isOverlayDragLocked(key, data)) + this.setOverlayBounds(key, configuredBounds, false) + } + + private queueOverlayBoundsSync(key: WidgetKey, boundsOverride?: Rectangle, force = false): void { + const window = this.overlayWindows.get(key) + const runtime = this.overlayState.get(key) + if (!window || !runtime || (!force && (runtime.suppressBoundsSync || runtime.hidden))) { + return + } + + clearTimeout(runtime.syncTimer) + runtime.syncTimer = setTimeout(() => { + if (runtime.hidden) { + return + } + + const bounds = this.constrainToMatchingDisplay(boundsOverride ?? window.getBounds()) + if (!sameBounds(window.getBounds(), bounds)) { + this.setOverlayBounds(key, bounds) + } + + void this.options.onOverlayBoundsChanged({ + key, + changes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }, + }) + }, 180) + } + + private setOverlayBounds(key: WidgetKey, bounds: Rectangle, center = false): void { + const window = this.overlayWindows.get(key) + const runtime = this.overlayState.get(key) + if (!window || !runtime) { + return + } + + runtime.suppressBoundsSync = true + clearTimeout(runtime.suppressTimer) + if (center) { + window.center() + } else { + window.setBounds(bounds) + } + runtime.suppressTimer = setTimeout(() => { + runtime.suppressBoundsSync = false + }, PROGRAMMATIC_BOUNDS_SUPPRESS_MS) + } + + private expandOverlayWindow(key: WidgetKey): void { + const window = this.overlayWindows.get(key) + const runtime = this.overlayState.get(key) + if (!window || !runtime || !runtime.hidden || !runtime.expandedBounds) { + return + } + + const expandedBounds = this.constrainToMatchingDisplay(runtime.expandedBounds) + const data = this.latestData + runtime.hidden = false + runtime.expandedBounds = undefined + runtime.collapsedEdge = undefined + window.setResizable(isOverlayUserAdjustable(key)) + window.setMovable(isOverlayUserAdjustable(key) && (!data || !this.isOverlayDragLocked(key, data))) + this.setOverlayBounds(key, expandedBounds, false) + } + + private collapseOverlayWindow(key: WidgetKey, fallbackData: AppData): void { + const window = this.overlayWindows.get(key) + const runtime = this.overlayState.get(key) + const data = this.latestData ?? fallbackData + if (!window || !runtime || runtime.hidden || !this.shouldAutoHide(key, data)) { + return + } + + const currentBounds = this.constrainToMatchingDisplay(window.getBounds()) + const cursor = screen.getCursorScreenPoint() + if (pointInBounds(cursor, currentBounds, 2)) { + return + } + + const display = screen.getDisplayMatching(currentBounds).workArea + const edge = getCollapsibleEdge(currentBounds, display, AUTO_HIDE_THRESHOLD) + if (!edge) { + if (!sameBounds(window.getBounds(), currentBounds)) { + this.setOverlayBounds(key, currentBounds, false) + } + return + } + + const expandedBounds = snapBoundsToEdge(currentBounds, display, edge) + runtime.expandedBounds = expandedBounds + runtime.collapsedEdge = edge + runtime.hidden = true + window.setResizable(false) + window.setMovable(false) + this.setOverlayBounds(key, getCollapsedBounds(expandedBounds, display, edge), false) + } + + private destroyOverlayWindow(key: WidgetKey): void { + const window = this.overlayWindows.get(key) + if (!window) { + return + } + + const runtime = this.overlayState.get(key) + clearTimeout(runtime?.hideTimer) + clearTimeout(runtime?.syncTimer) + clearTimeout(runtime?.suppressTimer) + this.overlayWindows.delete(key) + this.overlayState.delete(key) + window.destroy() + } + + private shouldAutoHide(key: WidgetKey, data: AppData): boolean { + const config = data.desktopSettings.widgets[key] + return config.autoHide || data.desktopSettings.autoHide + } + + private isOverlayDragLocked(key: WidgetKey, data: AppData): boolean { + const config = data.desktopSettings.widgets[key] + return data.desktopSettings.dragLocked || Boolean(config.dragLocked) + } + + private constrainToMatchingDisplay(bounds: WindowBounds): Rectangle { + return constrainBoundsToDisplay(bounds, screen.getDisplayMatching(bounds).workArea) + } +} + +function isOverlayUserAdjustable(key: WidgetKey): boolean { + return key !== 'countdown' +} + +function getOverlayMinWidth(key: WidgetKey): number { + return key === 'countdown' ? 300 : 240 +} + +function getOverlayMinHeight(key: WidgetKey): number { + return key === 'countdown' ? 46 : 160 +} diff --git a/src/main/windows.ts b/src/main/windows.ts index bce369c..771643c 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1,32 +1,13 @@ import { join } from 'node:path' -import { app, BrowserWindow, Menu, nativeImage, screen, shell, Tray } from 'electron' -import type { Rectangle } from 'electron/main' +import { app, type BrowserWindow } from 'electron' +import { applyDataPatch } from '@shared/data/reducer' import type { AppData, WidgetKey } from '@shared/types/app' -import type { OverlayWidgetUpdatePayload, WindowStatePayload } from '@shared/ipc' - -type OverlayRuntimeState = { - hidden: boolean - collapsedEdge?: 'left' | 'right' | 'top' | 'bottom' - expandedBounds?: Rectangle - suppressBoundsSync: boolean - syncTimer?: NodeJS.Timeout - suppressTimer?: NodeJS.Timeout - suppressResizeUntil?: number - hideTimer?: NodeJS.Timeout -} +import type { AppDataPatch, OverlayWidgetUpdatePayload, WindowControlAction } from '@shared/ipc' +import { MainWindowController } from './windowing/mainWindowController' +import { OverlayWindowController } from './windowing/overlayWindowController' const rendererHtml = join(__dirname, '../renderer/index.html') const preloadPath = join(__dirname, '../preload/index.mjs') -const AUTO_HIDE_THRESHOLD = 40 -const AUTO_HIDE_STRIP = 22 -const AUTO_HIDE_COLLAPSE_DELAY_MS = 420 -const AUTO_HIDE_POINTER_MARGIN = 8 -const PROGRAMMATIC_BOUNDS_SUPPRESS_MS = 180 -const MAIN_WINDOW_DEFAULT_WIDTH = 1180 -const MAIN_WINDOW_DEFAULT_HEIGHT = 760 -const MAIN_WINDOW_MIN_WIDTH = 1024 -const MAIN_WINDOW_MIN_HEIGHT = 660 -const MAIN_WINDOW_SCREEN_MARGIN = 64 function loadRoute(window: BrowserWindow, hash: string): Promise { if (process.env.ELECTRON_RENDERER_URL) { @@ -37,606 +18,82 @@ function loadRoute(window: BrowserWindow, hash: string): Promise { } export class WindowManager { - private mainWindow: BrowserWindow | null = null - private tray: Tray | null = null - private isQuitting = false - private readonly overlayWindows = new Map() - private readonly overlayState = new Map() private latestData: AppData | null = null - - constructor( - private readonly onOverlayBoundsChanged: (payload: OverlayWidgetUpdatePayload) => Promise, - private readonly getData?: () => AppData, - ) {} - - async createMainWindow(): Promise { - this.initializeTray() - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - return this.mainWindow - } - - const primaryWorkArea = screen.getPrimaryDisplay().workArea - const initialSize = getDefaultMainWindowSize(primaryWorkArea) - const window = new BrowserWindow({ - width: initialSize.width, - height: initialSize.height, - minWidth: initialSize.minWidth, - minHeight: initialSize.minHeight, - frame: false, - titleBarStyle: 'hidden', - backgroundColor: '#EDF4FF', - show: false, - webPreferences: { - preload: preloadPath, - contextIsolation: true, - sandbox: false, - }, - }) - - window.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url) - return { action: 'deny' } + private isQuitting = false + private readonly mainWindows: MainWindowController + private readonly overlayWindows: OverlayWindowController + + constructor(onOverlayBoundsChanged: (payload: OverlayWidgetUpdatePayload) => Promise) { + this.mainWindows = new MainWindowController({ + preloadPath, + loadRoute, + isQuitting: () => this.isQuitting, + shouldCloseToTray: () => this.shouldCloseToTray(), + quitApplication: () => this.quitApplication(), }) - - window.on('closed', () => { - this.mainWindow = null - this.refreshTrayMenu() + this.overlayWindows = new OverlayWindowController({ + preloadPath, + loadRoute, + onOverlayBoundsChanged, }) - window.on('show', () => this.refreshTrayMenu()) - window.on('hide', () => this.refreshTrayMenu()) - window.on('maximize', () => this.sendWindowState(window, { isMaximized: true })) - window.on('unmaximize', () => this.sendWindowState(window, { isMaximized: false })) - window.once('ready-to-show', () => window.show()) - - await loadRoute(window, 'overview') - this.mainWindow = window - return window } - initializeTray(): void { - if (this.tray && !this.tray.isDestroyed()) { - return - } - - const trayIcon = nativeImage.createFromPath(join(__dirname, '../renderer/tray-icon.png')) - const fallbackIcon = nativeImage.createFromPath(join(__dirname, '../renderer/favicon.svg')) - const image = trayIcon.isEmpty() ? fallbackIcon : trayIcon.resize({ width: 16, height: 16 }) - this.tray = new Tray(image) - this.tray.setToolTip('Timetable') - this.tray.on('click', () => void this.showMainWindow()) - this.tray.on('double-click', () => void this.showMainWindow()) - this.refreshTrayMenu() - } - - refreshTrayMenu(): void { - if (!this.tray || this.tray.isDestroyed()) { - return - } - - const mainVisible = Boolean(this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) - const closeAction = this.getCloseButtonAction() - const trayOnlyQuitEnabled = this.isTrayOnlyQuitEnabled() - const contextMenu = Menu.buildFromTemplate([ - { label: '显示 Timetable', click: () => void this.showMainWindow() }, - { label: '隐藏到托盘', enabled: mainVisible, click: () => this.hideMainWindow() }, - { type: 'separator' }, - { label: `退出方式:${trayOnlyQuitEnabled ? '仅托盘退出' : '按关闭按钮设置'}`, enabled: false }, - { label: `关闭按钮:${trayOnlyQuitEnabled || closeAction === 'hide' ? '隐藏到托盘' : '退出程序'}`, enabled: false }, - { type: 'separator' }, - { label: '彻底退出', click: () => void this.quitApplication() }, - ]) - this.tray.setContextMenu(contextMenu) + createMainWindow(): Promise { + return this.mainWindows.createMainWindow() } async syncOverlayWindows(data: AppData): Promise { this.latestData = data - if (!data.desktopSettings.overlayEnabled) { - this.hideOverlayWindows() - return - } - - const keys = Object.keys(data.desktopSettings.widgets) as WidgetKey[] - for (const key of keys) { - const config = data.desktopSettings.widgets[key] - if (!config.enabled) { - this.destroyOverlayWindow(key) - continue - } - - const window = this.overlayWindows.get(key) ?? (await this.createOverlayWindow(key, data)) - this.applyOverlayWindowState(window, key, data) - } - - for (const key of this.overlayWindows.keys()) { - if (!keys.includes(key) || !data.desktopSettings.widgets[key].enabled) { - this.destroyOverlayWindow(key) - } - } + await this.overlayWindows.syncOverlayWindows(data) } hideOverlayWindows(): void { - for (const key of [...this.overlayWindows.keys()]) { - this.destroyOverlayWindow(key) - } + this.overlayWindows.hideOverlayWindows() } broadcastData(data: AppData): void { - this.mainWindow?.webContents.send('data:changed', data) - for (const window of this.overlayWindows.values()) { - window.webContents.send('data:changed', data) - } - this.refreshTrayMenu() + this.mainWindows.broadcastData(data) + this.overlayWindows.broadcastData(data) } - async controlCurrentWindow(window: BrowserWindow, action: 'minimize' | 'maximize' | 'close' | 'hide' | 'show' | 'quit'): Promise { - if (action === 'minimize') { - window.minimize() - return - } - if (action === 'maximize') { - if (window.isMaximized()) { - window.unmaximize() - } else { - window.maximize() - } - return - } - - if (action === 'hide') { - this.hideMainWindow() - return + broadcastPatch(patch: AppDataPatch): void { + if (this.latestData) { + this.latestData = applyDataPatch(this.latestData, patch) } - if (action === 'show') { - await this.showMainWindow() - return + this.mainWindows.broadcastPatch(patch) + if (patch.type === 'widget/replace') { + this.overlayWindows.broadcastPatch(patch) } - - if (action === 'quit') { - await this.quitApplication() - return - } - - await this.handleMainWindowCloseIntent(this.getData?.()) } - async handleMainWindowCloseIntent(data?: AppData | null): Promise { - const shouldHide = this.isTrayOnlyQuitEnabled(data) || this.getCloseButtonAction(data) === 'hide' - if (shouldHide) { - this.hideMainWindow() - return - } - - await this.quitApplication() + showMainWindow(): Promise { + return this.mainWindows.showMainWindow() } hideMainWindow(): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.hide() - } - this.refreshTrayMenu() + this.mainWindows.hideMainWindow() } - async showMainWindow(): Promise { - const window = await this.createMainWindow() - if (window.isMinimized()) { - window.restore() - } - window.show() - window.focus() - this.refreshTrayMenu() + shouldCloseToTray(): boolean { + return this.latestData?.appSettings.closeButtonAction === 'tray' } - async quitApplication(): Promise { + controlCurrentWindow(window: BrowserWindow, action: WindowControlAction): void { + this.mainWindows.controlCurrentWindow(window, action) + } + + quitApplication(): void { this.isQuitting = true - this.tray?.destroy() - this.tray = null - for (const key of [...this.overlayWindows.keys()]) { - this.destroyOverlayWindow(key) - } - this.mainWindow?.destroy() + this.hideOverlayWindows() app.quit() } handleOverlayHover(key: WidgetKey, hovering: boolean, data: AppData): void { - const window = this.overlayWindows.get(key) - const runtime = this.overlayState.get(key) - if (!window || !runtime) { - return - } - - if (hovering) { - clearTimeout(runtime.hideTimer) - runtime.hideTimer = undefined - if (runtime.hidden && runtime.expandedBounds) { - runtime.hidden = false - runtime.collapsedEdge = undefined - const expandedBounds = constrainBoundsToDisplay(runtime.expandedBounds) - this.restoreOverlayWindowConstraints(window, key, data) - this.setOverlayBounds(key, expandedBounds) - runtime.expandedBounds = undefined - } - return - } - - if (runtime.hidden) { - return - } - - const config = data.desktopSettings.widgets[key] - const shouldAutoHide = config.autoHide || data.desktopSettings.autoHide - if (!shouldAutoHide) { - return - } - - this.scheduleOverlayCollapse(key, data) + this.overlayWindows.handleOverlayHover(key, hovering, data) } getMainWindow(): BrowserWindow | null { - return this.mainWindow - } - - private async createOverlayWindow(key: WidgetKey, data: AppData): Promise { - const config = data.desktopSettings.widgets[key] - const initialBounds = constrainBoundsToDisplay({ - x: Math.round(config.x), - y: Math.round(config.y), - width: Math.round(config.width), - height: Math.round(config.height), - }) - const window = new BrowserWindow({ - ...initialBounds, - minWidth: getOverlayMinWidth(key), - minHeight: getOverlayMinHeight(key), - frame: false, - transparent: true, - hasShadow: false, - resizable: isOverlayUserResizable(key), - skipTaskbar: true, - show: false, - backgroundColor: '#00000000', - webPreferences: { - preload: preloadPath, - contextIsolation: true, - sandbox: false, - }, - }) - - window.on('will-move', (event, newBounds) => { - const runtime = this.overlayState.get(key) - if (runtime?.hidden) { - event.preventDefault() - return - } - if (runtime) { - runtime.suppressResizeUntil = Date.now() + 900 - } - const constrained = constrainBoundsToDisplay(newBounds) - if (!sameBounds(newBounds, constrained)) { - event.preventDefault() - const moveBounds = this.withConfiguredOverlaySize(key, constrained) - this.setOverlayBounds(key, moveBounds) - this.queueOverlayBoundsSync(key, moveBounds, true, 'move') - } - }) - window.on('moved', () => { - const runtime = this.overlayState.get(key) - if (runtime) { - runtime.suppressResizeUntil = Date.now() + 900 - } - this.queueOverlayBoundsSync(key, undefined, false, 'move') - }) - window.on('resized', () => { - const runtime = this.overlayState.get(key) - const resizedDuringMove = Boolean(runtime?.suppressResizeUntil && Date.now() < runtime.suppressResizeUntil) - this.queueOverlayBoundsSync(key, undefined, resizedDuringMove, resizedDuringMove ? 'move' : 'resize') - }) - window.on('closed', () => { - const runtime = this.overlayState.get(key) - clearTimeout(runtime?.hideTimer) - clearTimeout(runtime?.syncTimer) - clearTimeout(runtime?.suppressTimer) - this.overlayWindows.delete(key) - this.overlayState.delete(key) - }) - window.once('ready-to-show', () => window.showInactive()) - - this.overlayWindows.set(key, window) - this.overlayState.set(key, { hidden: false, suppressBoundsSync: false }) - await loadRoute(window, `overlay/${key}`) - return window - } - - private applyOverlayWindowState(window: BrowserWindow, key: WidgetKey, data: AppData): void { - const config = data.desktopSettings.widgets[key] - const runtime = this.overlayState.get(key) - if (!runtime) { - return - } - const configuredBounds = constrainBoundsToDisplay({ - x: Math.round(config.x), - y: Math.round(config.y), - width: Math.round(config.width), - height: Math.round(config.height), - }) - window.setIgnoreMouseEvents(false) - window.setAlwaysOnTop(data.desktopSettings.overlayMode === 'floating' && data.desktopSettings.alwaysOnTop, 'screen-saver') - window.setOpacity(clampOpacity(config.opacity * data.desktopSettings.opacity)) - - if (runtime.hidden) { - runtime.expandedBounds ??= configuredBounds - window.setResizable(false) - window.setMovable(false) - - if (runtime.collapsedEdge) { - const display = screen.getDisplayMatching(runtime.expandedBounds).workArea - const collapsedBounds = getHiddenOverlayBounds(runtime.expandedBounds, display, runtime.collapsedEdge) - if (!sameBounds(window.getBounds(), collapsedBounds)) { - this.setOverlayBounds(key, collapsedBounds, false) - } - } - return - } - - this.restoreOverlayWindowConstraints(window, key, data) - if (!runtime.hidden && !sameBounds(window.getBounds(), configuredBounds)) { - this.setOverlayBounds(key, configuredBounds, false) - } - } - - private restoreOverlayWindowConstraints(window: BrowserWindow, key: WidgetKey, data: AppData): void { - window.setMinimumSize(getOverlayMinWidth(key), getOverlayMinHeight(key)) - window.setResizable(isOverlayUserResizable(key)) - window.setMovable(isOverlayUserMovable() && !this.isOverlayDragLocked(key, data)) + return this.mainWindows.getMainWindow() } - - private scheduleOverlayCollapse(key: WidgetKey, data: AppData): void { - const runtime = this.overlayState.get(key) - if (!runtime) { - return - } - - clearTimeout(runtime.hideTimer) - runtime.hideTimer = setTimeout(() => { - runtime.hideTimer = undefined - const window = this.overlayWindows.get(key) - const currentRuntime = this.overlayState.get(key) - const currentData = this.getData?.() ?? data - if (!window || !currentRuntime || currentRuntime.hidden) { - return - } - - const config = currentData.desktopSettings.widgets[key] - const shouldAutoHide = config.autoHide || currentData.desktopSettings.autoHide - if (!shouldAutoHide) { - return - } - - const currentBounds = window.getBounds() - const cursor = screen.getCursorScreenPoint() - if (isPointInsideBounds(cursor, currentBounds, AUTO_HIDE_POINTER_MARGIN)) { - return - } - - const display = screen.getDisplayMatching(currentBounds).workArea - const bounds = constrainBoundsToDisplay(currentBounds, display) - const edge = getCollapsibleEdge(bounds, display, AUTO_HIDE_THRESHOLD) - if (!edge) { - return - } - - currentRuntime.expandedBounds = bounds - currentRuntime.collapsedEdge = edge - currentRuntime.hidden = true - - window.setResizable(false) - window.setMovable(false) - this.setOverlayBounds(key, getHiddenOverlayBounds(bounds, display, edge), false) - }, AUTO_HIDE_COLLAPSE_DELAY_MS) - } - - private withConfiguredOverlaySize(key: WidgetKey, bounds: Rectangle): Rectangle { - const config = this.latestData?.desktopSettings.widgets[key] - if (!config) { - return constrainBoundsToDisplay(bounds) - } - - return constrainBoundsToDisplay({ - x: bounds.x, - y: bounds.y, - width: Math.round(config.width), - height: Math.round(config.height), - }) - } - - private queueOverlayBoundsSync(key: WidgetKey, boundsOverride?: Rectangle, force = false, syncMode: 'bounds' | 'move' | 'resize' = 'bounds'): void { - const window = this.overlayWindows.get(key) - const runtime = this.overlayState.get(key) - if (!window || !runtime || (!force && (runtime.suppressBoundsSync || runtime.hidden))) { - return - } - - clearTimeout(runtime.syncTimer) - runtime.syncTimer = setTimeout(() => { - if (runtime.hidden) { - return - } - - const currentBounds = constrainBoundsToDisplay(boundsOverride ?? window.getBounds()) - const resizeSuppressedByMove = syncMode === 'resize' && runtime.suppressResizeUntil && Date.now() < runtime.suppressResizeUntil - const effectiveMode = resizeSuppressedByMove ? 'move' : syncMode - const bounds = effectiveMode === 'move' ? this.withConfiguredOverlaySize(key, currentBounds) : currentBounds - if (!sameBounds(window.getBounds(), bounds)) { - this.setOverlayBounds(key, bounds) - } - const changes = effectiveMode === 'move' - ? { x: bounds.x, y: bounds.y } - : { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } - void this.onOverlayBoundsChanged({ - key, - changes, - }) - }, 180) - } - - private setOverlayBounds(key: WidgetKey, bounds: Rectangle, center = false): void { - const window = this.overlayWindows.get(key) - const runtime = this.overlayState.get(key) - if (!window || !runtime) { - return - } - - runtime.suppressBoundsSync = true - clearTimeout(runtime.suppressTimer) - if (center) { - window.center() - } else { - window.setBounds(bounds) - } - runtime.suppressTimer = setTimeout(() => { - runtime.suppressBoundsSync = false - }, PROGRAMMATIC_BOUNDS_SUPPRESS_MS) - } - - private destroyOverlayWindow(key: WidgetKey): void { - const window = this.overlayWindows.get(key) - if (!window) { - return - } - - this.overlayWindows.delete(key) - this.overlayState.delete(key) - window.destroy() - } - - private sendWindowState(window: BrowserWindow, payload: WindowStatePayload): void { - window.webContents.send('window:state', payload) - } - - private getCloseButtonAction(data: AppData | null | undefined = undefined): 'exit' | 'hide' { - try { - const current = data ?? this.getData?.() - return current?.appSettings?.closeButtonAction === 'hide' ? 'hide' : 'exit' - } catch { - return 'exit' - } - } - - private isTrayOnlyQuitEnabled(data: AppData | null | undefined = undefined): boolean { - try { - const current = data ?? this.getData?.() - return current?.appSettings?.trayOnlyQuitEnabled === true - } catch { - return false - } - } - - private isOverlayDragLocked(key: WidgetKey, data: AppData): boolean { - const config = data.desktopSettings.widgets[key] - return data.desktopSettings.dragLocked || Boolean(config.dragLocked) - } -} - -function clampOpacity(value: number): number { - return Math.max(0.2, Math.min(1, value)) -} - -function isOverlayUserResizable(key: WidgetKey): boolean { - return key !== 'countdown' -} - -function isOverlayUserMovable(): boolean { - return true -} - -function getOverlayMinWidth(key: WidgetKey): number { - return key === 'countdown' ? 300 : 240 -} - -function getOverlayMinHeight(key: WidgetKey): number { - if (key === 'countdown') { - return 46 - } - if (key === 'principle') { - return 128 - } - return 160 -} - -function clamp(value: number, min: number, max: number): number { - if (max < min) { - return min - } - return Math.max(min, Math.min(max, value)) -} - -function getDefaultMainWindowSize(workArea: Rectangle): { width: number; height: number; minWidth: number; minHeight: number } { - const widthFloor = Math.min(MAIN_WINDOW_MIN_WIDTH, workArea.width) - const heightFloor = Math.min(MAIN_WINDOW_MIN_HEIGHT, workArea.height) - const width = Math.min(MAIN_WINDOW_DEFAULT_WIDTH, Math.max(widthFloor, workArea.width - MAIN_WINDOW_SCREEN_MARGIN)) - const height = Math.min(MAIN_WINDOW_DEFAULT_HEIGHT, Math.max(heightFloor, workArea.height - MAIN_WINDOW_SCREEN_MARGIN)) - return { - width, - height, - minWidth: Math.min(MAIN_WINDOW_MIN_WIDTH, width), - minHeight: Math.min(MAIN_WINDOW_MIN_HEIGHT, height), - } -} - -function constrainBoundsToDisplay(bounds: Rectangle, display = screen.getDisplayMatching(bounds).workArea): Rectangle { - const width = Math.min(Math.max(1, Math.round(bounds.width)), display.width) - const height = Math.min(Math.max(1, Math.round(bounds.height)), display.height) - return { - x: clamp(Math.round(bounds.x), display.x, display.x + display.width - width), - y: clamp(Math.round(bounds.y), display.y, display.y + display.height - height), - width, - height, - } -} - -function sameBounds(a: Rectangle, b: Rectangle): boolean { - return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height -} - -function getCollapsibleEdge(bounds: Rectangle, display: Rectangle, threshold: number): 'left' | 'right' | 'top' | 'bottom' | undefined { - if (Math.abs(bounds.x - display.x) <= threshold) { - return 'left' - } - if (Math.abs(display.x + display.width - (bounds.x + bounds.width)) <= threshold) { - return 'right' - } - if (Math.abs(bounds.y - display.y) <= threshold) { - return 'top' - } - if (Math.abs(display.y + display.height - (bounds.y + bounds.height)) <= threshold) { - return 'bottom' - } - return undefined -} - -function getHiddenOverlayBounds(bounds: Rectangle, display: Rectangle, edge: 'left' | 'right' | 'top' | 'bottom'): Rectangle { - const width = Math.min(Math.max(1, Math.round(bounds.width)), display.width) - const height = Math.min(Math.max(1, Math.round(bounds.height)), display.height) - const x = clamp(Math.round(bounds.x), display.x, display.x + display.width - width) - const y = clamp(Math.round(bounds.y), display.y, display.y + display.height - height) - - if (edge === 'left') { - return { x: display.x - width + AUTO_HIDE_STRIP, y, width, height } - } - if (edge === 'right') { - return { x: display.x + display.width - AUTO_HIDE_STRIP, y, width, height } - } - if (edge === 'top') { - return { x, y: display.y - height + AUTO_HIDE_STRIP, width, height } - } - return { x, y: display.y + display.height - AUTO_HIDE_STRIP, width, height } -} - -function isPointInsideBounds(point: { x: number; y: number }, bounds: Rectangle, margin = 0): boolean { - return ( - point.x >= bounds.x - margin && - point.x <= bounds.x + bounds.width + margin && - point.y >= bounds.y - margin && - point.y <= bounds.y + bounds.height + margin - ) } diff --git a/src/preload/global.d.ts b/src/preload/global.d.ts index 1c48d6a..b8b675e 100644 --- a/src/preload/global.d.ts +++ b/src/preload/global.d.ts @@ -1,8 +1,8 @@ -import type { TimetableApi } from '@shared/ipc' +import type { TimeableApi } from '@shared/ipc' declare global { interface Window { - timeable: TimetableApi + timeable: TimeableApi } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5731a58..108ef2e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,8 +1,8 @@ import { pathToFileURL } from 'node:url' import { contextBridge, ipcRenderer } from 'electron' -import type { TimetableApi } from '@shared/ipc' +import type { AppDataPatch, TimeableApi } from '@shared/ipc' -const api: TimetableApi = { +const api: TimeableApi = { loadData: () => ipcRenderer.invoke('data:load'), updateData: (action) => ipcRenderer.invoke('data:update', action), updateSettings: (payload) => ipcRenderer.invoke('settings:update', payload), @@ -13,18 +13,14 @@ const api: TimetableApi = { setStartup: (enabled) => ipcRenderer.invoke('startup:set', enabled), selectBackground: () => ipcRenderer.invoke('file:selectBackground'), exportData: () => ipcRenderer.invoke('data:export'), - createBackup: () => ipcRenderer.invoke('data:createBackup'), - listBackups: () => ipcRenderer.invoke('data:listBackups'), - restoreBackup: (filePath) => ipcRenderer.invoke('data:restoreBackup', filePath), - openBackupDir: () => ipcRenderer.invoke('data:openBackupDir'), - checkForUpdate: () => ipcRenderer.invoke('update:check'), - installUpdate: () => ipcRenderer.invoke('update:install'), + listDataBackups: () => ipcRenderer.invoke('data:listBackups'), + restoreDataBackup: (id) => ipcRenderer.invoke('data:restoreBackup', id), saveBrowserUsageDay: (date) => ipcRenderer.invoke('browserUsage:saveDay', date), filePathToUrl: (filePath) => pathToFileURL(filePath).toString(), windowControl: (action) => ipcRenderer.invoke('window:control', action), overlayHover: (key, hovering) => ipcRenderer.send('overlay:hover', { key, hovering }), onDataChanged: (listener) => { - const subscription = (_event: Electron.IpcRendererEvent, data: Awaited>) => { + const subscription = (_event: Electron.IpcRendererEvent, data: Awaited>) => { listener(data) } @@ -33,6 +29,16 @@ const api: TimetableApi = { ipcRenderer.removeListener('data:changed', subscription) } }, + onDataPatched: (listener) => { + const subscription = (_event: Electron.IpcRendererEvent, patch: AppDataPatch) => { + listener(patch) + } + + ipcRenderer.on('data:patched', subscription) + return () => { + ipcRenderer.removeListener('data:patched', subscription) + } + }, onWindowStateChanged: (listener) => { const subscription = (_event: Electron.IpcRendererEvent, payload: { isMaximized: boolean }) => { listener(payload) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b338edc..6cb5123 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -19,17 +19,20 @@ import { useAppStore } from '@renderer/store/appStore' function Boot() { const load = useAppStore((state) => state.load) const setData = useAppStore((state) => state.setData) + const applyDataPatch = useAppStore((state) => state.applyDataPatch) const setWindowState = useAppStore((state) => state.setWindowState) useEffect(() => { void load() const removeDataListener = window.timeable.onDataChanged((data) => setData(data)) + const removePatchListener = window.timeable.onDataPatched((patch) => applyDataPatch(patch)) const removeWindowStateListener = window.timeable.onWindowStateChanged((payload) => setWindowState(payload)) return () => { removeDataListener() + removePatchListener() removeWindowStateListener() } - }, [load, setData, setWindowState]) + }, [load, setData, applyDataPatch, setWindowState]) return null } @@ -44,10 +47,10 @@ export default function App() { }> } /> } /> - } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/components/Card.tsx b/src/renderer/components/Card.tsx index ce46b61..6d37e06 100644 --- a/src/renderer/components/Card.tsx +++ b/src/renderer/components/Card.tsx @@ -7,7 +7,7 @@ type CardProps = PropsWithChildren> & { export function Card({ className, padded = true, children, ...props }: CardProps) { return ( -
+
{children}
) diff --git a/src/renderer/components/CountdownStrip.tsx b/src/renderer/components/CountdownStrip.tsx new file mode 100644 index 0000000..9147998 --- /dev/null +++ b/src/renderer/components/CountdownStrip.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import { cn } from '@renderer/utils/cn' + +type CountdownStripProps = { + icon: ReactNode + label: string + value: string + meta?: string + className?: string + iconClassName?: string + valueClassName?: string +} + +export function CountdownStrip({ + icon, + label, + value, + meta, + className, + iconClassName, + valueClassName, +}: CountdownStripProps) { + return ( +
+ + {icon} + + {label} + + {value} + + {meta ? {meta} : null} +
+ ) +} diff --git a/src/renderer/components/PageHeader.tsx b/src/renderer/components/PageHeader.tsx index c17e717..b14e672 100644 --- a/src/renderer/components/PageHeader.tsx +++ b/src/renderer/components/PageHeader.tsx @@ -8,13 +8,13 @@ type PageHeaderProps = PropsWithChildren<{ export function PageHeader({ title, subtitle, actions, children }: PageHeaderProps) { return ( -
+
-

{title}

- {subtitle ?

{subtitle}

: null} +

{title}

+ {subtitle ?

{subtitle}

: null} {children}
- {actions ?
{actions}
: null} + {actions ?
{actions}
: null}
) } diff --git a/src/renderer/components/ShellLayout.tsx b/src/renderer/components/ShellLayout.tsx index ed5a4a2..de3bfde 100644 --- a/src/renderer/components/ShellLayout.tsx +++ b/src/renderer/components/ShellLayout.tsx @@ -8,7 +8,7 @@ export function ShellLayout() {
-
+
diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index cb1cf24..951c1b6 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -5,18 +5,17 @@ import { cn } from '@renderer/utils/cn' export function Sidebar() { return ( -