Initial commit

This commit is contained in:
AnRil
2026-05-16 13:43:29 +07:00
commit 688a86b611
208 changed files with 44350 additions and 0 deletions

21
src/main/autostart.ts Normal file
View File

@@ -0,0 +1,21 @@
import { app } from 'electron'
const HIDDEN_FLAG = '--hidden'
export function setAutostart(enabled: boolean): void {
if (process.platform !== 'win32') return
app.setLoginItemSettings({
openAtLogin: enabled,
path: process.execPath,
args: [HIDDEN_FLAG]
})
}
export function isAutostartEnabled(): boolean {
if (process.platform !== 'win32') return false
return app.getLoginItemSettings().openAtLogin
}
export function wasStartedHidden(): boolean {
return process.argv.includes(HIDDEN_FLAG) || app.getLoginItemSettings().wasOpenedAsHidden
}

218
src/main/games/dota2.ts Normal file
View File

@@ -0,0 +1,218 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { app } from 'electron'
import type { GameProvider, ProviderEventHandler } from './provider'
import { findGameInstall } from './steam'
import { registerGsiRoute, getGsiBaseUrl, getGsiPort } from './gsi-server'
import {
ensureLaunchOption,
ensureLaunchOptionRemoved,
isLaunchOptionPresent,
isSteamRunning
} from './steam-launch-options'
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
const APP_ID = '570'
const INSTALL_DIR = 'dota 2 beta'
const CFG_NAME = 'gamestate_integration_exercise_reminder.cfg'
const ROUTE = '/dota2'
const LAUNCH_OPTION = '-gamestateintegration'
type DotaGsi = {
provider?: { name?: string }
map?: {
game_state?: string
win_team?: 'radiant' | 'dire' | 'none'
game_time?: number
clock_time?: number
}
player?: {
kills?: number
deaths?: number
assists?: number
last_hits?: number
denies?: number
team_name?: 'radiant' | 'dire'
steamid?: string
}
}
function tokenStorePath(): string {
return join(app.getPath('userData'), 'dota2-gsi-token.txt')
}
function getOrCreateToken(): string {
const p = tokenStorePath()
if (existsSync(p)) {
const v = readFileSync(p, 'utf-8').trim()
if (v) return v
}
const token = randomBytes(16).toString('hex')
writeFileSync(p, token, 'utf-8')
return token
}
function cfgDir(installPath: string): string {
return join(installPath, 'game', 'dota', 'cfg', 'gamestate_integration')
}
function cfgPath(installPath: string): string {
return join(cfgDir(installPath), CFG_NAME)
}
function buildCfg(token: string): string {
const uri = `${getGsiBaseUrl()}${ROUTE}`
return `"Exercise Reminder Integration"
{
"uri" "${uri}"
"timeout" "5.0"
"buffer" "0.1"
"throttle" "0.5"
"heartbeat" "10.0"
"auth"
{
"token" "${token}"
}
"data"
{
"provider" "1"
"map" "1"
"player" "1"
"hero" "1"
"abilities" "0"
"items" "0"
"events" "0"
"buildings" "0"
"league" "0"
"draft" "0"
"wearables" "0"
}
}
`
}
export class Dota2Provider implements GameProvider {
readonly id: GameId = 'dota2'
readonly displayName = 'Dota 2'
private installPath: string | undefined
private unregister: (() => void) | undefined
private emit: ProviderEventHandler | undefined
private prevState: string | undefined
private latest: DotaGsi | undefined
private lastMatchEndAt = 0
private token: string = getOrCreateToken()
async detect(): Promise<GameStatus> {
const path = await findGameInstall(APP_ID, INSTALL_DIR)
this.installPath = path
const integrationActive = !!path && existsSync(cfgPath(path))
let launchOptionStatus: LaunchOptionStatus = 'not_needed'
let steamRunning: boolean | undefined
if (integrationActive) {
const present = await isLaunchOptionPresent(APP_ID, LAUNCH_OPTION)
if (present) launchOptionStatus = 'applied'
else {
steamRunning = await isSteamRunning()
launchOptionStatus = steamRunning ? 'queued' : 'queued'
}
}
return {
id: this.id,
name: this.displayName,
installed: !!path,
installPath: path,
integrationActive,
launchOption: LAUNCH_OPTION,
launchOptionStatus,
steamRunning,
enabled: false
}
}
async install(): Promise<void> {
if (!this.installPath) {
const status = await this.detect()
if (!status.installPath) throw new Error('Dota 2 не найдена в Steam-библиотеках')
}
const dir = cfgDir(this.installPath!)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(cfgPath(this.installPath!), buildCfg(this.token), 'utf-8')
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
}
async uninstall(): Promise<void> {
await ensureLaunchOptionRemoved(APP_ID, LAUNCH_OPTION)
if (!this.installPath) return
const p = cfgPath(this.installPath)
if (existsSync(p)) unlinkSync(p)
}
async reconcile(): Promise<void> {
if (!this.installPath) await this.detect()
if (!this.installPath || !existsSync(cfgPath(this.installPath))) return
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
}
async start(emit: ProviderEventHandler): Promise<void> {
this.emit = emit
this.unregister = registerGsiRoute(ROUTE, (payload) => this.handle(payload as DotaGsi))
}
async stop(): Promise<void> {
this.unregister?.()
this.unregister = undefined
this.emit = undefined
this.prevState = undefined
this.latest = undefined
}
private handle(g: DotaGsi): void {
// Track latest snapshot so we have stats when the transition fires.
if (g.player || g.map) this.latest = { ...this.latest, ...g, player: { ...this.latest?.player, ...g.player }, map: { ...this.latest?.map, ...g.map } }
const state = g.map?.game_state ?? this.latest?.map?.game_state
if (!state) return
const prev = this.prevState
this.prevState = state
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
const now = Date.now()
if (now - this.lastMatchEndAt < 30_000) return
this.lastMatchEndAt = now
const p = this.latest?.player ?? {}
const m = this.latest?.map ?? {}
const playerTeam = p.team_name
const winner = m.win_team
const won =
winner === 'radiant' || winner === 'dire'
? playerTeam === winner
: undefined
const durationMs = (m.game_time ?? m.clock_time ?? 0) * 1000
this.emit?.({
type: 'match_end',
payload: {
durationMs,
won,
stats: {
deaths: p.deaths ?? 0,
kills: p.kills ?? 0,
assists: p.assists ?? 0,
last_hits: p.last_hits ?? 0,
denies: p.denies ?? 0,
duration_min: Math.floor(durationMs / 60_000)
}
}
})
}
}
}
export function dota2DebugInfo(): { port: number; route: string } {
return { port: getGsiPort(), route: ROUTE }
}

