Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/lib/refresher.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
37 changes: 37 additions & 0 deletions frontend/src/lib/refresher.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | null = null
let pending: ReturnType<typeof setTimeout> | 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)
},
}
}
19 changes: 12 additions & 7 deletions frontend/src/lib/state.svelte.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -71,16 +72,20 @@ export function applyTheme(t: string) {
else delete root.dataset.theme
}

let pending: ReturnType<typeof setTimeout> | 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) }, () => {})
}
2 changes: 2 additions & 0 deletions frontend/wailsjs/go/main/App.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,5 @@ export function UseDriveApp():Promise<void>;
export function UseModSettingsEverywhere():Promise<void>;

export function VortexShares():Promise<Array<main.VortexShareView>>;

export function WindowHidden():Promise<boolean>;
4 changes: 4 additions & 0 deletions frontend/wailsjs/go/main/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']();
}
66 changes: 66 additions & 0 deletions internal/winx/trim.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions internal/winx/trim_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
30 changes: 30 additions & 0 deletions tray.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"time"

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 ------------------------------------------------------
Expand Down
Loading