diff --git a/app.go b/app.go index 84dc6bd..4385bad 100644 --- a/app.go +++ b/app.go @@ -52,6 +52,9 @@ func (a *App) startup(ctx context.Context) { a.ctx = ctx a.applyTheme(store.LoadSettings().Theme) a.startTray() + if a.hidden { + a.trimSoon(trimAfterStartup) + } go func() { defer logx.Recover("startup") ensureAutostart(store.LoadSettings().StartAtLogin) diff --git a/frontend/src/lib/refresher.test.ts b/frontend/src/lib/refresher.test.ts new file mode 100644 index 0000000..6cdf8c4 --- /dev/null +++ b/frontend/src/lib/refresher.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRefresher } from './refresher' + +describe('createRefresher', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('refreshes on the timer and after changes while shown', () => { + const calls: boolean[] = [] + const r = createRefresher(c => calls.push(c), 1000, 100) + r.start() + vi.advanceTimersByTime(1000) + expect(calls).toEqual([false]) + r.changed() + r.changed() // a burst of changes is one refresh + vi.advanceTimersByTime(100) + expect(calls).toEqual([false, true]) + }) + + it('does nothing while hidden and catches up once shown', () => { + const calls: boolean[] = [] + const r = createRefresher(c => calls.push(c), 1000, 100) + r.start() + r.changed() + r.shown(false) // the pending refresh is dropped too + r.changed() + vi.advanceTimersByTime(10_000) + expect(calls).toEqual([]) + r.shown(true) + expect(calls).toEqual([true]) + vi.advanceTimersByTime(1000) + expect(calls).toEqual([true, false]) + }) + + it('starting hidden waits for the window', () => { + const calls: boolean[] = [] + const r = createRefresher(c => calls.push(c), 1000, 100) + r.shown(false) + r.start() + vi.advanceTimersByTime(5000) + expect(calls).toEqual([]) + r.shown(true) + r.shown(true) // no second catch-up + expect(calls).toEqual([true]) + }) +}) diff --git a/frontend/src/lib/refresher.ts b/frontend/src/lib/refresher.ts new file mode 100644 index 0000000..4f920cf --- /dev/null +++ b/frontend/src/lib/refresher.ts @@ -0,0 +1,37 @@ +/** + * Refreshes the UI on a timer and shortly after backend changes, but only + * while the window shows: hidden in the tray it does nothing (no timer, no + * backend calls), and it catches up the moment the window shows again. + * `refresh(true)` means views should refetch too, `refresh(false)` is the + * timer's lighter pass. + */ +export function createRefresher(refresh: (changed: boolean) => void, every = 15000, settle = 400) { + let shown = true + let timer: ReturnType | null = null + let pending: ReturnType | null = null + + const start = () => { timer ??= setInterval(() => refresh(false), every) } + const stop = () => { + if (timer) clearInterval(timer) + if (pending) clearTimeout(pending) + timer = pending = null + } + + return { + /** Starts the timer (unless hidden). */ + start() { if (shown) start() }, + /** The backend reported a change: refresh soon, unless hidden. */ + changed() { + if (!shown || pending) return + pending = setTimeout(() => { pending = null; refresh(true) }, settle) + }, + /** The window was shown or hidden. */ + shown(on: boolean) { + if (on === shown) return + shown = on + if (!on) return stop() + start() + refresh(true) + }, + } +} diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts index d451897..743f3b6 100644 --- a/frontend/src/lib/state.svelte.ts +++ b/frontend/src/lib/state.svelte.ts @@ -1,8 +1,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime' -import { Overview, Accounts } from '../../wailsjs/go/main/App' +import { Overview, Accounts, WindowHidden } from '../../wailsjs/go/main/App' import type { main } from '../../wailsjs/go/models' import { err } from './fmt' import { issueView } from './features' +import { createRefresher } from './refresher' export type View = 'overview' | 'games' | 'mods' | 'accounts' | 'devices' | 'backup' | 'settings' @@ -71,16 +72,20 @@ export function applyTheme(t: string) { else delete root.dataset.theme } -let pending: ReturnType | null = null +// Hidden in the tray, the UI asks the backend nothing (see refresher.ts). +const refresher = createRefresher(changed => { + if (changed) ui.tick++ + refresh() +}) export function init() { refresh() - EventsOn('changed', () => { - if (pending) return - pending = setTimeout(() => { pending = null; ui.tick++; refresh() }, 400) - }) + EventsOn('changed', refresher.changed) + let told = false // a window event is newer than WindowHidden's answer + EventsOn('window', (shown: boolean) => { told = true; refresher.shown(shown) }) EventsOn('toast', (t: string) => toast(t, 'ok')) EventsOn('toast:error', (t: string) => toast(t, 'err')) EventsOn('games:added', () => { ui.gamesAdded++ }) - setInterval(refresh, 15000) + refresher.start() + WindowHidden().then(h => { if (h && !told) refresher.shown(false) }, () => {}) } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f2f84ee..2f06f3b 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -176,3 +176,5 @@ export function UseDriveApp():Promise; export function UseModSettingsEverywhere():Promise; export function VortexShares():Promise>; + +export function WindowHidden():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index c74b3ff..e61310b 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -341,3 +341,7 @@ export function UseModSettingsEverywhere() { export function VortexShares() { return window['go']['main']['App']['VortexShares'](); } + +export function WindowHidden() { + return window['go']['main']['App']['WindowHidden'](); +} diff --git a/internal/winx/trim.go b/internal/winx/trim.go new file mode 100644 index 0000000..cca4629 --- /dev/null +++ b/internal/winx/trim.go @@ -0,0 +1,66 @@ +package winx + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +var emptyWorkingSet = windows.NewLazySystemDLL("psapi.dll").NewProc("EmptyWorkingSet") + +// TrimMemory hands the idle pages of this process and its children (the +// WebView2 processes) back to Windows. They move to the standby list and come +// back on demand, so a window that is hidden in the tray stops holding on to +// hundreds of MB it won't touch for hours. Failures are ignored: it is only a +// hint. It returns how many processes were trimmed. +func TrimMemory() int { + n := 0 + for _, pid := range descendants(windows.GetCurrentProcessId()) { + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + continue + } + if r, _, _ := emptyWorkingSet.Call(uintptr(h)); r != 0 { + n++ + } + windows.CloseHandle(h) + } + return n +} + +// descendants lists root and every process below it. +func descendants(root uint32) []uint32 { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return []uint32{root} + } + defer windows.CloseHandle(snap) + return processTree(root, parents(snap)) +} + +// parents maps each process ID in the snapshot to its parent's. +func parents(snap windows.Handle) map[uint32]uint32 { + out := map[uint32]uint32{} + e := windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))} + for err := windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) { + out[e.ProcessID] = e.ParentProcessID + } + return out +} + +// processTree returns root and all its descendants given each process's parent. +func processTree(root uint32, parent map[uint32]uint32) []uint32 { + tree := []uint32{root} + in := map[uint32]bool{root: true} + for grew := true; grew; { + grew = false + for pid, ppid := range parent { + if !in[pid] && in[ppid] { + in[pid] = true + tree = append(tree, pid) + grew = true + } + } + } + return tree +} diff --git a/internal/winx/trim_test.go b/internal/winx/trim_test.go new file mode 100644 index 0000000..09813b3 --- /dev/null +++ b/internal/winx/trim_test.go @@ -0,0 +1,28 @@ +package winx + +import ( + "slices" + "testing" +) + +func TestProcessTree(t *testing.T) { + parent := map[uint32]uint32{2: 1, 3: 2, 4: 3, 5: 9, 6: 1} + got := processTree(1, parent) + slices.Sort(got) + if want := []uint32{1, 2, 3, 4, 6}; !slices.Equal(got, want) { + t.Fatalf("tree = %v, want %v", got, want) + } +} + +func TestProcessTreeIgnoresPidReuseCycle(t *testing.T) { + // A child whose recorded parent is itself must not loop forever. + if got := processTree(1, map[uint32]uint32{7: 7, 2: 1}); len(got) != 2 { + t.Fatalf("tree = %v", got) + } +} + +func TestTrimMemoryRuns(t *testing.T) { + if TrimMemory() < 1 { + t.Fatal("did not even trim this process") + } +} diff --git a/tray.go b/tray.go index e29c552..dbda386 100644 --- a/tray.go +++ b/tray.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "runtime/debug" "strings" "time" @@ -17,6 +18,7 @@ import ( "github.com/ApolloF/syncer/internal/backup" "github.com/ApolloF/syncer/internal/logx" "github.com/ApolloF/syncer/internal/store" + "github.com/ApolloF/syncer/internal/winx" ) //go:embed build/tray.ico @@ -189,6 +191,15 @@ func (a *App) showWindow() { wruntime.WindowShow(a.ctx) wruntime.WindowSetAlwaysOnTop(a.ctx, true) // bring to front… wruntime.WindowSetAlwaysOnTop(a.ctx, false) + wruntime.EventsEmit(a.ctx, "window", true) +} + +// WindowHidden reports whether only the tray icon shows. The UI stops +// refreshing while hidden and catches up on the "window" event. +func (a *App) WindowHidden() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.hidden } // quit really exits, even with "keep running in the tray" on. @@ -212,9 +223,28 @@ func (a *App) beforeClose(ctx context.Context) bool { a.mu.Lock() a.hidden = true a.mu.Unlock() + wruntime.EventsEmit(ctx, "window", false) + a.trimSoon(trimAfterHide) return true } +const ( + trimAfterHide = 5 * time.Second // let the window finish hiding + trimAfterStartup = 45 * time.Second // let the startup work settle +) + +// trimSoon gives the memory a hidden window doesn't need back to Windows (see +// winx.TrimMemory), unless the window shows again in the meantime. +func (a *App) trimSoon(after time.Duration) { + time.AfterFunc(after, func() { + defer logx.Recover("trim memory") + if a.WindowHidden() { + debug.FreeOSMemory() + winx.TrimMemory() + } + }) +} + func (a *App) shutdown(context.Context) { systray.Quit() } // ---- start with Windows ------------------------------------------------------