View File

@@ -0,0 +1,73 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
export type GsiHandler = (payload: unknown, headers: Record<string, string | string[] | undefined>) => void
const PORT = 4701
let server: Server | null = null
const handlers: Map<string, GsiHandler> = new Map()
function getBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (c) => chunks.push(c as Buffer))
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}
async function onRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const route = (req.url ?? '/').split('?')[0]
const handler = handlers.get(route)
if (!handler) {
res.statusCode = 404
res.end()
return
}
if (req.method !== 'POST') {
res.statusCode = 405
res.end()
return
}
try {
const body = await getBody(req)
const text = body.toString('utf-8')
const payload = text.length > 0 ? JSON.parse(text) : {}
handler(payload, req.headers)
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
} catch (err) {
res.statusCode = 500
res.end(String(err))
}
}
export async function startGsiServer(): Promise<void> {
if (server) return
await new Promise<void>((resolve, reject) => {
server = createServer(onRequest)
server.once('error', reject)
server.listen(PORT, '127.0.0.1', () => resolve())
})
}
export function stopGsiServer(): void {
if (server) {
server.close()
server = null
}
}
export function registerGsiRoute(route: string, handler: GsiHandler): () => void {
handlers.set(route, handler)
return () => handlers.delete(route)
}
export function getGsiBaseUrl(): string {
return `http://127.0.0.1:${PORT}`
}
export function getGsiPort(): number {
return PORT
}

View File

@@ -0,0 +1,20 @@
import type { GameId, GameStat, GameStatus } from '@shared/types'
export type MatchEndPayload = {
durationMs: number
won?: boolean
stats: Partial<Record<GameStat, number>>
}
export type ProviderEventHandler = (event: { type: 'match_end'; payload: MatchEndPayload }) => void
export interface GameProvider {
readonly id: GameId
readonly displayName: string
detect(): Promise<GameStatus>
install(): Promise<void>
uninstall(): Promise<void>
start(emit: ProviderEventHandler): Promise<void>
stop(): Promise<void>
reconcile?(): Promise<void>
}

148
src/main/games/registry.ts Normal file
View File

@@ -0,0 +1,148 @@
import { BrowserWindow } from 'electron'
import type { GameProvider, MatchEndPayload } from './provider'
import { Dota2Provider } from './dota2'
import { startGsiServer, stopGsiServer } from './gsi-server'
import { onLaunchOptionsApplied } from './steam-launch-options'
import { IPC } from '@shared/ipc'
import type {
Challenge,
ChallengeResult,
GameId,
GameStatus,
MatchSummary
} from '@shared/types'
import { STAT_LABELS } from '@shared/types'
import { getChallenges, getGamesEnabled } from '../store'
import { fireMatchSummary } from '../notifications'
const providers: Record<GameId, GameProvider> = {
dota2: new Dota2Provider()
}
let running = false
async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<void> {
const provider = providers[gameId]
const challenges = getChallenges().filter(
(c) => c.gameId === gameId && c.enabled
)
const results: ChallengeResult[] = []
for (const ch of challenges) {
const statValue = payload.stats[ch.stat] ?? 0
const reps = Math.round(statValue * ch.multiplier)
if (reps <= 0) continue
results.push({
challengeId: ch.id,
name: ch.name,
icon: ch.icon,
exerciseName: ch.exerciseName,
reps,
statValue,
statLabel: STAT_LABELS[ch.stat]
})
}
if (results.length === 0) return
const summary: MatchSummary = {
gameId,
gameName: provider.displayName,
durationMs: payload.durationMs,
won: payload.won,
results
}
fireMatchSummary(summary)
}
export async function startGamesRegistry(): Promise<void> {
if (running) return
running = true
try {
await startGsiServer()
} catch (err) {
console.error('GSI server failed to start:', err)
return
}
onLaunchOptionsApplied(() => {
// When Steam closed and we flushed queued ops, refresh statuses.
void listGamesStatus().then((games) => broadcastGames(games))
})
const enabled = getGamesEnabled()
for (const id of Object.keys(providers) as GameId[]) {
const provider = providers[id]
// Reconcile launch options on boot (handles cases where Steam
// overwrote our flag, or user removed it manually).
try {
await provider.reconcile?.()
} catch (err) {
console.error('reconcile failed for', id, err)
}
if (!enabled[id]) continue
await provider.start((e) => {
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
})
}
}
export async function stopGamesRegistry(): Promise<void> {
if (!running) return
running = false
for (const id of Object.keys(providers) as GameId[]) {
await providers[id].stop()
}
stopGsiServer()
}
export async function listGamesStatus(): Promise<GameStatus[]> {
const enabled = getGamesEnabled()
const out: GameStatus[] = []
for (const id of Object.keys(providers) as GameId[]) {
const s = await providers[id].detect()
out.push({ ...s, enabled: !!enabled[id] })
}
return out
}
export async function installGame(id: GameId): Promise<GameStatus> {
const provider = providers[id]
await provider.install()
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
}
export async function uninstallGame(id: GameId): Promise<GameStatus> {
const provider = providers[id]
await provider.uninstall()
await provider.stop()
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
}
export async function toggleGame(id: GameId, enabled: boolean): Promise<void> {
const provider = providers[id]
if (enabled) {
await provider.start((e) => {
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
})
} else {
await provider.stop()
}
}
export function broadcastGames(games: GameStatus[]): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtGamesChanged, games)
}
}
// Simulate a match-end for debugging (called from IPC in dev).
export function simulateMatchEnd(id: GameId, stats: Partial<Record<string, number>>): void {
void onMatchEnd(id, {
durationMs: (stats.duration_min ?? 35) * 60_000,
won: stats.won === 1,
stats: stats as Partial<Record<import('@shared/types').GameStat, number>>
})
}
export function getProviders(): Record<GameId, GameProvider> {
return providers
}

