| 1 | import { rename, readFile, writeFile, mkdir } from "node:fs/promises"; |
| 2 | import { dirname, join } from "node:path"; |
| 3 | |
| 4 | export const MIN_APP_ZOOM = 0.5; |
| 5 | export const MAX_APP_ZOOM = 2; |
| 6 | export const APP_ZOOM_STEP = 0.05; |
| 7 | export type AppZoomSource = "default" | "user" | "migrated"; |
| 8 | export interface AppZoomState { version: 1; appZoomFactor: number; source: AppZoomSource; } |
| 9 | |
| 10 | const DEFAULT: AppZoomState = { version: 1, appZoomFactor: 1, source: "default" }; |
| 11 | |
| 12 | export function normalizeAppZoom(value: number): number { |
| 13 | if (!Number.isFinite(value)) return 1; |
| 14 | const clamped = Math.min(MAX_APP_ZOOM, Math.max(MIN_APP_ZOOM, value)); |
| 15 | return Number((MIN_APP_ZOOM + Math.round((clamped - MIN_APP_ZOOM) / APP_ZOOM_STEP) * APP_ZOOM_STEP).toFixed(2)); |
| 16 | } |
| 17 | |
| 18 | export class AppZoomStore { |
| 19 | private state: AppZoomState = DEFAULT; |
| 20 | constructor(private readonly file: string, private readonly legacyFile?: string) {} |
| 21 | async load(): Promise<AppZoomState> { |
| 22 | try { |
| 23 | const parsed = JSON.parse(await readFile(this.file, "utf8")) as Partial<AppZoomState>; |
| 24 | if (parsed.version === 1 && typeof parsed.appZoomFactor === "number" && Number.isFinite(parsed.appZoomFactor) |
| 25 | && (parsed.source === "default" || parsed.source === "user" || parsed.source === "migrated")) { |
| 26 | this.state = { version: 1, appZoomFactor: normalizeAppZoom(parsed.appZoomFactor), source: parsed.source }; |
| 27 | return this.state; |
| 28 | } |
| 29 | } catch { /* first launch or corrupt file */ } |
| 30 | this.state = await this.migrate(); |
| 31 | await this.persist(this.state); |
| 32 | return this.state; |
| 33 | } |
| 34 | async set(factor: number, source: AppZoomSource = "user"): Promise<AppZoomState> { |
| 35 | if (!Number.isFinite(factor)) throw new Error("app zoom factor must be finite"); |
| 36 | this.state = { version: 1, appZoomFactor: normalizeAppZoom(factor), source }; |
| 37 | await this.persist(this.state); |
| 38 | return this.state; |
| 39 | } |
| 40 | async reset(): Promise<AppZoomState> { return this.set(1, "user"); } |
| 41 | get current(): AppZoomState { return this.state; } |
| 42 | private async migrate(): Promise<AppZoomState> { |
| 43 | if (!this.legacyFile) return DEFAULT; |
| 44 | try { |
| 45 | const parsed = JSON.parse(await readFile(this.legacyFile, "utf8")) as { zoomFactor?: unknown; source?: unknown }; |
| 46 | // Legacy files had no trustworthy source marker; do not inherit an accidental 70% value. |
| 47 | if (parsed.source === "user" && typeof parsed.zoomFactor === "number" && Number.isFinite(parsed.zoomFactor)) { |
| 48 | return { version: 1, appZoomFactor: normalizeAppZoom(parsed.zoomFactor), source: "migrated" }; |
| 49 | } |
| 50 | } catch { /* use default */ } |
| 51 | return DEFAULT; |
| 52 | } |
| 53 | private async persist(state: AppZoomState): Promise<void> { |
| 54 | await mkdir(dirname(this.file), { recursive: true }); |
| 55 | const temp = join(dirname(this.file), `.${state.version}.zoom.tmp`); |
| 56 | await writeFile(temp, `${JSON.stringify(state)}\n`, "utf8"); |
| 57 | await rename(temp, this.file); |
| 58 | } |
| 59 | } |
| 60 |