View File

@@ -0,0 +1,310 @@
import { exec } from 'node:child_process'
import {
copyFileSync,
existsSync,
readFileSync,
readdirSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { parseVdf, stringifyVdf, type VdfNode } from './vdf'
import { getSteamPath } from './steam'
const execAsync = promisify(exec)
export async function isSteamRunning(): Promise<boolean> {
try {
const { stdout } = await execAsync(
'tasklist /FI "IMAGENAME eq steam.exe" /FO CSV /NH',
{ windowsHide: true }
)
return /steam\.exe/i.test(stdout)
} catch {
return false
}
}
async function getLocalConfigPaths(): Promise<string[]> {
const steamPath = await getSteamPath()
if (!steamPath) return []
const userdataDir = join(steamPath, 'userdata')
if (!existsSync(userdataDir)) return []
let entries: string[] = []
try {
entries = readdirSync(userdataDir)
} catch {
return []
}
const paths: string[] = []
for (const entry of entries) {
if (!/^\d+$/.test(entry) || entry === '0') continue
const p = join(userdataDir, entry, 'config', 'localconfig.vdf')
if (existsSync(p)) paths.push(p)
}
return paths
}
function findKey(node: VdfNode, target: string): string | undefined {
const lower = target.toLowerCase()
for (const k of Object.keys(node)) {
if (k.toLowerCase() === lower) return k
}
return undefined
}
function findCaseInsensitive(node: VdfNode, ...keys: string[]): VdfNode | undefined {
let cur: VdfNode = node
for (const key of keys) {
const found: string | undefined = findKey(cur, key)
if (!found) return undefined
const value: string | VdfNode = cur[found]
if (typeof value !== 'object') return undefined
cur = value
}
return cur
}
function findOrCreatePath(node: VdfNode, ...keys: string[]): VdfNode {
let cur: VdfNode = node
for (const key of keys) {
const found: string | undefined = findKey(cur, key)
if (found && typeof cur[found] === 'object') {
cur = cur[found] as VdfNode
} else {
if (found) delete cur[found]
cur[key] = {}
cur = cur[key] as VdfNode
}
}
return cur
}
function getAppNode(parsed: VdfNode, appId: string, create: boolean): VdfNode | undefined {
if (create) {
const apps = findOrCreatePath(
parsed,
'UserLocalConfigStore',
'Software',
'Valve',
'Steam',
'apps'
)
if (typeof apps[appId] !== 'object') apps[appId] = {}
return apps[appId] as VdfNode
}
const apps = findCaseInsensitive(
parsed,
'UserLocalConfigStore',
'Software',
'Valve',
'Steam',
'apps'
)
if (!apps) return undefined
const v = apps[appId]
return typeof v === 'object' ? v : undefined
}
function writeBackup(path: string): void {
try {
const bak = path + '.exr.bak'
if (!existsSync(bak)) copyFileSync(path, bak)
} catch {
// best-effort
}
}
function atomicWrite(path: string, contents: string): void {
// Write to temp then rename (atomic on Windows for same directory).
const tmp = path + '.exr.tmp'
writeFileSync(tmp, contents, 'utf-8')
// fs.renameSync replaces destination atomically on Windows
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('node:fs') as typeof import('node:fs')
fs.renameSync(tmp, path)
}
function modifyLaunchOptions(
configPath: string,
appId: string,
fn: (current: string) => string | null
): boolean {
let raw: string
try {
raw = readFileSync(configPath, 'utf-8')
} catch {
return false
}
let parsed: VdfNode
try {
parsed = parseVdf(raw)
} catch {
return false
}
// Read existing
const app = getAppNode(parsed, appId, false)
const loKey = app
? Object.keys(app).find((k) => k.toLowerCase() === 'launchoptions')
: undefined
const existing = app && loKey ? String(app[loKey] ?? '') : ''
const next = fn(existing)
if (next === null) return false
if (next === existing) return true
const targetApp = getAppNode(parsed, appId, true)!
// Remove any existing variant of the key to keep canonical name.
for (const k of Object.keys(targetApp)) {
if (k.toLowerCase() === 'launchoptions') delete targetApp[k]
}
if (next.length > 0) {
targetApp['LaunchOptions'] = next
}
writeBackup(configPath)
try {
atomicWrite(configPath, stringifyVdf(parsed))
} catch {
return false
}
return true
}
export async function isLaunchOptionPresent(
appId: string,
option: string
): Promise<boolean> {
const paths = await getLocalConfigPaths()
for (const p of paths) {
try {
const raw = readFileSync(p, 'utf-8')
const parsed = parseVdf(raw)
const app = getAppNode(parsed, appId, false)
if (!app) continue
const loKey = Object.keys(app).find((k) => k.toLowerCase() === 'launchoptions')
if (!loKey) continue
const value = String(app[loKey] ?? '')
if (value.includes(option)) return true
} catch {
// ignore
}
}
return false
}
async function applyOptionToAllConfigs(appId: string, option: string): Promise<void> {
const paths = await getLocalConfigPaths()
for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => {
if (current.includes(option)) return current
return current.length > 0 ? `${current} ${option}` : option
})
}
}
async function removeOptionFromAllConfigs(appId: string, option: string): Promise<void> {
const paths = await getLocalConfigPaths()
for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => {
if (!current.includes(option)) return current
return current
.split(/\s+/)
.filter((tok) => tok !== option)
.join(' ')
.trim()
})
}
}
// Pending operations queue with a watcher that polls Steam and applies on exit.
type PendingOp = {
appId: string
option: string
op: 'add' | 'remove'
}
const pending: PendingOp[] = []
let watchTimer: NodeJS.Timeout | null = null
let onAppliedCallback: (() => void) | null = null
export function onLaunchOptionsApplied(cb: () => void): void {
onAppliedCallback = cb
}
export function hasPendingOperations(): boolean {
return pending.length > 0
}
function dedupePush(op: PendingOp): void {
// Remove conflicting entries (opposite op for same option) before adding.
for (let i = pending.length - 1; i >= 0; i--) {
if (pending[i].appId === op.appId && pending[i].option === op.option) {
pending.splice(i, 1)
}
}
pending.push(op)
}
function ensureWatcher(): void {
if (watchTimer || pending.length === 0) return
watchTimer = setInterval(() => {
void tick()
}, 5_000)
}
function stopWatcher(): void {
if (watchTimer) {
clearInterval(watchTimer)
watchTimer = null
}
}
async function tick(): Promise<void> {
if (pending.length === 0) {
stopWatcher()
return
}
if (await isSteamRunning()) return
const toApply = [...pending]
pending.length = 0
for (const op of toApply) {
if (op.op === 'add') await applyOptionToAllConfigs(op.appId, op.option)
else await removeOptionFromAllConfigs(op.appId, op.option)
}
stopWatcher()
onAppliedCallback?.()
}
export type EnsureResult = 'applied' | 'queued' | 'no_user'
export async function ensureLaunchOption(
appId: string,
option: string
): Promise<EnsureResult> {
if (await isLaunchOptionPresent(appId, option)) return 'applied'
const paths = await getLocalConfigPaths()
if (paths.length === 0) return 'no_user'
if (await isSteamRunning()) {
dedupePush({ appId, option, op: 'add' })
ensureWatcher()
return 'queued'
}
await applyOptionToAllConfigs(appId, option)
return (await isLaunchOptionPresent(appId, option)) ? 'applied' : 'queued'
}
export async function ensureLaunchOptionRemoved(
appId: string,
option: string
): Promise<EnsureResult> {
if (!(await isLaunchOptionPresent(appId, option))) return 'applied'
if (await isSteamRunning()) {
dedupePush({ appId, option, op: 'remove' })
ensureWatcher()
return 'queued'
}
await removeOptionFromAllConfigs(appId, option)
return (await isLaunchOptionPresent(appId, option)) ? 'queued' : 'applied'
}

102
src/main/games/steam.ts Normal file
View File

@@ -0,0 +1,102 @@
import { exec } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { parseVdf, type VdfNode } from './vdf'
const execAsync = promisify(exec)
async function regQuery(key: string, valueName: string): Promise<string | undefined> {
try {
const { stdout } = await execAsync(
`reg query "${key}" /v ${valueName}`,
{ windowsHide: true }
)
const m = stdout.match(/REG_SZ\s+(.+)/)
return m?.[1]?.trim()
} catch {
return undefined
}
}
export async function getSteamPath(): Promise<string | undefined> {
const fromUser = await regQuery('HKCU\\Software\\Valve\\Steam', 'SteamPath')
if (fromUser && existsSync(fromUser)) return fromUser.replace(/\//g, '\\')
const fromMachine = await regQuery(
'HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam',
'InstallPath'
)
if (fromMachine && existsSync(fromMachine)) return fromMachine
// Common fallbacks
for (const p of [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam'
]) {
if (existsSync(p)) return p
}
return undefined
}
export type SteamLibrary = {
path: string
apps: Set<string>
}
export async function getSteamLibraries(): Promise<SteamLibrary[]> {
const steamPath = await getSteamPath()
if (!steamPath) return []
const libsFile = join(steamPath, 'config', 'libraryfolders.vdf')
if (!existsSync(libsFile)) {
// Bare minimum: the install itself is a library.
return [{ path: steamPath, apps: new Set() }]
}
const raw = readFileSync(libsFile, 'utf-8')
const parsed = parseVdf(raw)
const libs: SteamLibrary[] = []
const root = (parsed['libraryfolders'] as VdfNode) ?? parsed
for (const key of Object.keys(root)) {
const entry = root[key]
if (typeof entry !== 'object') continue
const path = (entry['path'] as string) ?? ''
if (!path) continue
const apps = new Set<string>()
const appsNode = entry['apps']
if (typeof appsNode === 'object') {
for (const appId of Object.keys(appsNode)) apps.add(appId)
}
libs.push({ path: path.replace(/\\\\/g, '\\'), apps })
}
return libs
}
export async function findGameInstall(
appId: string,
installDirName: string
): Promise<string | undefined> {
const libs = await getSteamLibraries()
for (const lib of libs) {
// Prefer libraries that claim this app, but also probe by directory.
const candidate = join(lib.path, 'steamapps', 'common', installDirName)
if (existsSync(candidate)) return candidate
}
// Fallback: scan all libs for matching appmanifest.
for (const lib of libs) {
const manifest = join(lib.path, 'steamapps', `appmanifest_${appId}.acf`)
if (!existsSync(manifest)) continue
try {
const acf = parseVdf(readFileSync(manifest, 'utf-8'))
const appState = acf['AppState'] as VdfNode | undefined
const installdir = appState?.['installdir'] as string | undefined
if (installdir) {
const candidate = join(lib.path, 'steamapps', 'common', installdir)
if (existsSync(candidate)) return candidate
}
} catch {
// ignore
}
}
return undefined
}

122
src/main/games/vdf.ts Normal file
View File

@@ -0,0 +1,122 @@
// Minimal Valve KeyValues (VDF) text parser.
// Handles nested objects and quoted string values. Sufficient for libraryfolders.vdf.
export type VdfNode = { [key: string]: string | VdfNode }
class Cursor {
constructor(public src: string, public pos: number = 0) {}
peek(): string {
return this.src[this.pos] ?? ''
}
next(): string {
return this.src[this.pos++] ?? ''
}
eof(): boolean {
return this.pos >= this.src.length
}
}
function skipWhitespaceAndComments(c: Cursor): void {
for (;;) {
while (!c.eof() && /\s/.test(c.peek())) c.next()
if (c.peek() === '/' && c.src[c.pos + 1] === '/') {
while (!c.eof() && c.next() !== '\n') {
/* skip line */
}
continue
}
return
}
}
function readToken(c: Cursor): string {
skipWhitespaceAndComments(c)
if (c.eof()) return ''
if (c.peek() === '"') {
c.next()
let out = ''
while (!c.eof()) {
const ch = c.next()
if (ch === '\\') {
const next = c.next()
if (next === 'n') out += '\n'
else if (next === 't') out += '\t'
else out += next
continue
}
if (ch === '"') return out
out += ch
}
return out
}
if (c.peek() === '{' || c.peek() === '}') return c.next()
let out = ''
while (!c.eof() && !/\s/.test(c.peek()) && c.peek() !== '{' && c.peek() !== '}') {
out += c.next()
}
return out
}
function parseObject(c: Cursor): VdfNode {
const node: VdfNode = {}
for (;;) {
skipWhitespaceAndComments(c)
if (c.eof()) return node
if (c.peek() === '}') {
c.next()
return node
}
const key = readToken(c)
if (!key) return node
skipWhitespaceAndComments(c)
if (c.peek() === '{') {
c.next()
node[key] = parseObject(c)
} else {
node[key] = readToken(c)
}
}
}
export function parseVdf(src: string): VdfNode {
const c = new Cursor(src)
const root: VdfNode = {}
for (;;) {
skipWhitespaceAndComments(c)
if (c.eof()) break
const key = readToken(c)
if (!key) break
skipWhitespaceAndComments(c)
if (c.peek() === '{') {
c.next()
root[key] = parseObject(c)
} else {
root[key] = readToken(c)
}
}
return root
}
function escapeVdfString(s: string): string {
return s
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
}
export function stringifyVdf(node: VdfNode, indent: number = 0): string {
const pad = '\t'.repeat(indent)
let out = ''
for (const key of Object.keys(node)) {
const value = node[key]
if (typeof value === 'string') {
out += `${pad}"${escapeVdfString(key)}"\t\t"${escapeVdfString(value)}"\n`
} else {
out += `${pad}"${escapeVdfString(key)}"\n${pad}{\n`
out += stringifyVdf(value, indent + 1)
out += `${pad}}\n`
}
}
return out
}

83
src/main/index.ts Normal file
View File

@@ -0,0 +1,83 @@
import { app, BrowserWindow, nativeTheme, systemPreferences } from 'electron'
import { createMainWindow, createReminderWindow, showMainWindow } from './windows'
import { registerIpc } from './ipc'
import { startScheduler, stopScheduler } from './scheduler'
import { createTray } from './tray'
import { flushNow, getState } from './store'
import { wasStartedHidden } from './autostart'
import { broadcastState } from './state-actions'
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
import { IPC } from '@shared/ipc'
const APP_ID = 'com.anril.exercise-reminder'
// Must be set BEFORE app.whenReady() for Windows toasts to show
// the correct app name / icon in Action Center.
app.setAppUserModelId(APP_ID)
app.setName('Exercise Reminder')
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
app.on('second-instance', () => showMainWindow())
app.whenReady().then(() => {
registerIpc()
createTray()
const hidden =
wasStartedHidden() || getState().settings.startMinimized
createMainWindow(!hidden)
// Pre-create the reminder window so first-trigger is instant (no load lag).
createReminderWindow()
startScheduler()
startGamesRegistry().catch((err) =>
console.error('games registry failed:', err)
)
nativeTheme.on('updated', () => {
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtThemeChanged, theme)
}
})
try {
systemPreferences.on('accent-color-changed' as never, () => {
try {
const color = '#' + systemPreferences.getAccentColor()
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtAccentChanged, color)
}
} catch {
// ignore
}
})
} catch {
// older Electron / non-Windows
}
})
app.on('window-all-closed', () => {
// Keep running in tray instead of quitting when all windows closed.
if (!getState().settings.minimizeToTray) {
app.quit()
}
})
app.on('before-quit', () => {
stopScheduler()
void stopGamesRegistry()
flushNow()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow(true)
else showMainWindow()
})
// Broadcast state once on ready so any prebuilt windows hydrate.
app.whenReady().then(() => broadcastState())
}

204
src/main/ipc.ts Normal file
View File

@@ -0,0 +1,204 @@
import { ipcMain, nativeTheme, systemPreferences, BrowserWindow, app, shell } from 'electron'
import { IPC } from '@shared/ipc'
import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
import {
addChallenge,
addExercise,
deleteChallenge,
deleteExercise,
getState,
markDone,
setGameEnabled,
skip,
snooze,
updateChallenge,
updateExercise,
updateSettings
} from './store'
import { broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart'
import { setPaused, forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows'
import {
broadcastGames,
installGame,
listGamesStatus,
simulateMatchEnd,
toggleGame,
uninstallGame
} from './games/registry'
export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => {
const state = getState()
state.settings.startWithWindows = isAutostartEnabled()
return state
})
ipcMain.handle(
IPC.addExercise,
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
const ex = addExercise(input)
broadcastState()
return ex
}
)
ipcMain.handle(IPC.updateExercise, (_e, id: string, patch: Partial<Exercise>) => {
const ex = updateExercise(id, patch)
broadcastState()
return ex
})
ipcMain.handle(IPC.deleteExercise, (_e, id: string) => {
const ok = deleteExercise(id)
broadcastState()
return ok
})
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => {
const patch: Partial<Exercise> = { enabled }
if (enabled) {
const ex = getState().exercises.find((e) => e.id === id)
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
}
const ex = updateExercise(id, patch)
broadcastState()
return ex
})
ipcMain.handle(IPC.markDone, (_e, id: string) => {
const ex = markDone(id)
broadcastState()
return ex
})
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
const ex = snooze(id, minutes)
broadcastState()
return ex
})
ipcMain.handle(IPC.skip, (_e, id: string) => {
const ex = skip(id)
broadcastState()
return ex
})
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => {
if (patch.startWithWindows !== undefined) {
setAutostart(patch.startWithWindows)
}
const merged: Partial<Settings> = { ...patch }
if (patch.startWithWindows !== undefined) {
merged.startWithWindows = isAutostartEnabled()
}
const settings = updateSettings(merged)
broadcastState()
return settings
})
ipcMain.handle(IPC.getAccentColor, () => {
try {
return '#' + systemPreferences.getAccentColor()
} catch {
return '#5B8DEF'
}
})
ipcMain.handle(IPC.getOsTheme, () =>
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
)
ipcMain.handle(IPC.pauseAll, () => setPaused(true))
ipcMain.handle(IPC.resumeAll, () => {
setPaused(false)
forceCheck()
})
ipcMain.handle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
ipcMain.on(IPC.minimizeMain, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on(IPC.closeMain, () => {
const main = getMainWindow()
if (!main) return
if (getState().settings.minimizeToTray) main.hide()
else main.close()
})
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide())
// Games
ipcMain.handle(IPC.gamesList, async () => listGamesStatus())
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => {
const status = await installGame(id)
setGameEnabled(id, true)
await toggleGame(id, true)
const all = await listGamesStatus()
broadcastGames(all)
broadcastState()
return status
})
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => {
const status = await uninstallGame(id)
setGameEnabled(id, false)
const all = await listGamesStatus()
broadcastGames(all)
broadcastState()
return status
})
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
setGameEnabled(id, enabled)
await toggleGame(id, enabled)
const all = await listGamesStatus()
broadcastGames(all)
broadcastState()
})
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
// Opens Steam's library; user manually adds launch options.
shell.openExternal('steam://nav/games/details/570')
})
// Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => {
const c = addChallenge(input)
broadcastState()
return c
})
ipcMain.handle(
IPC.updateChallenge,
(_e, id: string, patch: Partial<Challenge>) => {
const c = updateChallenge(id, patch)
broadcastState()
return c
}
)
ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => {
const ok = deleteChallenge(id)
broadcastState()
return ok
})
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => {
const c = updateChallenge(id, { enabled })
broadcastState()
return c
})
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
// Dev helper: simulate a match end with given stats.
ipcMain.handle(
'dev:simulateMatchEnd',
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
}
)
}

65
src/main/notifications.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Notification, app } from 'electron'
import type { Exercise, MatchSummary, NotificationMode } from '@shared/types'
import { IPC } from '@shared/ipc'
import {
createReminderWindow,
getReminderWindow,
showReminderWindow
} from './windows'
export function fireReminder(exercise: Exercise, mode: NotificationMode): void {
if (mode === 'toast' || mode === 'both') showToast(exercise)
if (mode === 'modal' || mode === 'both') showModal(exercise)
}
export function fireMatchSummary(summary: MatchSummary): void {
if (Notification.isSupported()) {
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
const n = new Notification({
title: `Матч ${summary.gameName} завершён`,
body: `Челленджей: ${summary.results.length}, всего повторений: ${totalReps}`,
silent: false
})
n.on('click', () => showReminderWindow())
n.show()
}
const win = createReminderWindow()
const send = (): void => {
win.webContents.send(IPC.evtMatchEnd, summary)
}
if (win.webContents.isLoading()) {
win.webContents.once('did-finish-load', send)
} else {
send()
}
showReminderWindow()
}
function showToast(exercise: Exercise): void {
if (!Notification.isSupported()) return
const n = new Notification({
title: app.getName(),
body: `${exercise.name}${exercise.reps}`,
silent: false
})
n.on('click', () => showReminderWindow())
n.show()
}
function showModal(exercise: Exercise): void {
const win = createReminderWindow()
const send = (): void => {
win.webContents.send(IPC.evtFire, exercise)
}
if (win.webContents.isLoading()) {
win.webContents.once('did-finish-load', send)
} else {
send()
}
showReminderWindow()
}
export function notifyReminderClosed(): void {
const win = getReminderWindow()
if (win && !win.isDestroyed()) win.hide()
}

88
src/main/scheduler.ts Normal file
View File

@@ -0,0 +1,88 @@
import { powerMonitor, BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import type { Tick } from '@shared/types'
import { getExercises, getSettings, updateExercise } from './store'
import { fireReminder } from './notifications'
const TICK_MS = 1000
const CHECK_MS = 5000
let tickHandle: NodeJS.Timeout | null = null
let lastCheckAt = 0
let paused = false
function checkDueExercises(): void {
if (paused) return
const settings = getSettings()
if (!settings.globalEnabled) return
const now = Date.now()
const exercises = getExercises()
for (const ex of exercises) {
if (!ex.enabled) continue
if (ex.nextFireAt <= now) {
// Fire once, reschedule from now (drop missed intervals).
const updated = updateExercise(ex.id, {
nextFireAt: now + ex.intervalMinutes * 60_000
})
if (updated) fireReminder(updated, settings.notificationMode)
}
}
}
function broadcastTicks(): void {
const now = Date.now()
const ticks: Tick[] = getExercises().map((e) => ({
exerciseId: e.id,
msUntilFire: Math.max(0, e.nextFireAt - now),
enabled: e.enabled
}))
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtTick, ticks)
}
}
function tick(): void {
broadcastTicks()
const now = Date.now()
if (now - lastCheckAt >= CHECK_MS) {
lastCheckAt = now
checkDueExercises()
}
}
export function startScheduler(): void {
if (tickHandle) return
lastCheckAt = 0
tickHandle = setInterval(tick, TICK_MS)
// Run an immediate tick so renderer hydrates quickly.
tick()
powerMonitor.on('resume', () => {
lastCheckAt = 0
tick()
})
powerMonitor.on('unlock-screen', () => {
lastCheckAt = 0
tick()
})
}
export function stopScheduler(): void {
if (tickHandle) {
clearInterval(tickHandle)
tickHandle = null
}
}
export function setPaused(value: boolean): void {
paused = value
}
export function isPaused(): boolean {
return paused
}
export function forceCheck(): void {
lastCheckAt = 0
tick()
}

19
src/main/state-actions.ts Normal file
View File

@@ -0,0 +1,19 @@
import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import { getExercises, getState, updateExercise } from './store'
export function broadcastState(): void {
const state = getState()
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state)
}
}
export function snoozeAll(minutes: number): void {
const now = Date.now()
for (const ex of getExercises()) {
if (!ex.enabled) continue
updateExercise(ex.id, { nextFireAt: now + minutes * 60_000 })
}
broadcastState()
}

235
src/main/store.ts Normal file
View File

@@ -0,0 +1,235 @@
import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
AppState,
Challenge,
DEFAULT_SETTINGS,
Exercise,
GameId,
SAMPLE_EXERCISES,
Settings
} from '@shared/types'
let cache: AppState | null = null
let storePath = ''
let pendingWrite: NodeJS.Timeout | null = null
function getStorePath(): string {
if (!storePath) {
const dir = app.getPath('userData')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
storePath = join(dir, 'app-state.json')
}
return storePath
}
function makeInitial(): AppState {
const now = Date.now()
return {
exercises: SAMPLE_EXERCISES.map((e) => ({
...e,
id: randomUUID(),
nextFireAt: now + e.intervalMinutes * 60_000
})),
settings: { ...DEFAULT_SETTINGS },
challenges: [
{
id: randomUUID(),
name: 'За смерти в Dota — приседания',
gameId: 'dota2',
stat: 'deaths',
multiplier: 3,
exerciseName: 'Приседания',
icon: 'Activity',
enabled: true
},
{
id: randomUUID(),
name: 'За убийства — отжимания',
gameId: 'dota2',
stat: 'kills',
multiplier: 1,
exerciseName: 'Отжимания',
icon: 'Dumbbell',
enabled: false
}
],
gamesEnabled: {}
}
}
function load(): AppState {
const p = getStorePath()
if (!existsSync(p)) {
const initial = makeInitial()
writeFileSync(p, JSON.stringify(initial, null, 2), 'utf-8')
return initial
}
try {
const raw = readFileSync(p, 'utf-8')
const parsed = JSON.parse(raw) as Partial<AppState>
return {
exercises: parsed.exercises ?? [],
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
challenges: parsed.challenges ?? [],
gamesEnabled: parsed.gamesEnabled ?? {}
}
} catch {
return makeInitial()
}
}
function flush(): void {
if (!cache) return
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
}
function scheduleWrite(): void {
if (pendingWrite) return
pendingWrite = setTimeout(() => {
pendingWrite = null
flush()
}, 1500)
}
export function getState(): AppState {
if (!cache) cache = load()
return cache
}
export function getSettings(): Settings {
return getState().settings
}
export function getExercises(): Exercise[] {
return getState().exercises
}
export function updateSettings(patch: Partial<Settings>): Settings {
const state = getState()
state.settings = { ...state.settings, ...patch }
scheduleWrite()
return state.settings
}
export function addExercise(
input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>
): Exercise {
const state = getState()
const exercise: Exercise = {
...input,
id: randomUUID(),
nextFireAt: Date.now() + input.intervalMinutes * 60_000
}
state.exercises.push(exercise)
scheduleWrite()
return exercise
}
export function updateExercise(
id: string,
patch: Partial<Omit<Exercise, 'id'>>
): Exercise | undefined {
const state = getState()
const idx = state.exercises.findIndex((e) => e.id === id)
if (idx === -1) return undefined
const prev = state.exercises[idx]
const merged: Exercise = { ...prev, ...patch }
// If interval changed, reschedule from now.
if (patch.intervalMinutes !== undefined && patch.intervalMinutes !== prev.intervalMinutes) {
merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000
}
state.exercises[idx] = merged
scheduleWrite()
return merged
}
export function deleteExercise(id: string): boolean {
const state = getState()
const before = state.exercises.length
state.exercises = state.exercises.filter((e) => e.id !== id)
const changed = state.exercises.length !== before
if (changed) scheduleWrite()
return changed
}
export function markDone(id: string): Exercise | undefined {
const state = getState()
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.lastDoneAt = Date.now()
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
scheduleWrite()
return ex
}
export function snooze(id: string, minutes: number): Exercise | undefined {
const state = getState()
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000
scheduleWrite()
return ex
}
export function skip(id: string): Exercise | undefined {
const state = getState()
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
scheduleWrite()
return ex
}
export function flushNow(): void {
if (pendingWrite) {
clearTimeout(pendingWrite)
pendingWrite = null
}
flush()
}
export function getChallenges(): Challenge[] {
return getState().challenges
}
export function addChallenge(input: Omit<Challenge, 'id'>): Challenge {
const state = getState()
const challenge: Challenge = { ...input, id: randomUUID() }
state.challenges.push(challenge)
scheduleWrite()
return challenge
}
export function updateChallenge(
id: string,
patch: Partial<Omit<Challenge, 'id'>>
): Challenge | undefined {
const state = getState()
const idx = state.challenges.findIndex((c) => c.id === id)
if (idx === -1) return undefined
state.challenges[idx] = { ...state.challenges[idx], ...patch }
scheduleWrite()
return state.challenges[idx]
}
export function deleteChallenge(id: string): boolean {
const state = getState()
const before = state.challenges.length
state.challenges = state.challenges.filter((c) => c.id !== id)
const changed = state.challenges.length !== before
if (changed) scheduleWrite()
return changed
}
export function getGamesEnabled(): Partial<Record<GameId, boolean>> {
return getState().gamesEnabled
}
export function setGameEnabled(id: GameId, enabled: boolean): void {
const state = getState()
state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled }
scheduleWrite()
}

68
src/main/tray.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'node:path'
import { showMainWindow } from './windows'
import { isPaused, setPaused, forceCheck } from './scheduler'
import { snoozeAll } from './state-actions'
let tray: Tray | null = null
function resolveTrayIcon(): Electron.NativeImage {
// Try resources/, fallback to a transparent 16x16 if missing during dev.
const candidates = [
join(process.resourcesPath, 'tray.png'),
join(__dirname, '../../resources/tray.png'),
join(app.getAppPath(), 'resources/tray.png')
]
for (const p of candidates) {
const img = nativeImage.createFromPath(p)
if (!img.isEmpty()) return img
}
return nativeImage.createEmpty()
}
export function createTray(): Tray {
if (tray) return tray
const icon = resolveTrayIcon()
tray = new Tray(icon)
tray.setToolTip('Exercise Reminder')
refreshMenu()
tray.on('click', () => showMainWindow())
tray.on('double-click', () => showMainWindow())
return tray
}
export function refreshMenu(): void {
if (!tray) return
const paused = isPaused()
const menu = Menu.buildFromTemplate([
{ label: 'Открыть', click: () => showMainWindow() },
{ type: 'separator' },
{
label: paused ? 'Возобновить напоминания' : 'Пауза напоминаний',
click: () => {
setPaused(!paused)
refreshMenu()
if (!paused) forceCheck()
}
},
{
label: 'Отложить все на 15 мин',
click: () => snoozeAll(15)
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.quit()
}
}
])
tray.setContextMenu(menu)
}
export function destroyTray(): void {
if (tray) {
tray.destroy()
tray = null
}
}

157
src/main/windows.ts Normal file
View File

@@ -0,0 +1,157 @@
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
let mainWindow: BrowserWindow | null = null
let reminderWindow: BrowserWindow | null = null
function preloadPath(): string {
return join(__dirname, '../preload/index.js')
}
function windowIcon(): Electron.NativeImage | undefined {
const candidates = [
join(process.resourcesPath, 'icon.png'),
join(__dirname, '../../resources/icon.png'),
join(app.getAppPath(), 'resources/icon.png')
]
for (const p of candidates) {
if (existsSync(p)) {
const img = nativeImage.createFromPath(p)
if (!img.isEmpty()) return img
}
}
return undefined
}
function loadRoute(win: BrowserWindow, route: 'main' | 'reminder'): void {
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
win.loadURL(`${devUrl}?window=${route}`)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'), {
search: `window=${route}`
})
}
}
export function createMainWindow(showImmediately = true): BrowserWindow {
if (mainWindow && !mainWindow.isDestroyed()) {
if (showImmediately) {
mainWindow.show()
mainWindow.focus()
}
return mainWindow
}
const icon = windowIcon()
const win = new BrowserWindow({
width: 1100,
height: 720,
minWidth: 900,
minHeight: 600,
show: false,
frame: false,
backgroundColor: '#0f1117',
titleBarStyle: 'hidden',
autoHideMenuBar: true,
...(icon ? { icon } : {}),
webPreferences: {
preload: preloadPath(),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
win.on('ready-to-show', () => {
if (showImmediately) win.show()
})
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
loadRoute(win, 'main')
mainWindow = win
win.on('closed', () => {
if (mainWindow === win) mainWindow = null
})
return win
}
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}
export function showMainWindow(): void {
const win = createMainWindow(true)
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
export function createReminderWindow(): BrowserWindow {
if (reminderWindow && !reminderWindow.isDestroyed()) return reminderWindow
const display = screen.getPrimaryDisplay()
const width = 560
const height = 520
const icon = windowIcon()
const win = new BrowserWindow({
width,
height,
x: Math.round(display.workArea.x + (display.workArea.width - width) / 2),
y: Math.round(display.workArea.y + (display.workArea.height - height) / 2),
show: false,
frame: false,
resizable: false,
fullscreenable: false,
skipTaskbar: true,
movable: true,
alwaysOnTop: true,
backgroundColor: '#0f1117',
...(icon ? { icon } : {}),
webPreferences: {
preload: preloadPath(),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
win.setAlwaysOnTop(true, 'screen-saver')
loadRoute(win, 'reminder')
win.on('closed', () => {
if (reminderWindow === win) reminderWindow = null
})
reminderWindow = win
return win
}
export function showReminderWindow(): void {
const win = createReminderWindow()
// Recenter in case display config changed.
const display = screen.getPrimaryDisplay()
const [w, h] = win.getSize()
win.setBounds({
x: Math.round(display.workArea.x + (display.workArea.width - w) / 2),
y: Math.round(display.workArea.y + (display.workArea.height - h) / 2),
width: w,
height: h
})
win.show()
win.focus()
}
export function hideReminderWindow(): void {
if (reminderWindow && !reminderWindow.isDestroyed()) reminderWindow.hide()
}
export function getReminderWindow(): BrowserWindow | null {
return reminderWindow
}