detached 1fe80dd

#1 feat(wine): native Wayland HDR with safe Wine prefix upgrades

state
merged · 1fe80dd · fast-forward
branch
feat/wine-hdr → main
opened
bnema ·
updated

Selects Wine >= 11.19 in Wayland mode (winewayland colour management), sets DXVK_HDR=1 by default (--no-hdr opt-out), and upgrades existing prefixes safely: per-prefix flock, downgrade refusal, migration record, wineboot -u -r with RunOnce preflight, Go-installed DXVK/vkd3d-proton DLLs verified before the version marker is written.

CommitSubjectAuthorDateSignatureStatus
1fe80ddfix(wine): restrict startup preflight to maintenancebrice GPG Verified
32544e8fix(wine): respect prefix architecture and optional d3d8brice GPG Verified
e3de95dfix(wine): install and verify graphics DLLs without setup scriptsbrice GPG Verified
ae813c8fix(wine): lock prefix maintenance and prevent runtime downgradesbrice GPG Verified
42fbd6ffix(env): report inherited HDR state on X11brice GPG Verified
cea9d01feat(wine): update prefixes on Wine version changesbrice GPG Verified
9592e56feat(env): enable Wayland HDR with launch opt-outbrice GPG Verified
bcbf52bfeat(wine): prefer HDR-capable Wine for Waylandbrice GPG Verified

Checks 0

no checks

20 files · +1287 -41
no checks
M README.md +54 -1
@@ -43,7 +43,7 @@ bnetctl clean -a # Full purge (cache, prefix, desktop entry, data)
4343
4444 ## What it does
4545
46-- Uses `/opt/wine-cachyos/bin/wine` by default, or `BNETCTL_WINE` when set
46+- Prefers `/opt/wine-cachyos/bin/wine`; in Wayland mode, prefers Wine >= 11.19, falling back to `wine` on PATH when necessary. `BNETCTL_WINE` always wins
4747 - Creates a Wine prefix with DXVK + VKD3D-proton auto-installed
4848 - Downloads and runs the official Battle.net installer
4949 - Uses native Wayland by default for both `install` and `launch`, with `--display-driver x11` available for XWayland
@@ -52,6 +52,59 @@ bnetctl clean -a # Full purge (cache, prefix, desktop entry, data)
5252 - Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries
5353 - Graceful process management with orphan cleanup
5454
55+## HDR on Wayland
56+
57+HDR is enabled automatically for native Wayland (`DXVK_HDR=1`). It requires
58+**Wine >= 11.19**, an HDR-capable compositor with HDR enabled, and an HDR-capable
59+monitor/game. An existing `DXVK_HDR` environment variable is respected, including
60+an empty value. To disable HDR explicitly (also overriding `DXVK_HDR=1`):
61+
62+```bash
63+bnetctl launch --no-hdr
64+```
65+
66+X11 mode does not set `DXVK_HDR`. Games started by Battle.net inherit its HDR
67+policy; enable HDR in the game's own settings too.
68+
69+For Wayland `install` and `launch`, bnetctl prefers CachyOS Wine when it meets the
70+minimum, otherwise qualifying `wine` on PATH. If neither qualifies, it retains
71+the usual preference and warns. X11 selection is unchanged. `BNETCTL_WINE`
72+always wins, e.g. `BNETCTL_WINE=/usr/bin/wine`; companion `wineboot` and
73+`wineserver` must be beside its resolved binary.
74+
75+On the first launch of an existing prefix without a version marker, or after a
76+Wine version change, bnetctl runs `wineboot -u -r`, waits for Wine to exit, and
77+directly reinstalls and verifies DXVK/vkd3d-proton DLL symlinks and `native`
78+overrides. No setup scripts are executed. Package data is found beside the
79+resolved setup scripts, falling back to `/usr/share/dxvk` and
80+`/usr/share/vkd3d-proton` (including Arch's vkd3d wrapper).
81+
82+**Maintenance limitation:** `-r` skips Run keys and Startup, but Wine still
83+processes RunServicesOnce and HKLM RunOnce (`programs/wineboot/wineboot.c`,
84+lines 1931 and 1945; Run/Startup are gated at 1947–1952). Before maintenance,
85+bnetctl reads `pfx/system.reg` and `pfx/user.reg` and refuses if RunOnce or
86+RunServicesOnce, including Wow6432Node variants, contains any values. The error
87+lists the entries. bnetctl never deletes or disables these registry values.
88+Resolve pending one-time startup tasks yourself before retrying maintenance.
89+
90+The `prefix/wine-version` marker is committed only after verification. A
91+`prefix/wine-version.migrating` record is written before updating; failures
92+retain it and force a retry, even if the committed version already matches.
93+Both records establish a minimum Wine version: X11 and Wayland will use an
94+available runtime at least that new, or refuse. An older explicit `BNETCTL_WINE`
95+is rejected rather than silently overridden. Switching display modes never
96+downgrades a tracked prefix. Version probes (`wine --version`) may run during
97+selection; downgrade refusals run no prefix Wine commands.
98+
99+An exclusive `prefix/.bnetctl.lock` protects selection and maintenance until the
100+application process starts; concurrent invocations fail immediately. Close
101+Battle.net and all its games before switching Wine versions. The first update
102+may take longer. The default prefix is `~/.local/share/bnetctl/prefix`.
103+
104+Use `bnetctl --verbose launch --display-driver wayland` to see the selected Wine,
105+HDR policy, and any one-time prefix update on stderr. Logs are always saved to
106+`~/.cache/bnetctl/bnetctl.log` (or the corresponding XDG cache directory).
107+
55108 ## Directories
56109
57110 | Type | Path |
@@ -43,7 +43,7 @@ bnetctl clean -a # Full purge (cache, prefix, desktop entry, data)
43 43
44 ## What it does44 ## What it does
45 45
46-- Uses `/opt/wine-cachyos/bin/wine` by default, or `BNETCTL_WINE` when set46+- Prefers `/opt/wine-cachyos/bin/wine`; in Wayland mode, prefers Wine >= 11.19, falling back to `wine` on PATH when necessary. `BNETCTL_WINE` always wins
47 - Creates a Wine prefix with DXVK + VKD3D-proton auto-installed47 - Creates a Wine prefix with DXVK + VKD3D-proton auto-installed
48 - Downloads and runs the official Battle.net installer48 - Downloads and runs the official Battle.net installer
49 - Uses native Wayland by default for both `install` and `launch`, with `--display-driver x11` available for XWayland49 - Uses native Wayland by default for both `install` and `launch`, with `--display-driver x11` available for XWayland
@@ -52,6 +52,59 @@ bnetctl clean -a # Full purge (cache, prefix, desktop entry, data)
52 - Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries52 - Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries
53 - Graceful process management with orphan cleanup53 - Graceful process management with orphan cleanup
54 54
55+## HDR on Wayland
56+
57+HDR is enabled automatically for native Wayland (`DXVK_HDR=1`). It requires
58+**Wine >= 11.19**, an HDR-capable compositor with HDR enabled, and an HDR-capable
59+monitor/game. An existing `DXVK_HDR` environment variable is respected, including
60+an empty value. To disable HDR explicitly (also overriding `DXVK_HDR=1`):
61+
62+```bash
63+bnetctl launch --no-hdr
64+```
65+
66+X11 mode does not set `DXVK_HDR`. Games started by Battle.net inherit its HDR
67+policy; enable HDR in the game's own settings too.
68+
69+For Wayland `install` and `launch`, bnetctl prefers CachyOS Wine when it meets the
70+minimum, otherwise qualifying `wine` on PATH. If neither qualifies, it retains
71+the usual preference and warns. X11 selection is unchanged. `BNETCTL_WINE`
72+always wins, e.g. `BNETCTL_WINE=/usr/bin/wine`; companion `wineboot` and
73+`wineserver` must be beside its resolved binary.
74+
75+On the first launch of an existing prefix without a version marker, or after a
76+Wine version change, bnetctl runs `wineboot -u -r`, waits for Wine to exit, and
77+directly reinstalls and verifies DXVK/vkd3d-proton DLL symlinks and `native`
78+overrides. No setup scripts are executed. Package data is found beside the
79+resolved setup scripts, falling back to `/usr/share/dxvk` and
80+`/usr/share/vkd3d-proton` (including Arch's vkd3d wrapper).
81+
82+**Maintenance limitation:** `-r` skips Run keys and Startup, but Wine still
83+processes RunServicesOnce and HKLM RunOnce (`programs/wineboot/wineboot.c`,
84+lines 1931 and 1945; Run/Startup are gated at 1947–1952). Before maintenance,
85+bnetctl reads `pfx/system.reg` and `pfx/user.reg` and refuses if RunOnce or
86+RunServicesOnce, including Wow6432Node variants, contains any values. The error
87+lists the entries. bnetctl never deletes or disables these registry values.
88+Resolve pending one-time startup tasks yourself before retrying maintenance.
89+
90+The `prefix/wine-version` marker is committed only after verification. A
91+`prefix/wine-version.migrating` record is written before updating; failures
92+retain it and force a retry, even if the committed version already matches.
93+Both records establish a minimum Wine version: X11 and Wayland will use an
94+available runtime at least that new, or refuse. An older explicit `BNETCTL_WINE`
95+is rejected rather than silently overridden. Switching display modes never
96+downgrades a tracked prefix. Version probes (`wine --version`) may run during
97+selection; downgrade refusals run no prefix Wine commands.
98+
99+An exclusive `prefix/.bnetctl.lock` protects selection and maintenance until the
100+application process starts; concurrent invocations fail immediately. Close
101+Battle.net and all its games before switching Wine versions. The first update
102+may take longer. The default prefix is `~/.local/share/bnetctl/prefix`.
103+
104+Use `bnetctl --verbose launch --display-driver wayland` to see the selected Wine,
105+HDR policy, and any one-time prefix update on stderr. Logs are always saved to
106+`~/.cache/bnetctl/bnetctl.log` (or the corresponding XDG cache directory).
107+
55 ## Directories108 ## Directories
56 109
57 | Type | Path |110 | Type | Path |
M cmd/install.go +3 -0
@@ -58,6 +58,9 @@ func runInstall(cmd *cobra.Command, args []string) error {
5858 }
5959
6060 runtime := wine.NewAdapter(log)
61+ if driver == env.DisplayDriverWayland {
62+ runtime = wine.NewWaylandAdapter(log)
63+ }
6164 downloader := http.NewDownloader()
6265
6366 installer := services.NewInstallerService(runtime, downloader, fs, cfg, func() *domain.WineEnv {
@@ -58,6 +58,9 @@ func runInstall(cmd *cobra.Command, args []string) error {
58 }58 }
59 59
60 runtime := wine.NewAdapter(log)60 runtime := wine.NewAdapter(log)
61+ if driver == env.DisplayDriverWayland {
62+ runtime = wine.NewWaylandAdapter(log)
63+ }
61 downloader := http.NewDownloader()64 downloader := http.NewDownloader()
62 65
63 installer := services.NewInstallerService(runtime, downloader, fs, cfg, func() *domain.WineEnv {66 installer := services.NewInstallerService(runtime, downloader, fs, cfg, func() *domain.WineEnv {
M cmd/launch.go +8 -5
@@ -15,6 +15,7 @@ import (
1515 )
1616
1717 var displayDriver string
18+var noHDR bool
1819
1920 var launchCmd = &cobra.Command{
2021 Use: "launch",
@@ -25,6 +26,7 @@ var launchCmd = &cobra.Command{
2526
2627 func init() {
2728 registerDisplayDriverFlag(launchCmd, &displayDriver)
29+ launchCmd.Flags().BoolVar(&noHDR, "no-hdr", false, "Disable automatic Wayland HDR")
2830 rootCmd.AddCommand(launchCmd)
2931 }
3032
@@ -42,16 +44,17 @@ func runLaunch(cmd *cobra.Command, args []string) error {
4244 }
4345
4446 runtime := wine.NewAdapter(log)
47+ if driver == env.DisplayDriverWayland {
48+ runtime = wine.NewWaylandAdapter(log)
49+ }
4550 fs := xdg.NewFilesystem()
4651
47- // Display Wine version before launch (display-only; service re-detects internally)
48- if rt, err := runtime.Detect(); err == nil {
49- fmt.Println(styles.StepPrefix.Render("Wine: ") + rt.Version)
50- }
5152 fmt.Println(styles.StepPrefix.Render("Launching Battle.net..."))
5253
5354 launcher := services.NewLaunchService(runtime, fs, cfg, func() *domain.WineEnv {
54- return env.BuildGPUEnvForDisplay(driver)
55+ enabled, reason := env.HDRStatus(driver, noHDR)
56+ log.Info("hdr env", "enabled", enabled, "reason", reason)
57+ return env.BuildGPUEnvForDisplayHDR(driver, noHDR)
5558 }, log, env.BattleNetArgsForDisplay(driver)...)
5659
5760 log.Info("launching battle.net")
@@ -15,6 +15,7 @@ import (
15 )15 )
16 16
17 var displayDriver string17 var displayDriver string
18+var noHDR bool
18 19
19 var launchCmd = &cobra.Command{20 var launchCmd = &cobra.Command{
20 Use: "launch",21 Use: "launch",
@@ -25,6 +26,7 @@ var launchCmd = &cobra.Command{
25 26
26 func init() {27 func init() {
27 registerDisplayDriverFlag(launchCmd, &displayDriver)28 registerDisplayDriverFlag(launchCmd, &displayDriver)
29+ launchCmd.Flags().BoolVar(&noHDR, "no-hdr", false, "Disable automatic Wayland HDR")
28 rootCmd.AddCommand(launchCmd)30 rootCmd.AddCommand(launchCmd)
29 }31 }
30 32
@@ -42,16 +44,17 @@ func runLaunch(cmd *cobra.Command, args []string) error {
42 }44 }
43 45
44 runtime := wine.NewAdapter(log)46 runtime := wine.NewAdapter(log)
47+ if driver == env.DisplayDriverWayland {
48+ runtime = wine.NewWaylandAdapter(log)
49+ }
45 fs := xdg.NewFilesystem()50 fs := xdg.NewFilesystem()
46 51
47- // Display Wine version before launch (display-only; service re-detects internally)
48- if rt, err := runtime.Detect(); err == nil {
49- fmt.Println(styles.StepPrefix.Render("Wine: ") + rt.Version)
50- }
51 fmt.Println(styles.StepPrefix.Render("Launching Battle.net..."))52 fmt.Println(styles.StepPrefix.Render("Launching Battle.net..."))
52 53
53 launcher := services.NewLaunchService(runtime, fs, cfg, func() *domain.WineEnv {54 launcher := services.NewLaunchService(runtime, fs, cfg, func() *domain.WineEnv {
54- return env.BuildGPUEnvForDisplay(driver)55+ enabled, reason := env.HDRStatus(driver, noHDR)
56+ log.Info("hdr env", "enabled", enabled, "reason", reason)
57+ return env.BuildGPUEnvForDisplayHDR(driver, noHDR)
55 }, log, env.BattleNetArgsForDisplay(driver)...)58 }, log, env.BattleNetArgsForDisplay(driver)...)
56 59
57 log.Info("launching battle.net")60 log.Info("launching battle.net")
M internal/adapters/env/gpu.go +27 -0
@@ -89,6 +89,11 @@ func BattleNetArgsForDisplay(driver DisplayDriver) []string {
8989 }
9090
9191 func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {
92+ return BuildGPUEnvForDisplayHDR(driver, false)
93+}
94+
95+// BuildGPUEnvForDisplayHDR applies the launch HDR policy.
96+func BuildGPUEnvForDisplayHDR(driver DisplayDriver, noHDR bool) *domain.WineEnv {
9297 env := &domain.WineEnv{
9398 Vars: make(map[string]string),
9499 }
@@ -110,6 +115,11 @@ func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {
110115 if driver == DisplayDriverWayland {
111116 // Without an X11 display, Wine selects its native Wayland driver.
112117 env.Unset = append(env.Unset, "DISPLAY")
118+ if noHDR {
119+ env.Vars["DXVK_HDR"] = "0"
120+ } else if _, set := os.LookupEnv("DXVK_HDR"); !set {
121+ env.Vars["DXVK_HDR"] = "1"
122+ }
113123 }
114124
115125 // NOTE: do NOT set DISABLE_LSFGVK here. lsfg-vk is a GLOBAL Vulkan
@@ -135,3 +145,20 @@ func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {
135145
136146 return env
137147 }
148+
149+// HDRStatus explains the effective HDR launch policy for logging.
150+func HDRStatus(driver DisplayDriver, noHDR bool) (bool, string) {
151+ if driver == DisplayDriverX11 {
152+ if value, set := os.LookupEnv("DXVK_HDR"); set {
153+ return value == "1", "user env"
154+ }
155+ return false, "x11"
156+ }
157+ if noHDR {
158+ return false, "--no-hdr"
159+ }
160+ if value, set := os.LookupEnv("DXVK_HDR"); set {
161+ return value == "1", "user DXVK_HDR=" + value
162+ }
163+ return true, "wayland default"
164+}
@@ -89,6 +89,11 @@ func BattleNetArgsForDisplay(driver DisplayDriver) []string {
89 }89 }
90 90
91 func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {91 func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {
92+ return BuildGPUEnvForDisplayHDR(driver, false)
93+}
94+
95+// BuildGPUEnvForDisplayHDR applies the launch HDR policy.
96+func BuildGPUEnvForDisplayHDR(driver DisplayDriver, noHDR bool) *domain.WineEnv {
92 env := &domain.WineEnv{97 env := &domain.WineEnv{
93 Vars: make(map[string]string),98 Vars: make(map[string]string),
94 }99 }
@@ -110,6 +115,11 @@ func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {
110 if driver == DisplayDriverWayland {115 if driver == DisplayDriverWayland {
111 // Without an X11 display, Wine selects its native Wayland driver.116 // Without an X11 display, Wine selects its native Wayland driver.
112 env.Unset = append(env.Unset, "DISPLAY")117 env.Unset = append(env.Unset, "DISPLAY")
118+ if noHDR {
119+ env.Vars["DXVK_HDR"] = "0"
120+ } else if _, set := os.LookupEnv("DXVK_HDR"); !set {
121+ env.Vars["DXVK_HDR"] = "1"
122+ }
113 }123 }
114 124
115 // NOTE: do NOT set DISABLE_LSFGVK here. lsfg-vk is a GLOBAL Vulkan125 // NOTE: do NOT set DISABLE_LSFGVK here. lsfg-vk is a GLOBAL Vulkan
@@ -135,3 +145,20 @@ func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv {
135 145
136 return env146 return env
137 }147 }
148+
149+// HDRStatus explains the effective HDR launch policy for logging.
150+func HDRStatus(driver DisplayDriver, noHDR bool) (bool, string) {
151+ if driver == DisplayDriverX11 {
152+ if value, set := os.LookupEnv("DXVK_HDR"); set {
153+ return value == "1", "user env"
154+ }
155+ return false, "x11"
156+ }
157+ if noHDR {
158+ return false, "--no-hdr"
159+ }
160+ if value, set := os.LookupEnv("DXVK_HDR"); set {
161+ return value == "1", "user DXVK_HDR=" + value
162+ }
163+ return true, "wayland default"
164+}
A internal/adapters/env/hdr_test.go +53 -0
@@ -0,0 +1,53 @@
1+package env
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestHDRPolicy(t *testing.T) {
9+ for _, tt := range []struct {
10+ name string
11+ driver DisplayDriver
12+ noHDR bool
13+ user *string
14+ want, reason string
15+ enabled bool
16+ }{
17+ {"default", DisplayDriverWayland, false, nil, "1", "wayland default", true},
18+ {"user on", DisplayDriverWayland, false, str("1"), "", "user DXVK_HDR=1", true},
19+ {"user off", DisplayDriverWayland, false, str("0"), "", "user DXVK_HDR=0", false},
20+ {"user empty", DisplayDriverWayland, false, str(""), "", "user DXVK_HDR=", false},
21+ {"opt out", DisplayDriverWayland, true, str("1"), "0", "--no-hdr", false},
22+ {"x11", DisplayDriverX11, false, nil, "", "x11", false},
23+ {"x11 inherited on", DisplayDriverX11, false, str("1"), "", "user env", true},
24+ {"x11 inherited off", DisplayDriverX11, false, str("0"), "", "user env", false},
25+ {"x11 inherited empty", DisplayDriverX11, false, str(""), "", "user env", false},
26+ {"x11 ignores opt out", DisplayDriverX11, true, str("1"), "", "user env", true},
27+ } {
28+ t.Run(tt.name, func(t *testing.T) {
29+ t.Setenv("DXVK_HDR", "")
30+ if tt.user == nil {
31+ if err := os.Unsetenv("DXVK_HDR"); err != nil {
32+ t.Fatal(err)
33+ }
34+ } else {
35+ t.Setenv("DXVK_HDR", *tt.user)
36+ }
37+ got := BuildGPUEnvForDisplayHDR(tt.driver, tt.noHDR)
38+ if got.Vars["DXVK_HDR"] != tt.want {
39+ t.Fatalf("env: %v", got.Vars)
40+ }
41+ if tt.want == "" {
42+ if _, ok := got.Vars["DXVK_HDR"]; ok {
43+ t.Fatal("unexpected HDR assignment")
44+ }
45+ }
46+ enabled, reason := HDRStatus(tt.driver, tt.noHDR)
47+ if enabled != tt.enabled || reason != tt.reason {
48+ t.Fatalf("status: %v %q", enabled, reason)
49+ }
50+ })
51+ }
52+}
53+func str(s string) *string { return &s }
@@ -0,0 +1,53 @@
1+package env
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestHDRPolicy(t *testing.T) {
9+ for _, tt := range []struct {
10+ name string
11+ driver DisplayDriver
12+ noHDR bool
13+ user *string
14+ want, reason string
15+ enabled bool
16+ }{
17+ {"default", DisplayDriverWayland, false, nil, "1", "wayland default", true},
18+ {"user on", DisplayDriverWayland, false, str("1"), "", "user DXVK_HDR=1", true},
19+ {"user off", DisplayDriverWayland, false, str("0"), "", "user DXVK_HDR=0", false},
20+ {"user empty", DisplayDriverWayland, false, str(""), "", "user DXVK_HDR=", false},
21+ {"opt out", DisplayDriverWayland, true, str("1"), "0", "--no-hdr", false},
22+ {"x11", DisplayDriverX11, false, nil, "", "x11", false},
23+ {"x11 inherited on", DisplayDriverX11, false, str("1"), "", "user env", true},
24+ {"x11 inherited off", DisplayDriverX11, false, str("0"), "", "user env", false},
25+ {"x11 inherited empty", DisplayDriverX11, false, str(""), "", "user env", false},
26+ {"x11 ignores opt out", DisplayDriverX11, true, str("1"), "", "user env", true},
27+ } {
28+ t.Run(tt.name, func(t *testing.T) {
29+ t.Setenv("DXVK_HDR", "")
30+ if tt.user == nil {
31+ if err := os.Unsetenv("DXVK_HDR"); err != nil {
32+ t.Fatal(err)
33+ }
34+ } else {
35+ t.Setenv("DXVK_HDR", *tt.user)
36+ }
37+ got := BuildGPUEnvForDisplayHDR(tt.driver, tt.noHDR)
38+ if got.Vars["DXVK_HDR"] != tt.want {
39+ t.Fatalf("env: %v", got.Vars)
40+ }
41+ if tt.want == "" {
42+ if _, ok := got.Vars["DXVK_HDR"]; ok {
43+ t.Fatal("unexpected HDR assignment")
44+ }
45+ }
46+ enabled, reason := HDRStatus(tt.driver, tt.noHDR)
47+ if enabled != tt.enabled || reason != tt.reason {
48+ t.Fatalf("status: %v %q", enabled, reason)
49+ }
50+ })
51+ }
52+}
53+func str(s string) *string { return &s }
A internal/adapters/wine/graphics.go +177 -0
@@ -0,0 +1,177 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/bnema/bnetctl/internal/domain"
11+)
12+
13+type graphicsDLL struct{ source, destination, name string }
14+
15+// Discover package data beside the resolved setup script. Arch's vkd3d command
16+// is a wrapper, so its package share directory is the fallback. Never run it.
17+func graphicsBase(script, fallback string) string {
18+ resolved, err := filepath.EvalSymlinks(script)
19+ if err == nil {
20+ base := filepath.Dir(resolved)
21+ if info, err := os.Stat(filepath.Join(base, "x64")); err == nil && info.IsDir() {
22+ return base
23+ }
24+ }
25+ return fallback
26+}
27+func graphicsPlan(rt *domain.WineRuntime, pfx string) ([]graphicsDLL, error) {
28+ prefixArch, err := readPrefixArch(pfx)
29+ if err != nil {
30+ return nil, err
31+ }
32+ var result []graphicsDLL
33+ for _, pkg := range []struct {
34+ base, lib32 string
35+ names []string
36+ }{
37+ {graphicsBase(rt.DXVKSetupBin, "/usr/share/dxvk"), "x32", []string{"dxgi", "d3d8", "d3d9", "d3d10core", "d3d11"}},
38+ {graphicsBase(rt.VKD3DSetupBin, "/usr/share/vkd3d-proton"), "x86", []string{"d3d12", "d3d12core"}},
39+ } {
40+ arches := []struct{ lib, dir string }{{"x64", "system32"}, {pkg.lib32, "syswow64"}}
41+ if prefixArch == "win32" {
42+ arches = []struct{ lib, dir string }{{pkg.lib32, "system32"}}
43+ }
44+ for _, arch := range arches {
45+ for _, name := range pkg.names {
46+ src := filepath.Join(pkg.base, arch.lib, name+".dll")
47+ info, err := os.Stat(src)
48+ if name == "d3d8" && os.IsNotExist(err) {
49+ continue
50+ }
51+ if err != nil {
52+ return nil, fmt.Errorf("graphics DLL %s: %w", src, err)
53+ }
54+ if !info.Mode().IsRegular() {
55+ return nil, fmt.Errorf("not a graphics DLL file: %s", src)
56+ }
57+ result = append(result, graphicsDLL{src, filepath.Join(pfx, "drive_c", "windows", arch.dir, name+".dll"), name})
58+ }
59+ }
60+ }
61+ return result, nil
62+}
63+
64+const overridesKey = `HKCU\Software\Wine\DllOverrides`
65+
66+func (a *Adapter) installGraphics(rt *domain.WineRuntime, env map[string]string) error {
67+ plan, err := graphicsPlan(rt, env["WINEPREFIX"])
68+ if err != nil {
69+ return err
70+ }
71+ for _, dll := range plan {
72+ if err := os.MkdirAll(filepath.Dir(dll.destination), 0755); err != nil {
73+ return err
74+ }
75+ if _, err := os.Lstat(dll.destination); err == nil {
76+ // Preserve originals just as the package scripts do.
77+ if _, err := os.Lstat(dll.destination + ".old"); os.IsNotExist(err) {
78+ if err := os.Rename(dll.destination, dll.destination+".old"); err != nil {
79+ return err
80+ }
81+ } else if err := os.Remove(dll.destination); err != nil {
82+ return err
83+ }
84+ } else if !os.IsNotExist(err) {
85+ return err
86+ }
87+ if err := os.Symlink(dll.source, dll.destination); err != nil {
88+ return err
89+ }
90+ }
91+ seen := map[string]bool{}
92+ for _, dll := range plan {
93+ if seen[dll.name] {
94+ continue
95+ }
96+ seen[dll.name] = true
97+ if err := runPrefixCommand(rt.WineBin, env, "reg", "add", overridesKey, "/v", dll.name, "/t", "REG_SZ", "/d", "native", "/f"); err != nil {
98+ return err
99+ }
100+ }
101+ if err := verifyGraphics(rt, env, plan); err != nil {
102+ return err
103+ }
104+ return runPrefixCommand(rt.WineServerBin, env, "-w")
105+}
106+func verifyGraphics(rt *domain.WineRuntime, env map[string]string, plan []graphicsDLL) error {
107+ seen := map[string]bool{}
108+ for _, dll := range plan {
109+ target, err := os.Readlink(dll.destination)
110+ if err != nil {
111+ return fmt.Errorf("verify graphics symlink %s: %w", dll.destination, err)
112+ }
113+ if target != dll.source {
114+ return fmt.Errorf("unexpected graphics symlink target: %s -> %s", dll.destination, target)
115+ }
116+ if _, err := os.Stat(dll.destination); err != nil {
117+ return fmt.Errorf("broken graphics symlink %s: %w", dll.destination, err)
118+ }
119+ if seen[dll.name] {
120+ continue
121+ }
122+ seen[dll.name] = true
123+ cmd := exec.Command(rt.WineBin, "reg", "query", overridesKey, "/v", dll.name)
124+ cmd.Env = envMapToSlice(env)
125+ out, err := cmd.CombinedOutput()
126+ if err != nil {
127+ return fmt.Errorf("verify native override %s: %w (%s)", dll.name, err, out)
128+ }
129+ found := false
130+ for _, line := range strings.Split(string(out), "\n") {
131+ fields := strings.Fields(line)
132+ if len(fields) == 3 && strings.EqualFold(fields[0], dll.name) && fields[1] == "REG_SZ" && fields[2] == "native" {
133+ found = true
134+ }
135+ }
136+ if !found {
137+ return fmt.Errorf("native override not verified for %s: %s", dll.name, out)
138+ }
139+ }
140+ // Registry commands must not have removed DLLs verified earlier in the loop.
141+ for _, dll := range plan {
142+ target, err := os.Readlink(dll.destination)
143+ if err != nil {
144+ return fmt.Errorf("verify graphics symlink %s: %w", dll.destination, err)
145+ }
146+ if target != dll.source {
147+ return fmt.Errorf("unexpected graphics symlink target: %s", dll.destination)
148+ }
149+ if _, err := os.Stat(dll.destination); err != nil {
150+ return fmt.Errorf("broken graphics symlink %s: %w", dll.destination, err)
151+ }
152+ }
153+ return nil
154+}
155+
156+// Existing prefixes declare their architecture in Wine's registry export header.
157+func readPrefixArch(pfx string) (string, error) {
158+ data, err := os.ReadFile(filepath.Join(pfx, "system.reg"))
159+ if err != nil {
160+ return "", fmt.Errorf("read prefix architecture: %w", err)
161+ }
162+ arch := ""
163+ for _, line := range strings.Split(string(data), "\n") {
164+ line = strings.TrimSpace(line)
165+ if strings.HasPrefix(line, "#arch=") {
166+ value := strings.TrimPrefix(line, "#arch=")
167+ if arch != "" && arch != value {
168+ return "", fmt.Errorf("conflicting prefix architecture")
169+ }
170+ arch = value
171+ }
172+ }
173+ if arch != "win64" && arch != "win32" {
174+ return "", fmt.Errorf("unknown prefix architecture %q in system.reg", arch)
175+ }
176+ return arch, nil
177+}
@@ -0,0 +1,177 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/bnema/bnetctl/internal/domain"
11+)
12+
13+type graphicsDLL struct{ source, destination, name string }
14+
15+// Discover package data beside the resolved setup script. Arch's vkd3d command
16+// is a wrapper, so its package share directory is the fallback. Never run it.
17+func graphicsBase(script, fallback string) string {
18+ resolved, err := filepath.EvalSymlinks(script)
19+ if err == nil {
20+ base := filepath.Dir(resolved)
21+ if info, err := os.Stat(filepath.Join(base, "x64")); err == nil && info.IsDir() {
22+ return base
23+ }
24+ }
25+ return fallback
26+}
27+func graphicsPlan(rt *domain.WineRuntime, pfx string) ([]graphicsDLL, error) {
28+ prefixArch, err := readPrefixArch(pfx)
29+ if err != nil {
30+ return nil, err
31+ }
32+ var result []graphicsDLL
33+ for _, pkg := range []struct {
34+ base, lib32 string
35+ names []string
36+ }{
37+ {graphicsBase(rt.DXVKSetupBin, "/usr/share/dxvk"), "x32", []string{"dxgi", "d3d8", "d3d9", "d3d10core", "d3d11"}},
38+ {graphicsBase(rt.VKD3DSetupBin, "/usr/share/vkd3d-proton"), "x86", []string{"d3d12", "d3d12core"}},
39+ } {
40+ arches := []struct{ lib, dir string }{{"x64", "system32"}, {pkg.lib32, "syswow64"}}
41+ if prefixArch == "win32" {
42+ arches = []struct{ lib, dir string }{{pkg.lib32, "system32"}}
43+ }
44+ for _, arch := range arches {
45+ for _, name := range pkg.names {
46+ src := filepath.Join(pkg.base, arch.lib, name+".dll")
47+ info, err := os.Stat(src)
48+ if name == "d3d8" && os.IsNotExist(err) {
49+ continue
50+ }
51+ if err != nil {
52+ return nil, fmt.Errorf("graphics DLL %s: %w", src, err)
53+ }
54+ if !info.Mode().IsRegular() {
55+ return nil, fmt.Errorf("not a graphics DLL file: %s", src)
56+ }
57+ result = append(result, graphicsDLL{src, filepath.Join(pfx, "drive_c", "windows", arch.dir, name+".dll"), name})
58+ }
59+ }
60+ }
61+ return result, nil
62+}
63+
64+const overridesKey = `HKCU\Software\Wine\DllOverrides`
65+
66+func (a *Adapter) installGraphics(rt *domain.WineRuntime, env map[string]string) error {
67+ plan, err := graphicsPlan(rt, env["WINEPREFIX"])
68+ if err != nil {
69+ return err
70+ }
71+ for _, dll := range plan {
72+ if err := os.MkdirAll(filepath.Dir(dll.destination), 0755); err != nil {
73+ return err
74+ }
75+ if _, err := os.Lstat(dll.destination); err == nil {
76+ // Preserve originals just as the package scripts do.
77+ if _, err := os.Lstat(dll.destination + ".old"); os.IsNotExist(err) {
78+ if err := os.Rename(dll.destination, dll.destination+".old"); err != nil {
79+ return err
80+ }
81+ } else if err := os.Remove(dll.destination); err != nil {
82+ return err
83+ }
84+ } else if !os.IsNotExist(err) {
85+ return err
86+ }
87+ if err := os.Symlink(dll.source, dll.destination); err != nil {
88+ return err
89+ }
90+ }
91+ seen := map[string]bool{}
92+ for _, dll := range plan {
93+ if seen[dll.name] {
94+ continue
95+ }
96+ seen[dll.name] = true
97+ if err := runPrefixCommand(rt.WineBin, env, "reg", "add", overridesKey, "/v", dll.name, "/t", "REG_SZ", "/d", "native", "/f"); err != nil {
98+ return err
99+ }
100+ }
101+ if err := verifyGraphics(rt, env, plan); err != nil {
102+ return err
103+ }
104+ return runPrefixCommand(rt.WineServerBin, env, "-w")
105+}
106+func verifyGraphics(rt *domain.WineRuntime, env map[string]string, plan []graphicsDLL) error {
107+ seen := map[string]bool{}
108+ for _, dll := range plan {
109+ target, err := os.Readlink(dll.destination)
110+ if err != nil {
111+ return fmt.Errorf("verify graphics symlink %s: %w", dll.destination, err)
112+ }
113+ if target != dll.source {
114+ return fmt.Errorf("unexpected graphics symlink target: %s -> %s", dll.destination, target)
115+ }
116+ if _, err := os.Stat(dll.destination); err != nil {
117+ return fmt.Errorf("broken graphics symlink %s: %w", dll.destination, err)
118+ }
119+ if seen[dll.name] {
120+ continue
121+ }
122+ seen[dll.name] = true
123+ cmd := exec.Command(rt.WineBin, "reg", "query", overridesKey, "/v", dll.name)
124+ cmd.Env = envMapToSlice(env)
125+ out, err := cmd.CombinedOutput()
126+ if err != nil {
127+ return fmt.Errorf("verify native override %s: %w (%s)", dll.name, err, out)
128+ }
129+ found := false
130+ for _, line := range strings.Split(string(out), "\n") {
131+ fields := strings.Fields(line)
132+ if len(fields) == 3 && strings.EqualFold(fields[0], dll.name) && fields[1] == "REG_SZ" && fields[2] == "native" {
133+ found = true
134+ }
135+ }
136+ if !found {
137+ return fmt.Errorf("native override not verified for %s: %s", dll.name, out)
138+ }
139+ }
140+ // Registry commands must not have removed DLLs verified earlier in the loop.
141+ for _, dll := range plan {
142+ target, err := os.Readlink(dll.destination)
143+ if err != nil {
144+ return fmt.Errorf("verify graphics symlink %s: %w", dll.destination, err)
145+ }
146+ if target != dll.source {
147+ return fmt.Errorf("unexpected graphics symlink target: %s", dll.destination)
148+ }
149+ if _, err := os.Stat(dll.destination); err != nil {
150+ return fmt.Errorf("broken graphics symlink %s: %w", dll.destination, err)
151+ }
152+ }
153+ return nil
154+}
155+
156+// Existing prefixes declare their architecture in Wine's registry export header.
157+func readPrefixArch(pfx string) (string, error) {
158+ data, err := os.ReadFile(filepath.Join(pfx, "system.reg"))
159+ if err != nil {
160+ return "", fmt.Errorf("read prefix architecture: %w", err)
161+ }
162+ arch := ""
163+ for _, line := range strings.Split(string(data), "\n") {
164+ line = strings.TrimSpace(line)
165+ if strings.HasPrefix(line, "#arch=") {
166+ value := strings.TrimPrefix(line, "#arch=")
167+ if arch != "" && arch != value {
168+ return "", fmt.Errorf("conflicting prefix architecture")
169+ }
170+ arch = value
171+ }
172+ }
173+ if arch != "win64" && arch != "win32" {
174+ return "", fmt.Errorf("unknown prefix architecture %q in system.reg", arch)
175+ }
176+ return arch, nil
177+}
A internal/adapters/wine/graphics_test.go +97 -0
@@ -0,0 +1,97 @@
1+package wine
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestGraphicsPrefixArchitecture(t *testing.T) {
11+ for _, arch := range []string{"win32", "win64", "unknown", ""} {
12+ t.Run(arch, func(t *testing.T) {
13+ a, prefix, calls := migrationFixture(t)
14+ pfx := filepath.Join(prefix, "pfx")
15+ mustWrite(t, filepath.Join(pfx, "system.reg"), "WINE REGISTRY Version 2\n#arch="+arch+"\n")
16+ plan, err := graphicsPlan(a.detected, pfx)
17+ if arch == "unknown" || arch == "" {
18+ if err == nil {
19+ t.Fatal("accepted unknown arch")
20+ }
21+ if err := a.UpdatePrefix(prefix, nil); err == nil {
22+ t.Fatal("migration accepted unknown arch")
23+ }
24+ for _, path := range []string{calls, filepath.Join(prefix, prefixVersionFile+".migrating")} {
25+ if _, err := os.Stat(path); !os.IsNotExist(err) {
26+ t.Fatalf("unknown arch mutated prefix: %s", path)
27+ }
28+ }
29+ return
30+ }
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ want := 14
35+ if arch == "win32" {
36+ want = 7
37+ }
38+ if len(plan) != want {
39+ t.Fatalf("got %d DLLs, want %d", len(plan), want)
40+ }
41+ for _, dll := range plan {
42+ if arch == "win32" {
43+ if filepath.Base(filepath.Dir(dll.destination)) != "system32" || strings.Contains(dll.source, "/x64/") {
44+ t.Fatalf("wrong win32 mapping: %+v", dll)
45+ }
46+ } else if filepath.Base(filepath.Dir(dll.destination)) == "system32" && !strings.Contains(dll.source, "/x64/") {
47+ t.Fatalf("wrong win64 mapping: %+v", dll)
48+ }
49+ }
50+ if err := a.UpdatePrefix(prefix, nil); err != nil {
51+ t.Fatal(err)
52+ }
53+ if arch == "win32" {
54+ if _, err := os.Stat(filepath.Join(pfx, "drive_c/windows/syswow64")); !os.IsNotExist(err) {
55+ t.Fatal("win32 created syswow64")
56+ }
57+ }
58+ })
59+ }
60+}
61+func TestOptionalD3D8(t *testing.T) {
62+ a, prefix, calls := migrationFixture(t)
63+ base := filepath.Dir(a.detected.DXVKSetupBin)
64+ for _, arch := range []string{"x32", "x64"} {
65+ if err := os.Remove(filepath.Join(base, arch, "d3d8.dll")); err != nil {
66+ t.Fatal(err)
67+ }
68+ }
69+ if err := a.UpdatePrefix(prefix, nil); err != nil {
70+ t.Fatal(err)
71+ }
72+ data, err := os.ReadFile(calls)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ if strings.Contains(string(data), "d3d8") {
77+ t.Fatalf("optional DLL registered or queried: %s", data)
78+ }
79+ for _, dir := range []string{"system32", "syswow64"} {
80+ if _, err := os.Lstat(filepath.Join(prefix, "pfx/drive_c/windows", dir, "d3d8.dll")); !os.IsNotExist(err) {
81+ t.Fatal("optional DLL installed")
82+ }
83+ }
84+}
85+func TestOptionalD3D8OtherErrorsFail(t *testing.T) {
86+ a, prefix, _ := migrationFixture(t)
87+ src := filepath.Join(filepath.Dir(a.detected.DXVKSetupBin), "x64/d3d8.dll")
88+ if err := os.Remove(src); err != nil {
89+ t.Fatal(err)
90+ }
91+ if err := os.Symlink(src, src); err != nil {
92+ t.Fatal(err)
93+ }
94+ if _, err := graphicsPlan(a.detected, filepath.Join(prefix, "pfx")); err == nil {
95+ t.Fatal("symlink loop incorrectly treated as optional absence")
96+ }
97+}
@@ -0,0 +1,97 @@
1+package wine
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestGraphicsPrefixArchitecture(t *testing.T) {
11+ for _, arch := range []string{"win32", "win64", "unknown", ""} {
12+ t.Run(arch, func(t *testing.T) {
13+ a, prefix, calls := migrationFixture(t)
14+ pfx := filepath.Join(prefix, "pfx")
15+ mustWrite(t, filepath.Join(pfx, "system.reg"), "WINE REGISTRY Version 2\n#arch="+arch+"\n")
16+ plan, err := graphicsPlan(a.detected, pfx)
17+ if arch == "unknown" || arch == "" {
18+ if err == nil {
19+ t.Fatal("accepted unknown arch")
20+ }
21+ if err := a.UpdatePrefix(prefix, nil); err == nil {
22+ t.Fatal("migration accepted unknown arch")
23+ }
24+ for _, path := range []string{calls, filepath.Join(prefix, prefixVersionFile+".migrating")} {
25+ if _, err := os.Stat(path); !os.IsNotExist(err) {
26+ t.Fatalf("unknown arch mutated prefix: %s", path)
27+ }
28+ }
29+ return
30+ }
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ want := 14
35+ if arch == "win32" {
36+ want = 7
37+ }
38+ if len(plan) != want {
39+ t.Fatalf("got %d DLLs, want %d", len(plan), want)
40+ }
41+ for _, dll := range plan {
42+ if arch == "win32" {
43+ if filepath.Base(filepath.Dir(dll.destination)) != "system32" || strings.Contains(dll.source, "/x64/") {
44+ t.Fatalf("wrong win32 mapping: %+v", dll)
45+ }
46+ } else if filepath.Base(filepath.Dir(dll.destination)) == "system32" && !strings.Contains(dll.source, "/x64/") {
47+ t.Fatalf("wrong win64 mapping: %+v", dll)
48+ }
49+ }
50+ if err := a.UpdatePrefix(prefix, nil); err != nil {
51+ t.Fatal(err)
52+ }
53+ if arch == "win32" {
54+ if _, err := os.Stat(filepath.Join(pfx, "drive_c/windows/syswow64")); !os.IsNotExist(err) {
55+ t.Fatal("win32 created syswow64")
56+ }
57+ }
58+ })
59+ }
60+}
61+func TestOptionalD3D8(t *testing.T) {
62+ a, prefix, calls := migrationFixture(t)
63+ base := filepath.Dir(a.detected.DXVKSetupBin)
64+ for _, arch := range []string{"x32", "x64"} {
65+ if err := os.Remove(filepath.Join(base, arch, "d3d8.dll")); err != nil {
66+ t.Fatal(err)
67+ }
68+ }
69+ if err := a.UpdatePrefix(prefix, nil); err != nil {
70+ t.Fatal(err)
71+ }
72+ data, err := os.ReadFile(calls)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ if strings.Contains(string(data), "d3d8") {
77+ t.Fatalf("optional DLL registered or queried: %s", data)
78+ }
79+ for _, dir := range []string{"system32", "syswow64"} {
80+ if _, err := os.Lstat(filepath.Join(prefix, "pfx/drive_c/windows", dir, "d3d8.dll")); !os.IsNotExist(err) {
81+ t.Fatal("optional DLL installed")
82+ }
83+ }
84+}
85+func TestOptionalD3D8OtherErrorsFail(t *testing.T) {
86+ a, prefix, _ := migrationFixture(t)
87+ src := filepath.Join(filepath.Dir(a.detected.DXVKSetupBin), "x64/d3d8.dll")
88+ if err := os.Remove(src); err != nil {
89+ t.Fatal(err)
90+ }
91+ if err := os.Symlink(src, src); err != nil {
92+ t.Fatal(err)
93+ }
94+ if _, err := graphicsPlan(a.detected, filepath.Join(prefix, "pfx")); err == nil {
95+ t.Fatal("symlink loop incorrectly treated as optional absence")
96+ }
97+}
A internal/adapters/wine/prefix.go +116 -0
@@ -0,0 +1,116 @@
1+package wine
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/bnetctl/internal/domain"
12+)
13+
14+const prefixVersionFile = "wine-version"
15+
16+func writePrefixVersion(prefix, version string) error {
17+ return writeVersionRecord(prefix, prefixVersionFile, version)
18+}
19+func writeVersionRecord(prefix, name, version string) error {
20+ path := filepath.Join(prefix, name)
21+ f, err := os.CreateTemp(prefix, ".wine-version-*")
22+ if err != nil {
23+ return err
24+ }
25+ temp := f.Name()
26+ defer func() { _ = os.Remove(temp) }()
27+ if _, err := f.WriteString(version + "\n"); err != nil {
28+ _ = f.Close()
29+ return err
30+ }
31+ if err := f.Sync(); err != nil {
32+ _ = f.Close()
33+ return err
34+ }
35+ if err := f.Close(); err != nil {
36+ return err
37+ }
38+ if err := os.Rename(temp, path); err != nil {
39+ return err
40+ }
41+ dir, err := os.Open(prefix)
42+ if err != nil {
43+ return err
44+ }
45+ defer func() { _ = dir.Close() }()
46+ return dir.Sync()
47+}
48+
49+// UpdatePrefix updates an idle existing prefix before any application is started.
50+func (a *Adapter) UpdatePrefix(prefix string, wineEnv *domain.WineEnv) error {
51+ rt, err := a.Detect()
52+ if err != nil {
53+ return err
54+ }
55+ old, err := os.ReadFile(filepath.Join(prefix, prefixVersionFile))
56+ if err != nil && !errors.Is(err, os.ErrNotExist) {
57+ return fmt.Errorf("read Wine version marker: %w", err)
58+ }
59+ from := strings.TrimSpace(string(old))
60+ pending, err := readVersion(filepath.Join(prefix, prefixVersionFile+".migrating"))
61+ if err != nil {
62+ return err
63+ }
64+ for _, minimum := range []string{from, pending} {
65+ if minimum != "" {
66+ cmp, err := compareVersions(rt.Version, minimum)
67+ if err != nil {
68+ return err
69+ }
70+ if cmp < 0 {
71+ return fmt.Errorf("refusing Wine downgrade: selected %s; prefix requires >= %s", rt.Version, minimum)
72+ }
73+ }
74+ }
75+ if from == rt.Version && pending == "" {
76+ return nil
77+ }
78+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
79+ return err
80+ }
81+ if _, err := graphicsPlan(rt, filepath.Join(prefix, "pfx")); err != nil {
82+ return err
83+ }
84+ if a.IsProcessRunning(prefix) {
85+ return fmt.Errorf("cannot update Wine prefix while it is running")
86+ }
87+ if err := writeVersionRecord(prefix, prefixVersionFile+".migrating", rt.Version); err != nil {
88+ return err
89+ }
90+ a.log.Info("wine version changed, updating prefix", "from", from, "to", rt.Version)
91+ env := a.buildEnv(filepath.Join(prefix, "pfx"), wineEnv)
92+ for _, step := range []struct {
93+ bin string
94+ args []string
95+ }{{rt.WineBootBin, []string{"-u", "-r"}}, {rt.WineServerBin, []string{"-w"}}} {
96+ if err := runPrefixCommand(step.bin, env, step.args...); err != nil {
97+ return fmt.Errorf("update Wine prefix: %w", err)
98+ }
99+ }
100+ if err := a.installGraphics(rt, env); err != nil {
101+ return fmt.Errorf("restore prefix graphics: %w", err)
102+ }
103+ if err := writePrefixVersion(prefix, rt.Version); err != nil {
104+ return err
105+ }
106+ return os.Remove(filepath.Join(prefix, prefixVersionFile+".migrating"))
107+}
108+
109+func runPrefixCommand(bin string, env map[string]string, args ...string) error {
110+ cmd := exec.Command(bin, args...)
111+ cmd.Env = envMapToSlice(env)
112+ if out, err := cmd.CombinedOutput(); err != nil {
113+ return fmt.Errorf("%s %s: %w (%s)", bin, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
114+ }
115+ return nil
116+}
@@ -0,0 +1,116 @@
1+package wine
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/bnetctl/internal/domain"
12+)
13+
14+const prefixVersionFile = "wine-version"
15+
16+func writePrefixVersion(prefix, version string) error {
17+ return writeVersionRecord(prefix, prefixVersionFile, version)
18+}
19+func writeVersionRecord(prefix, name, version string) error {
20+ path := filepath.Join(prefix, name)
21+ f, err := os.CreateTemp(prefix, ".wine-version-*")
22+ if err != nil {
23+ return err
24+ }
25+ temp := f.Name()
26+ defer func() { _ = os.Remove(temp) }()
27+ if _, err := f.WriteString(version + "\n"); err != nil {
28+ _ = f.Close()
29+ return err
30+ }
31+ if err := f.Sync(); err != nil {
32+ _ = f.Close()
33+ return err
34+ }
35+ if err := f.Close(); err != nil {
36+ return err
37+ }
38+ if err := os.Rename(temp, path); err != nil {
39+ return err
40+ }
41+ dir, err := os.Open(prefix)
42+ if err != nil {
43+ return err
44+ }
45+ defer func() { _ = dir.Close() }()
46+ return dir.Sync()
47+}
48+
49+// UpdatePrefix updates an idle existing prefix before any application is started.
50+func (a *Adapter) UpdatePrefix(prefix string, wineEnv *domain.WineEnv) error {
51+ rt, err := a.Detect()
52+ if err != nil {
53+ return err
54+ }
55+ old, err := os.ReadFile(filepath.Join(prefix, prefixVersionFile))
56+ if err != nil && !errors.Is(err, os.ErrNotExist) {
57+ return fmt.Errorf("read Wine version marker: %w", err)
58+ }
59+ from := strings.TrimSpace(string(old))
60+ pending, err := readVersion(filepath.Join(prefix, prefixVersionFile+".migrating"))
61+ if err != nil {
62+ return err
63+ }
64+ for _, minimum := range []string{from, pending} {
65+ if minimum != "" {
66+ cmp, err := compareVersions(rt.Version, minimum)
67+ if err != nil {
68+ return err
69+ }
70+ if cmp < 0 {
71+ return fmt.Errorf("refusing Wine downgrade: selected %s; prefix requires >= %s", rt.Version, minimum)
72+ }
73+ }
74+ }
75+ if from == rt.Version && pending == "" {
76+ return nil
77+ }
78+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
79+ return err
80+ }
81+ if _, err := graphicsPlan(rt, filepath.Join(prefix, "pfx")); err != nil {
82+ return err
83+ }
84+ if a.IsProcessRunning(prefix) {
85+ return fmt.Errorf("cannot update Wine prefix while it is running")
86+ }
87+ if err := writeVersionRecord(prefix, prefixVersionFile+".migrating", rt.Version); err != nil {
88+ return err
89+ }
90+ a.log.Info("wine version changed, updating prefix", "from", from, "to", rt.Version)
91+ env := a.buildEnv(filepath.Join(prefix, "pfx"), wineEnv)
92+ for _, step := range []struct {
93+ bin string
94+ args []string
95+ }{{rt.WineBootBin, []string{"-u", "-r"}}, {rt.WineServerBin, []string{"-w"}}} {
96+ if err := runPrefixCommand(step.bin, env, step.args...); err != nil {
97+ return fmt.Errorf("update Wine prefix: %w", err)
98+ }
99+ }
100+ if err := a.installGraphics(rt, env); err != nil {
101+ return fmt.Errorf("restore prefix graphics: %w", err)
102+ }
103+ if err := writePrefixVersion(prefix, rt.Version); err != nil {
104+ return err
105+ }
106+ return os.Remove(filepath.Join(prefix, prefixVersionFile+".migrating"))
107+}
108+
109+func runPrefixCommand(bin string, env map[string]string, args ...string) error {
110+ cmd := exec.Command(bin, args...)
111+ cmd.Env = envMapToSlice(env)
112+ if out, err := cmd.CombinedOutput(); err != nil {
113+ return fmt.Errorf("%s %s: %w (%s)", bin, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
114+ }
115+ return nil
116+}
A internal/adapters/wine/prefix_test.go +143 -0
@@ -0,0 +1,143 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/bnetctl/internal/domain"
12+ "github.com/charmbracelet/log"
13+)
14+
15+func migrationFixture(t *testing.T) (*Adapter, string, string) {
16+ t.Helper()
17+ dir := t.TempDir()
18+ prefix := filepath.Join(dir, "prefix")
19+ mustWrite(t, filepath.Join(prefix, "pfx", "system.reg"), "WINE REGISTRY Version 2\n#arch=win64\n")
20+ calls := filepath.Join(dir, "calls")
21+ script := fmt.Sprintf(`#!/bin/sh
22+if [ "$1" = '-k0' ]; then exit 1; fi
23+echo "$(basename "$0") $* HDR=$DXVK_HDR DISPLAY=${DISPLAY-unset}" >> '%s'
24+if [ "$1" = 'reg' ] && [ "$2" = 'query' ]; then echo "$5 REG_SZ native"; fi
25+`, calls)
26+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
27+ mustWrite(t, filepath.Join(dir, name), script)
28+ }
29+ rt := &domain.WineRuntime{WineBin: filepath.Join(dir, "wine"), WineBootBin: filepath.Join(dir, "wineboot"), WineServerBin: filepath.Join(dir, "wineserver"), Version: "wine-11.21"}
30+ for _, pkg := range []struct {
31+ name, lib32 string
32+ dlls []string
33+ }{{"dxvk", "x32", []string{"dxgi", "d3d8", "d3d9", "d3d10core", "d3d11"}}, {"vkd3d", "x86", []string{"d3d12", "d3d12core"}}} {
34+ base := filepath.Join(dir, pkg.name)
35+ setup := filepath.Join(base, "setup")
36+ mustWrite(t, setup, "#!/bin/sh\nexit 99\n")
37+ if pkg.name == "dxvk" {
38+ rt.DXVKSetupBin = setup
39+ } else {
40+ rt.VKD3DSetupBin = setup
41+ }
42+ for _, arch := range []string{"x64", pkg.lib32} {
43+ for _, dll := range pkg.dlls {
44+ mustWrite(t, filepath.Join(base, arch, dll+".dll"), "dll")
45+ }
46+ }
47+ }
48+ a := NewAdapter(log.New(io.Discard))
49+ a.detected = rt
50+ return a, prefix, calls
51+}
52+func mustWrite(t *testing.T, path, text string) {
53+ t.Helper()
54+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := os.WriteFile(path, []byte(text), 0755); err != nil {
58+ t.Fatal(err)
59+ }
60+}
61+
62+func TestPrefixMigration(t *testing.T) {
63+ for _, tt := range []struct{ name, old, pending, fail string }{
64+ {"missing", "", "", ""}, {"same", "wine-11.21", "", ""}, {"changed", "wine-10.0", "", ""},
65+ {"recovery", "wine-11.21", "wine-11.21", ""}, {"boot failure", "wine-10.0", "", "wineboot"},
66+ {"wait failure", "wine-10.0", "", "wineserver"}, {"override failure", "wine-10.0", "", "wine"},
67+ {"missing DLL despite zero exit", "wine-10.0", "", "delete"}, {"unverified override", "wine-10.0", "", "query"},
68+ } {
69+ t.Run(tt.name, func(t *testing.T) {
70+ a, prefix, calls := migrationFixture(t)
71+ marker := filepath.Join(prefix, prefixVersionFile)
72+ if tt.old != "" {
73+ mustWrite(t, marker, tt.old+"\n")
74+ }
75+ if tt.pending != "" {
76+ mustWrite(t, marker+".migrating", tt.pending+"\n")
77+ }
78+ if tt.fail != "" {
79+ switch tt.fail {
80+ case "delete":
81+ mustWrite(t, a.detected.WineBin, "#!/bin/sh\nrm -f \"$WINEPREFIX/drive_c/windows/system32/dxgi.dll\"\nexit 0\n")
82+ case "query":
83+ mustWrite(t, a.detected.WineBin, "#!/bin/sh\nexit 0\n")
84+ default:
85+ mustWrite(t, filepath.Join(filepath.Dir(calls), tt.fail), "#!/bin/sh\nif [ \"$1\" = '-k0' ]; then exit 1; fi\nexit 7\n")
86+ }
87+ }
88+ t.Setenv("DISPLAY", ":1")
89+ err := a.UpdatePrefix(prefix, &domain.WineEnv{Vars: map[string]string{"DXVK_HDR": "1"}, Unset: []string{"DISPLAY"}})
90+ if (err != nil) != (tt.fail != "") {
91+ t.Fatalf("result: %v", err)
92+ }
93+ data, readErr := os.ReadFile(marker)
94+ pending, pendingErr := os.ReadFile(marker + ".migrating")
95+ if tt.fail != "" {
96+ if strings.TrimSpace(string(data)) != tt.old {
97+ t.Fatalf("marker changed: %s", data)
98+ }
99+ if pendingErr != nil || strings.TrimSpace(string(pending)) != "wine-11.21" {
100+ t.Fatalf("missing recovery: %s %v", pending, pendingErr)
101+ }
102+ } else {
103+ if readErr != nil || string(data) != "wine-11.21\n" {
104+ t.Fatalf("marker: %s %v", data, readErr)
105+ }
106+ if !os.IsNotExist(pendingErr) {
107+ t.Fatal("recovery record not removed")
108+ }
109+ }
110+ invocation, _ := os.ReadFile(calls)
111+ text := string(invocation)
112+ if tt.old == "wine-11.21" && tt.pending == "" {
113+ if text != "" {
114+ t.Fatalf("same version ran commands: %s", text)
115+ }
116+ return
117+ }
118+ if tt.fail == "" {
119+ for _, want := range []string{"wineboot -u -r", "wineserver -w", "/v dxgi", "/v d3d12core", "/d native", "HDR=1 DISPLAY=unset"} {
120+ if !strings.Contains(text, want) {
121+ t.Fatalf("missing %q: %s", want, text)
122+ }
123+ }
124+ plan, err := graphicsPlan(a.detected, filepath.Join(prefix, "pfx"))
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ if err := verifyGraphics(a.detected, a.buildEnv(filepath.Join(prefix, "pfx"), nil), plan); err != nil {
129+ t.Fatal(err)
130+ }
131+ }
132+ })
133+ }
134+}
135+
136+func TestGraphicsUsesSelectedWineEnvironment(t *testing.T) {
137+ a := NewAdapter(log.New(io.Discard))
138+ a.detected = &domain.WineRuntime{WineBin: "/chosen/bin/wine", WineServerBin: "/chosen/bin/wineserver"}
139+ env := a.buildEnv(t.TempDir(), nil)
140+ if env["WINE"] != "/chosen/bin/wine" || env["WINESERVER"] != "/chosen/bin/wineserver" || !strings.HasPrefix(env["PATH"], "/chosen/bin:") {
141+ t.Fatalf("wrong setup environment: %v", env)
142+ }
143+}
@@ -0,0 +1,143 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/bnetctl/internal/domain"
12+ "github.com/charmbracelet/log"
13+)
14+
15+func migrationFixture(t *testing.T) (*Adapter, string, string) {
16+ t.Helper()
17+ dir := t.TempDir()
18+ prefix := filepath.Join(dir, "prefix")
19+ mustWrite(t, filepath.Join(prefix, "pfx", "system.reg"), "WINE REGISTRY Version 2\n#arch=win64\n")
20+ calls := filepath.Join(dir, "calls")
21+ script := fmt.Sprintf(`#!/bin/sh
22+if [ "$1" = '-k0' ]; then exit 1; fi
23+echo "$(basename "$0") $* HDR=$DXVK_HDR DISPLAY=${DISPLAY-unset}" >> '%s'
24+if [ "$1" = 'reg' ] && [ "$2" = 'query' ]; then echo "$5 REG_SZ native"; fi
25+`, calls)
26+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
27+ mustWrite(t, filepath.Join(dir, name), script)
28+ }
29+ rt := &domain.WineRuntime{WineBin: filepath.Join(dir, "wine"), WineBootBin: filepath.Join(dir, "wineboot"), WineServerBin: filepath.Join(dir, "wineserver"), Version: "wine-11.21"}
30+ for _, pkg := range []struct {
31+ name, lib32 string
32+ dlls []string
33+ }{{"dxvk", "x32", []string{"dxgi", "d3d8", "d3d9", "d3d10core", "d3d11"}}, {"vkd3d", "x86", []string{"d3d12", "d3d12core"}}} {
34+ base := filepath.Join(dir, pkg.name)
35+ setup := filepath.Join(base, "setup")
36+ mustWrite(t, setup, "#!/bin/sh\nexit 99\n")
37+ if pkg.name == "dxvk" {
38+ rt.DXVKSetupBin = setup
39+ } else {
40+ rt.VKD3DSetupBin = setup
41+ }
42+ for _, arch := range []string{"x64", pkg.lib32} {
43+ for _, dll := range pkg.dlls {
44+ mustWrite(t, filepath.Join(base, arch, dll+".dll"), "dll")
45+ }
46+ }
47+ }
48+ a := NewAdapter(log.New(io.Discard))
49+ a.detected = rt
50+ return a, prefix, calls
51+}
52+func mustWrite(t *testing.T, path, text string) {
53+ t.Helper()
54+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := os.WriteFile(path, []byte(text), 0755); err != nil {
58+ t.Fatal(err)
59+ }
60+}
61+
62+func TestPrefixMigration(t *testing.T) {
63+ for _, tt := range []struct{ name, old, pending, fail string }{
64+ {"missing", "", "", ""}, {"same", "wine-11.21", "", ""}, {"changed", "wine-10.0", "", ""},
65+ {"recovery", "wine-11.21", "wine-11.21", ""}, {"boot failure", "wine-10.0", "", "wineboot"},
66+ {"wait failure", "wine-10.0", "", "wineserver"}, {"override failure", "wine-10.0", "", "wine"},
67+ {"missing DLL despite zero exit", "wine-10.0", "", "delete"}, {"unverified override", "wine-10.0", "", "query"},
68+ } {
69+ t.Run(tt.name, func(t *testing.T) {
70+ a, prefix, calls := migrationFixture(t)
71+ marker := filepath.Join(prefix, prefixVersionFile)
72+ if tt.old != "" {
73+ mustWrite(t, marker, tt.old+"\n")
74+ }
75+ if tt.pending != "" {
76+ mustWrite(t, marker+".migrating", tt.pending+"\n")
77+ }
78+ if tt.fail != "" {
79+ switch tt.fail {
80+ case "delete":
81+ mustWrite(t, a.detected.WineBin, "#!/bin/sh\nrm -f \"$WINEPREFIX/drive_c/windows/system32/dxgi.dll\"\nexit 0\n")
82+ case "query":
83+ mustWrite(t, a.detected.WineBin, "#!/bin/sh\nexit 0\n")
84+ default:
85+ mustWrite(t, filepath.Join(filepath.Dir(calls), tt.fail), "#!/bin/sh\nif [ \"$1\" = '-k0' ]; then exit 1; fi\nexit 7\n")
86+ }
87+ }
88+ t.Setenv("DISPLAY", ":1")
89+ err := a.UpdatePrefix(prefix, &domain.WineEnv{Vars: map[string]string{"DXVK_HDR": "1"}, Unset: []string{"DISPLAY"}})
90+ if (err != nil) != (tt.fail != "") {
91+ t.Fatalf("result: %v", err)
92+ }
93+ data, readErr := os.ReadFile(marker)
94+ pending, pendingErr := os.ReadFile(marker + ".migrating")
95+ if tt.fail != "" {
96+ if strings.TrimSpace(string(data)) != tt.old {
97+ t.Fatalf("marker changed: %s", data)
98+ }
99+ if pendingErr != nil || strings.TrimSpace(string(pending)) != "wine-11.21" {
100+ t.Fatalf("missing recovery: %s %v", pending, pendingErr)
101+ }
102+ } else {
103+ if readErr != nil || string(data) != "wine-11.21\n" {
104+ t.Fatalf("marker: %s %v", data, readErr)
105+ }
106+ if !os.IsNotExist(pendingErr) {
107+ t.Fatal("recovery record not removed")
108+ }
109+ }
110+ invocation, _ := os.ReadFile(calls)
111+ text := string(invocation)
112+ if tt.old == "wine-11.21" && tt.pending == "" {
113+ if text != "" {
114+ t.Fatalf("same version ran commands: %s", text)
115+ }
116+ return
117+ }
118+ if tt.fail == "" {
119+ for _, want := range []string{"wineboot -u -r", "wineserver -w", "/v dxgi", "/v d3d12core", "/d native", "HDR=1 DISPLAY=unset"} {
120+ if !strings.Contains(text, want) {
121+ t.Fatalf("missing %q: %s", want, text)
122+ }
123+ }
124+ plan, err := graphicsPlan(a.detected, filepath.Join(prefix, "pfx"))
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ if err := verifyGraphics(a.detected, a.buildEnv(filepath.Join(prefix, "pfx"), nil), plan); err != nil {
129+ t.Fatal(err)
130+ }
131+ }
132+ })
133+ }
134+}
135+
136+func TestGraphicsUsesSelectedWineEnvironment(t *testing.T) {
137+ a := NewAdapter(log.New(io.Discard))
138+ a.detected = &domain.WineRuntime{WineBin: "/chosen/bin/wine", WineServerBin: "/chosen/bin/wineserver"}
139+ env := a.buildEnv(t.TempDir(), nil)
140+ if env["WINE"] != "/chosen/bin/wine" || env["WINESERVER"] != "/chosen/bin/wineserver" || !strings.HasPrefix(env["PATH"], "/chosen/bin:") {
141+ t.Fatalf("wrong setup environment: %v", env)
142+ }
143+}
A internal/adapters/wine/preflight.go +53 -0
@@ -0,0 +1,53 @@
1+package wine
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// Registry exports escape backslashes. Never execute Wine to inspect startup state.
12+func checkRunOnce(pfx string) error {
13+ var entries []string
14+ for _, name := range []string{"system.reg", "user.reg"} {
15+ f, err := os.Open(filepath.Join(pfx, name))
16+ if os.IsNotExist(err) {
17+ continue
18+ }
19+ if err != nil {
20+ return err
21+ }
22+ scanner := bufio.NewScanner(f)
23+ scanner.Buffer(make([]byte, 4096), 4*1024*1024)
24+ key := ""
25+ for scanner.Scan() {
26+ line := strings.TrimSpace(scanner.Text())
27+ if strings.HasPrefix(line, "[") {
28+ end := strings.Index(line, "]")
29+ key = ""
30+ if end >= 0 {
31+ key = strings.ReplaceAll(line[1:end], `\\`, `\`)
32+ }
33+ continue
34+ }
35+ lower := strings.ToLower(key)
36+ base := `software\microsoft\windows\currentversion\`
37+ wow := `software\wow6432node\microsoft\windows\currentversion\`
38+ dangerous := lower == base+"runonce" || lower == base+"runservicesonce" || lower == wow+"runonce" || lower == wow+"runservicesonce"
39+ if dangerous && (strings.HasPrefix(line, `"`) || strings.HasPrefix(line, "@=")) && strings.Contains(line, "=") {
40+ entries = append(entries, name+": ["+key+"] "+line)
41+ }
42+ }
43+ err = scanner.Err()
44+ _ = f.Close()
45+ if err != nil {
46+ return err
47+ }
48+ }
49+ if len(entries) > 0 {
50+ return fmt.Errorf("refusing prefix migration: wineboot -u -r still processes RunOnce/RunServicesOnce entries: %s", strings.Join(entries, "; "))
51+ }
52+ return nil
53+}
@@ -0,0 +1,53 @@
1+package wine
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// Registry exports escape backslashes. Never execute Wine to inspect startup state.
12+func checkRunOnce(pfx string) error {
13+ var entries []string
14+ for _, name := range []string{"system.reg", "user.reg"} {
15+ f, err := os.Open(filepath.Join(pfx, name))
16+ if os.IsNotExist(err) {
17+ continue
18+ }
19+ if err != nil {
20+ return err
21+ }
22+ scanner := bufio.NewScanner(f)
23+ scanner.Buffer(make([]byte, 4096), 4*1024*1024)
24+ key := ""
25+ for scanner.Scan() {
26+ line := strings.TrimSpace(scanner.Text())
27+ if strings.HasPrefix(line, "[") {
28+ end := strings.Index(line, "]")
29+ key = ""
30+ if end >= 0 {
31+ key = strings.ReplaceAll(line[1:end], `\\`, `\`)
32+ }
33+ continue
34+ }
35+ lower := strings.ToLower(key)
36+ base := `software\microsoft\windows\currentversion\`
37+ wow := `software\wow6432node\microsoft\windows\currentversion\`
38+ dangerous := lower == base+"runonce" || lower == base+"runservicesonce" || lower == wow+"runonce" || lower == wow+"runservicesonce"
39+ if dangerous && (strings.HasPrefix(line, `"`) || strings.HasPrefix(line, "@=")) && strings.Contains(line, "=") {
40+ entries = append(entries, name+": ["+key+"] "+line)
41+ }
42+ }
43+ err = scanner.Err()
44+ _ = f.Close()
45+ if err != nil {
46+ return err
47+ }
48+ }
49+ if len(entries) > 0 {
50+ return fmt.Errorf("refusing prefix migration: wineboot -u -r still processes RunOnce/RunServicesOnce entries: %s", strings.Join(entries, "; "))
51+ }
52+ return nil
53+}
M internal/adapters/wine/runtime.go +73 -34
@@ -25,7 +25,10 @@ const (
2525
2626 // Adapter implements ports.RuntimePort using wine-cachyos directly
2727 type Adapter struct {
28- log *log.Logger
28+ log *log.Logger
29+ wayland bool
30+ detected *domain.WineRuntime
31+ minimum string
2932 }
3033
3134 var _ ports.RuntimePort = (*Adapter)(nil)
@@ -35,11 +38,19 @@ func NewAdapter(log *log.Logger) *Adapter {
3538 return &Adapter{log: log}
3639 }
3740
41+// NewWaylandAdapter selects a runtime supporting native Wayland color management.
42+func NewWaylandAdapter(log *log.Logger) *Adapter {
43+ return &Adapter{log: log, wayland: true}
44+}
45+
3846 // Detect checks if wine-cachyos is available and returns runtime info
3947 func (a *Adapter) Detect() (*domain.WineRuntime, error) {
4048 log := a.log
49+ if a.detected != nil {
50+ return a.detected, nil
51+ }
4152
42- wineBin, wineBootBin, wineServerBin, err := resolveBinaries()
53+ wineBin, wineBootBin, wineServerBin, err := resolveBinariesForWayland(a.wayland)
4354 if err != nil {
4455 return nil, err
4556 }
@@ -53,6 +64,17 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) {
5364 version := strings.TrimSpace(string(out))
5465 log.Debug("wine version", "version", version)
5566
67+ if a.minimum != "" {
68+ wineBin, version, err = chooseMinimumWine(wineBin, version, a.minimum)
69+ if err != nil {
70+ return nil, err
71+ }
72+ wineBootBin, wineServerBin, err = companionBinaries(wineBin)
73+ if err != nil {
74+ return nil, err
75+ }
76+ log.Info("preserving prefix Wine minimum", "minimum", a.minimum, "path", wineBin, "version", version)
77+ }
5678 // Check NTSync availability
5779 hasNTSync := false
5880 if _, err := os.Stat("/dev/ntsync"); err == nil {
@@ -67,7 +89,11 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) {
6789 // Check VKD3D-proton setup availability
6890 vkd3dSetupBin, _ := exec.LookPath("setup_vkd3d_proton")
6991
70- return &domain.WineRuntime{
92+ if a.wayland && !supportsWaylandHDR(version) {
93+ log.Warn(fmt.Sprintf("HDR on Wayland needs Wine >= 11.19 (found %s); set BNETCTL_WINE", version))
94+ }
95+ log.Info("selected wine runtime", "path", wineBin, "version", version)
96+ a.detected = &domain.WineRuntime{
7197 WineBin: wineBin,
7298 WineBootBin: wineBootBin,
7399 WineServerBin: wineServerBin,
@@ -77,7 +103,8 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) {
77103 DXVKSetupBin: dxvkSetupBin,
78104 HasVKD3DSetup: vkd3dSetupBin != "",
79105 VKD3DSetupBin: vkd3dSetupBin,
80- }, nil
106+ }
107+ return a.detected, nil
81108 }
82109
83110 // CreatePrefix initializes a new Wine prefix at the given path
@@ -89,6 +116,9 @@ func (a *Adapter) CreatePrefix(prefixPath string) error {
89116 return err
90117 }
91118
119+ if err := writeVersionRecord(prefixPath, prefixVersionFile+".migrating", rt.Version); err != nil {
120+ return err
121+ }
92122 pfxDir := filepath.Join(prefixPath, "pfx")
93123 log.Info("creating wine prefix", "path", pfxDir)
94124 if err := os.MkdirAll(pfxDir, 0o755); err != nil {
@@ -116,35 +146,16 @@ func (a *Adapter) CreatePrefix(prefixPath string) error {
116146 return fmt.Errorf("wineserver -w failed: %w", err)
117147 }
118148
119- // Auto-install DXVK if setup_dxvk is available
120- log.Debug("dxvk auto-install", "enabled", rt.HasDXVKSetup)
121- if rt.HasDXVKSetup {
122- dxvkCmd := exec.Command(rt.DXVKSetupBin, "install", "--symlink")
123- dxvkCmd.Env = envMapToSlice(env)
124- dxvkCmd.Stdout = os.Stdout
125- dxvkCmd.Stderr = os.Stderr
126- if err := dxvkCmd.Run(); err != nil {
127- // DXVK install failure is non-fatal
128- log.Warn("dxvk setup failed", "error", err)
129- fmt.Fprintf(os.Stderr, "warning: DXVK setup failed (non-fatal): %v\n", err)
130- } else {
131- log.Info("dxvk installed")
132- }
149+ if err := a.installGraphics(rt, env); err != nil {
150+ return err
133151 }
134-
135- // Auto-install VKD3D-proton if setup_vkd3d_proton is available
136- if rt.HasVKD3DSetup {
137- log.Info("installing vkd3d-proton", "path", rt.VKD3DSetupBin)
138- vkd3dCmd := exec.Command(rt.VKD3DSetupBin, "install", "--symlink")
139- vkd3dCmd.Env = envMapToSlice(env)
140- vkd3dCmd.Stdout = os.Stdout
141- vkd3dCmd.Stderr = os.Stderr
142- if err := vkd3dCmd.Run(); err != nil {
143- // VKD3D install failure is non-fatal
144- log.Warn("vkd3d-proton setup failed (non-fatal)", "error", err)
145- }
152+ if err := writePrefixVersion(prefixPath, rt.Version); err != nil {
153+ return err
146154 }
147155
156+ if err := os.Remove(filepath.Join(prefixPath, prefixVersionFile+".migrating")); err != nil {
157+ return err
158+ }
148159 // Disable Wine's systray window (tiled as a full-size empty column on
149160 // Wayland compositors) before the prefix is used.
150161 log.Debug("disabling wine systray")
@@ -241,7 +252,7 @@ func (a *Adapter) RunExeAsync(prefixPath string, exePath string, wineEnv *domain
241252 func (a *Adapter) IsProcessRunning(prefixPath string) bool {
242253 log := a.log
243254
244- _, _, wineServerBin, err := resolveBinaries()
255+ _, _, wineServerBin, err := a.binaries()
245256 if err != nil {
246257 return false
247258 }
@@ -259,7 +270,7 @@ func (a *Adapter) IsProcessRunning(prefixPath string) bool {
259270 func (a *Adapter) stopWineserver(prefixPath string) error {
260271 log := a.log
261272
262- _, _, wineServerBin, err := resolveBinaries()
273+ _, _, wineServerBin, err := a.binaries()
263274 if err != nil {
264275 return err
265276 }
@@ -482,7 +493,7 @@ func processNames(processes []prefixProcess) domain.StoppedProcesses {
482493 // The waiter is killed and reaped once the deadline passes, so it cannot outlive
483494 // the call, and the wineserver binary is never confused with a leftover to sweep.
484495 func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) error {
485- _, _, wineServerBin, err := resolveBinaries()
496+ _, _, wineServerBin, err := a.binaries()
486497 if err != nil {
487498 return err
488499 }
@@ -506,7 +517,7 @@ func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) er
506517
507518 // forceKillPrefix sends SIGKILL to the prefix's wineserver.
508519 func (a *Adapter) forceKillPrefix(prefixPath string) error {
509- _, _, wineServerBin, err := resolveBinaries()
520+ _, _, wineServerBin, err := a.binaries()
510521 if err != nil {
511522 return err
512523 }
@@ -673,6 +684,13 @@ func (a *Adapter) buildEnv(pfxDir string, wineEnv *domain.WineEnv) map[string]st
673684 }
674685
675686 env["WINEPREFIX"] = pfxDir
687+ if a.detected != nil {
688+ // Setup scripts must use the selected install too, not a different PATH Wine.
689+ env["WINE"] = a.detected.WineBin
690+ env["WINELOADER"] = a.detected.WineBin
691+ env["WINESERVER"] = a.detected.WineServerBin
692+ env["PATH"] = filepath.Dir(a.detected.WineBin) + string(os.PathListSeparator) + env["PATH"]
693+ }
676694
677695 if wineEnv != nil {
678696 for _, key := range wineEnv.Unset {
@@ -687,6 +705,10 @@ func (a *Adapter) buildEnv(pfxDir string, wineEnv *domain.WineEnv) map[string]st
687705 }
688706
689707 func resolveBinaries() (wine, wineboot, wineserver string, err error) {
708+ return resolveBinariesForWayland(false)
709+}
710+
711+func resolveBinariesForWayland(wayland bool) (wine, wineboot, wineserver string, err error) {
690712 wine = os.Getenv(wineOverrideEnv)
691713 if wine == "" {
692714 if isExecutable(cachyOSWineBinary) {
@@ -699,6 +721,16 @@ func resolveBinaries() (wine, wineboot, wineserver string, err error) {
699721 return "", "", "", fmt.Errorf("resolve %s: %w", wineOverrideEnv, err)
700722 }
701723 }
724+ if wayland && os.Getenv(wineOverrideEnv) == "" {
725+ pathWine, _ := exec.LookPath("wine")
726+ wine = selectHDRWine(wine, pathWine, func(path string) string {
727+ out, err := exec.Command(path, "--version").Output()
728+ if err != nil {
729+ return ""
730+ }
731+ return strings.TrimSpace(string(out))
732+ })
733+ }
702734 if !isExecutable(wine) {
703735 return "", "", "", fmt.Errorf("wine executable is not usable: %s", wine)
704736 }
@@ -730,3 +762,10 @@ func envMapToSlice(env map[string]string) []string {
730762 }
731763 return result
732764 }
765+
766+func (a *Adapter) binaries() (string, string, string, error) {
767+ if a.detected != nil {
768+ return a.detected.WineBin, a.detected.WineBootBin, a.detected.WineServerBin, nil
769+ }
770+ return resolveBinariesForWayland(a.wayland)
771+}
@@ -25,7 +25,10 @@ const (
25 25
26 // Adapter implements ports.RuntimePort using wine-cachyos directly26 // Adapter implements ports.RuntimePort using wine-cachyos directly
27 type Adapter struct {27 type Adapter struct {
28- log *log.Logger28+ log *log.Logger
29+ wayland bool
30+ detected *domain.WineRuntime
31+ minimum string
29 }32 }
30 33
31 var _ ports.RuntimePort = (*Adapter)(nil)34 var _ ports.RuntimePort = (*Adapter)(nil)
@@ -35,11 +38,19 @@ func NewAdapter(log *log.Logger) *Adapter {
35 return &Adapter{log: log}38 return &Adapter{log: log}
36 }39 }
37 40
41+// NewWaylandAdapter selects a runtime supporting native Wayland color management.
42+func NewWaylandAdapter(log *log.Logger) *Adapter {
43+ return &Adapter{log: log, wayland: true}
44+}
45+
38 // Detect checks if wine-cachyos is available and returns runtime info46 // Detect checks if wine-cachyos is available and returns runtime info
39 func (a *Adapter) Detect() (*domain.WineRuntime, error) {47 func (a *Adapter) Detect() (*domain.WineRuntime, error) {
40 log := a.log48 log := a.log
49+ if a.detected != nil {
50+ return a.detected, nil
51+ }
41 52
42- wineBin, wineBootBin, wineServerBin, err := resolveBinaries()53+ wineBin, wineBootBin, wineServerBin, err := resolveBinariesForWayland(a.wayland)
43 if err != nil {54 if err != nil {
44 return nil, err55 return nil, err
45 }56 }
@@ -53,6 +64,17 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) {
53 version := strings.TrimSpace(string(out))64 version := strings.TrimSpace(string(out))
54 log.Debug("wine version", "version", version)65 log.Debug("wine version", "version", version)
55 66
67+ if a.minimum != "" {
68+ wineBin, version, err = chooseMinimumWine(wineBin, version, a.minimum)
69+ if err != nil {
70+ return nil, err
71+ }
72+ wineBootBin, wineServerBin, err = companionBinaries(wineBin)
73+ if err != nil {
74+ return nil, err
75+ }
76+ log.Info("preserving prefix Wine minimum", "minimum", a.minimum, "path", wineBin, "version", version)
77+ }
56 // Check NTSync availability78 // Check NTSync availability
57 hasNTSync := false79 hasNTSync := false
58 if _, err := os.Stat("/dev/ntsync"); err == nil {80 if _, err := os.Stat("/dev/ntsync"); err == nil {
@@ -67,7 +89,11 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) {
67 // Check VKD3D-proton setup availability89 // Check VKD3D-proton setup availability
68 vkd3dSetupBin, _ := exec.LookPath("setup_vkd3d_proton")90 vkd3dSetupBin, _ := exec.LookPath("setup_vkd3d_proton")
69 91
70- return &domain.WineRuntime{92+ if a.wayland && !supportsWaylandHDR(version) {
93+ log.Warn(fmt.Sprintf("HDR on Wayland needs Wine >= 11.19 (found %s); set BNETCTL_WINE", version))
94+ }
95+ log.Info("selected wine runtime", "path", wineBin, "version", version)
96+ a.detected = &domain.WineRuntime{
71 WineBin: wineBin,97 WineBin: wineBin,
72 WineBootBin: wineBootBin,98 WineBootBin: wineBootBin,
73 WineServerBin: wineServerBin,99 WineServerBin: wineServerBin,
@@ -77,7 +103,8 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) {
77 DXVKSetupBin: dxvkSetupBin,103 DXVKSetupBin: dxvkSetupBin,
78 HasVKD3DSetup: vkd3dSetupBin != "",104 HasVKD3DSetup: vkd3dSetupBin != "",
79 VKD3DSetupBin: vkd3dSetupBin,105 VKD3DSetupBin: vkd3dSetupBin,
80- }, nil106+ }
107+ return a.detected, nil
81 }108 }
82 109
83 // CreatePrefix initializes a new Wine prefix at the given path110 // CreatePrefix initializes a new Wine prefix at the given path
@@ -89,6 +116,9 @@ func (a *Adapter) CreatePrefix(prefixPath string) error {
89 return err116 return err
90 }117 }
91 118
119+ if err := writeVersionRecord(prefixPath, prefixVersionFile+".migrating", rt.Version); err != nil {
120+ return err
121+ }
92 pfxDir := filepath.Join(prefixPath, "pfx")122 pfxDir := filepath.Join(prefixPath, "pfx")
93 log.Info("creating wine prefix", "path", pfxDir)123 log.Info("creating wine prefix", "path", pfxDir)
94 if err := os.MkdirAll(pfxDir, 0o755); err != nil {124 if err := os.MkdirAll(pfxDir, 0o755); err != nil {
@@ -116,35 +146,16 @@ func (a *Adapter) CreatePrefix(prefixPath string) error {
116 return fmt.Errorf("wineserver -w failed: %w", err)146 return fmt.Errorf("wineserver -w failed: %w", err)
117 }147 }
118 148
119- // Auto-install DXVK if setup_dxvk is available149+ if err := a.installGraphics(rt, env); err != nil {
120- log.Debug("dxvk auto-install", "enabled", rt.HasDXVKSetup)150+ return err
121- if rt.HasDXVKSetup {
122- dxvkCmd := exec.Command(rt.DXVKSetupBin, "install", "--symlink")
123- dxvkCmd.Env = envMapToSlice(env)
124- dxvkCmd.Stdout = os.Stdout
125- dxvkCmd.Stderr = os.Stderr
126- if err := dxvkCmd.Run(); err != nil {
127- // DXVK install failure is non-fatal
128- log.Warn("dxvk setup failed", "error", err)
129- fmt.Fprintf(os.Stderr, "warning: DXVK setup failed (non-fatal): %v\n", err)
130- } else {
131- log.Info("dxvk installed")
132- }
133 }151 }
134-152+ if err := writePrefixVersion(prefixPath, rt.Version); err != nil {
135- // Auto-install VKD3D-proton if setup_vkd3d_proton is available153+ return err
136- if rt.HasVKD3DSetup {
137- log.Info("installing vkd3d-proton", "path", rt.VKD3DSetupBin)
138- vkd3dCmd := exec.Command(rt.VKD3DSetupBin, "install", "--symlink")
139- vkd3dCmd.Env = envMapToSlice(env)
140- vkd3dCmd.Stdout = os.Stdout
141- vkd3dCmd.Stderr = os.Stderr
142- if err := vkd3dCmd.Run(); err != nil {
143- // VKD3D install failure is non-fatal
144- log.Warn("vkd3d-proton setup failed (non-fatal)", "error", err)
145- }
146 }154 }
147 155
156+ if err := os.Remove(filepath.Join(prefixPath, prefixVersionFile+".migrating")); err != nil {
157+ return err
158+ }
148 // Disable Wine's systray window (tiled as a full-size empty column on159 // Disable Wine's systray window (tiled as a full-size empty column on
149 // Wayland compositors) before the prefix is used.160 // Wayland compositors) before the prefix is used.
150 log.Debug("disabling wine systray")161 log.Debug("disabling wine systray")
@@ -241,7 +252,7 @@ func (a *Adapter) RunExeAsync(prefixPath string, exePath string, wineEnv *domain
241 func (a *Adapter) IsProcessRunning(prefixPath string) bool {252 func (a *Adapter) IsProcessRunning(prefixPath string) bool {
242 log := a.log253 log := a.log
243 254
244- _, _, wineServerBin, err := resolveBinaries()255+ _, _, wineServerBin, err := a.binaries()
245 if err != nil {256 if err != nil {
246 return false257 return false
247 }258 }
@@ -259,7 +270,7 @@ func (a *Adapter) IsProcessRunning(prefixPath string) bool {
259 func (a *Adapter) stopWineserver(prefixPath string) error {270 func (a *Adapter) stopWineserver(prefixPath string) error {
260 log := a.log271 log := a.log
261 272
262- _, _, wineServerBin, err := resolveBinaries()273+ _, _, wineServerBin, err := a.binaries()
263 if err != nil {274 if err != nil {
264 return err275 return err
265 }276 }
@@ -482,7 +493,7 @@ func processNames(processes []prefixProcess) domain.StoppedProcesses {
482 // The waiter is killed and reaped once the deadline passes, so it cannot outlive493 // The waiter is killed and reaped once the deadline passes, so it cannot outlive
483 // the call, and the wineserver binary is never confused with a leftover to sweep.494 // the call, and the wineserver binary is never confused with a leftover to sweep.
484 func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) error {495 func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) error {
485- _, _, wineServerBin, err := resolveBinaries()496+ _, _, wineServerBin, err := a.binaries()
486 if err != nil {497 if err != nil {
487 return err498 return err
488 }499 }
@@ -506,7 +517,7 @@ func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) er
506 517
507 // forceKillPrefix sends SIGKILL to the prefix's wineserver.518 // forceKillPrefix sends SIGKILL to the prefix's wineserver.
508 func (a *Adapter) forceKillPrefix(prefixPath string) error {519 func (a *Adapter) forceKillPrefix(prefixPath string) error {
509- _, _, wineServerBin, err := resolveBinaries()520+ _, _, wineServerBin, err := a.binaries()
510 if err != nil {521 if err != nil {
511 return err522 return err
512 }523 }
@@ -673,6 +684,13 @@ func (a *Adapter) buildEnv(pfxDir string, wineEnv *domain.WineEnv) map[string]st
673 }684 }
674 685
675 env["WINEPREFIX"] = pfxDir686 env["WINEPREFIX"] = pfxDir
687+ if a.detected != nil {
688+ // Setup scripts must use the selected install too, not a different PATH Wine.
689+ env["WINE"] = a.detected.WineBin
690+ env["WINELOADER"] = a.detected.WineBin
691+ env["WINESERVER"] = a.detected.WineServerBin
692+ env["PATH"] = filepath.Dir(a.detected.WineBin) + string(os.PathListSeparator) + env["PATH"]
693+ }
676 694
677 if wineEnv != nil {695 if wineEnv != nil {
678 for _, key := range wineEnv.Unset {696 for _, key := range wineEnv.Unset {
@@ -687,6 +705,10 @@ func (a *Adapter) buildEnv(pfxDir string, wineEnv *domain.WineEnv) map[string]st
687 }705 }
688 706
689 func resolveBinaries() (wine, wineboot, wineserver string, err error) {707 func resolveBinaries() (wine, wineboot, wineserver string, err error) {
708+ return resolveBinariesForWayland(false)
709+}
710+
711+func resolveBinariesForWayland(wayland bool) (wine, wineboot, wineserver string, err error) {
690 wine = os.Getenv(wineOverrideEnv)712 wine = os.Getenv(wineOverrideEnv)
691 if wine == "" {713 if wine == "" {
692 if isExecutable(cachyOSWineBinary) {714 if isExecutable(cachyOSWineBinary) {
@@ -699,6 +721,16 @@ func resolveBinaries() (wine, wineboot, wineserver string, err error) {
699 return "", "", "", fmt.Errorf("resolve %s: %w", wineOverrideEnv, err)721 return "", "", "", fmt.Errorf("resolve %s: %w", wineOverrideEnv, err)
700 }722 }
701 }723 }
724+ if wayland && os.Getenv(wineOverrideEnv) == "" {
725+ pathWine, _ := exec.LookPath("wine")
726+ wine = selectHDRWine(wine, pathWine, func(path string) string {
727+ out, err := exec.Command(path, "--version").Output()
728+ if err != nil {
729+ return ""
730+ }
731+ return strings.TrimSpace(string(out))
732+ })
733+ }
702 if !isExecutable(wine) {734 if !isExecutable(wine) {
703 return "", "", "", fmt.Errorf("wine executable is not usable: %s", wine)735 return "", "", "", fmt.Errorf("wine executable is not usable: %s", wine)
704 }736 }
@@ -730,3 +762,10 @@ func envMapToSlice(env map[string]string) []string {
730 }762 }
731 return result763 return result
732 }764 }
765+
766+func (a *Adapter) binaries() (string, string, string, error) {
767+ if a.detected != nil {
768+ return a.detected.WineBin, a.detected.WineBootBin, a.detected.WineServerBin, nil
769+ }
770+ return resolveBinariesForWayland(a.wayland)
771+}
M internal/adapters/wine/runtime_test.go +4 -1
@@ -398,7 +398,10 @@ func waitForExit(t *testing.T, cmd *exec.Cmd) {
398398 done := make(chan error, 1)
399399 go func() { done <- cmd.Wait() }()
400400 select {
401- case <-done:
401+ case err := <-done:
402+ if err != nil {
403+ t.Logf("process exited: %v", err)
404+ }
402405 case <-time.After(2 * time.Second):
403406 t.Fatalf("process %d survived the kill", cmd.Process.Pid)
404407 }
@@ -398,7 +398,10 @@ func waitForExit(t *testing.T, cmd *exec.Cmd) {
398 done := make(chan error, 1)398 done := make(chan error, 1)
399 go func() { done <- cmd.Wait() }()399 go func() { done <- cmd.Wait() }()
400 select {400 select {
401- case <-done:401+ case err := <-done:
402+ if err != nil {
403+ t.Logf("process exited: %v", err)
404+ }
402 case <-time.After(2 * time.Second):405 case <-time.After(2 * time.Second):
403 t.Fatalf("process %d survived the kill", cmd.Process.Pid)406 t.Fatalf("process %d survived the kill", cmd.Process.Pid)
404 }407 }
A internal/adapters/wine/safety.go +181 -0
@@ -0,0 +1,181 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "syscall"
12+)
13+
14+func versionNumbers(v string) ([]int, error) {
15+ if !strings.HasPrefix(v, "wine-") {
16+ return nil, fmt.Errorf("invalid Wine version %q", v)
17+ }
18+ v = strings.TrimPrefix(v, "wine-")
19+ end := 0
20+ for end < len(v) && (v[end] >= '0' && v[end] <= '9' || v[end] == '.') {
21+ end++
22+ }
23+ if end < len(v) && v[end] != '-' && v[end] != ' ' {
24+ return nil, fmt.Errorf("invalid Wine version %q", v)
25+ }
26+ fields := strings.Split(v[:end], ".")
27+ if len(fields) < 2 {
28+ return nil, fmt.Errorf("invalid Wine version %q", v)
29+ }
30+ result := make([]int, len(fields))
31+ for i, f := range fields {
32+ n, err := strconv.Atoi(f)
33+ if err != nil {
34+ return nil, err
35+ }
36+ result[i] = n
37+ }
38+ return result, nil
39+}
40+func compareVersions(a, b string) (int, error) {
41+ x, err := versionNumbers(a)
42+ if err != nil {
43+ return 0, err
44+ }
45+ y, err := versionNumbers(b)
46+ if err != nil {
47+ return 0, err
48+ }
49+ for i := 0; i < len(x) || i < len(y); i++ {
50+ u, v := 0, 0
51+ if i < len(x) {
52+ u = x[i]
53+ }
54+ if i < len(y) {
55+ v = y[i]
56+ }
57+ if u < v {
58+ return -1, nil
59+ }
60+ if u > v {
61+ return 1, nil
62+ }
63+ }
64+ return 0, nil
65+}
66+func companionBinaries(wine string) (string, string, error) {
67+ boot, server := filepath.Join(filepath.Dir(wine), "wineboot"), filepath.Join(filepath.Dir(wine), "wineserver")
68+ if !isExecutable(boot) || !isExecutable(server) {
69+ return "", "", fmt.Errorf("missing companion binaries beside %s", wine)
70+ }
71+ return boot, server, nil
72+}
73+func chooseMinimumWine(selected, version, minimum string) (string, string, error) {
74+ cmp, err := compareVersions(version, minimum)
75+ if err != nil {
76+ return "", "", err
77+ }
78+ if cmp >= 0 {
79+ return selected, version, nil
80+ }
81+ if os.Getenv(wineOverrideEnv) != "" {
82+ return "", "", fmt.Errorf("refusing Wine downgrade: BNETCTL_WINE is %s; prefix requires >= %s", version, minimum)
83+ }
84+ path, _ := exec.LookPath("wine")
85+ for _, candidate := range []string{cachyOSWineBinary, path} {
86+ if candidate == "" || !isExecutable(candidate) {
87+ continue
88+ }
89+ resolved, err := filepath.EvalSymlinks(candidate)
90+ if err != nil {
91+ continue
92+ }
93+ out, err := exec.Command(resolved, "--version").Output()
94+ if err != nil {
95+ continue
96+ }
97+ v := strings.TrimSpace(string(out))
98+ cmp, err := compareVersions(v, minimum)
99+ if err == nil && cmp >= 0 {
100+ if _, _, err := companionBinaries(resolved); err == nil {
101+ return resolved, v, nil
102+ }
103+ }
104+ }
105+ return "", "", fmt.Errorf("refusing Wine downgrade: selected %s; prefix requires >= %s; set BNETCTL_WINE", version, minimum)
106+}
107+func readVersion(path string) (string, error) {
108+ data, err := os.ReadFile(path)
109+ if os.IsNotExist(err) {
110+ return "", nil
111+ }
112+ if err != nil {
113+ return "", err
114+ }
115+ v := strings.TrimSpace(string(data))
116+ if _, err := versionNumbers(v); err != nil {
117+ return "", fmt.Errorf("%s: %w", path, err)
118+ }
119+ return v, nil
120+}
121+func (a *Adapter) LockPrefix(prefix string) (func(), error) {
122+ if err := os.MkdirAll(prefix, 0755); err != nil {
123+ return nil, err
124+ }
125+ f, err := os.OpenFile(filepath.Join(prefix, ".bnetctl.lock"), os.O_CREATE|os.O_RDWR, 0600)
126+ if err != nil {
127+ return nil, err
128+ }
129+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
130+ _ = f.Close()
131+ return nil, fmt.Errorf("prefix is locked by another bnetctl invocation: %w", err)
132+ }
133+ var once sync.Once
134+ release := func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }
135+ a.minimum = ""
136+ versions := map[string]string{}
137+ for _, name := range []string{prefixVersionFile, prefixVersionFile + ".migrating"} {
138+ v, err := readVersion(filepath.Join(prefix, name))
139+ if err != nil {
140+ release()
141+ return nil, err
142+ }
143+ versions[name] = v
144+ if v != "" {
145+ cmp := 1
146+ if a.minimum != "" {
147+ cmp, err = compareVersions(v, a.minimum)
148+ }
149+ if err != nil {
150+ release()
151+ return nil, err
152+ }
153+ if cmp > 0 {
154+ a.minimum = v
155+ }
156+ }
157+ }
158+ a.detected = nil
159+ rt, err := a.Detect()
160+ if err != nil {
161+ release()
162+ return nil, err
163+ }
164+ if versions[prefixVersionFile+".migrating"] != "" || versions[prefixVersionFile] != rt.Version {
165+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
166+ release()
167+ return nil, err
168+ }
169+ // New prefixes have no system.reg until wineboot initializes them.
170+ if _, err := os.Stat(filepath.Join(prefix, "pfx")); err == nil {
171+ if _, err := readPrefixArch(filepath.Join(prefix, "pfx")); err != nil {
172+ release()
173+ return nil, err
174+ }
175+ } else if !os.IsNotExist(err) {
176+ release()
177+ return nil, err
178+ }
179+ }
180+ return release, nil
181+}
@@ -0,0 +1,181 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "syscall"
12+)
13+
14+func versionNumbers(v string) ([]int, error) {
15+ if !strings.HasPrefix(v, "wine-") {
16+ return nil, fmt.Errorf("invalid Wine version %q", v)
17+ }
18+ v = strings.TrimPrefix(v, "wine-")
19+ end := 0
20+ for end < len(v) && (v[end] >= '0' && v[end] <= '9' || v[end] == '.') {
21+ end++
22+ }
23+ if end < len(v) && v[end] != '-' && v[end] != ' ' {
24+ return nil, fmt.Errorf("invalid Wine version %q", v)
25+ }
26+ fields := strings.Split(v[:end], ".")
27+ if len(fields) < 2 {
28+ return nil, fmt.Errorf("invalid Wine version %q", v)
29+ }
30+ result := make([]int, len(fields))
31+ for i, f := range fields {
32+ n, err := strconv.Atoi(f)
33+ if err != nil {
34+ return nil, err
35+ }
36+ result[i] = n
37+ }
38+ return result, nil
39+}
40+func compareVersions(a, b string) (int, error) {
41+ x, err := versionNumbers(a)
42+ if err != nil {
43+ return 0, err
44+ }
45+ y, err := versionNumbers(b)
46+ if err != nil {
47+ return 0, err
48+ }
49+ for i := 0; i < len(x) || i < len(y); i++ {
50+ u, v := 0, 0
51+ if i < len(x) {
52+ u = x[i]
53+ }
54+ if i < len(y) {
55+ v = y[i]
56+ }
57+ if u < v {
58+ return -1, nil
59+ }
60+ if u > v {
61+ return 1, nil
62+ }
63+ }
64+ return 0, nil
65+}
66+func companionBinaries(wine string) (string, string, error) {
67+ boot, server := filepath.Join(filepath.Dir(wine), "wineboot"), filepath.Join(filepath.Dir(wine), "wineserver")
68+ if !isExecutable(boot) || !isExecutable(server) {
69+ return "", "", fmt.Errorf("missing companion binaries beside %s", wine)
70+ }
71+ return boot, server, nil
72+}
73+func chooseMinimumWine(selected, version, minimum string) (string, string, error) {
74+ cmp, err := compareVersions(version, minimum)
75+ if err != nil {
76+ return "", "", err
77+ }
78+ if cmp >= 0 {
79+ return selected, version, nil
80+ }
81+ if os.Getenv(wineOverrideEnv) != "" {
82+ return "", "", fmt.Errorf("refusing Wine downgrade: BNETCTL_WINE is %s; prefix requires >= %s", version, minimum)
83+ }
84+ path, _ := exec.LookPath("wine")
85+ for _, candidate := range []string{cachyOSWineBinary, path} {
86+ if candidate == "" || !isExecutable(candidate) {
87+ continue
88+ }
89+ resolved, err := filepath.EvalSymlinks(candidate)
90+ if err != nil {
91+ continue
92+ }
93+ out, err := exec.Command(resolved, "--version").Output()
94+ if err != nil {
95+ continue
96+ }
97+ v := strings.TrimSpace(string(out))
98+ cmp, err := compareVersions(v, minimum)
99+ if err == nil && cmp >= 0 {
100+ if _, _, err := companionBinaries(resolved); err == nil {
101+ return resolved, v, nil
102+ }
103+ }
104+ }
105+ return "", "", fmt.Errorf("refusing Wine downgrade: selected %s; prefix requires >= %s; set BNETCTL_WINE", version, minimum)
106+}
107+func readVersion(path string) (string, error) {
108+ data, err := os.ReadFile(path)
109+ if os.IsNotExist(err) {
110+ return "", nil
111+ }
112+ if err != nil {
113+ return "", err
114+ }
115+ v := strings.TrimSpace(string(data))
116+ if _, err := versionNumbers(v); err != nil {
117+ return "", fmt.Errorf("%s: %w", path, err)
118+ }
119+ return v, nil
120+}
121+func (a *Adapter) LockPrefix(prefix string) (func(), error) {
122+ if err := os.MkdirAll(prefix, 0755); err != nil {
123+ return nil, err
124+ }
125+ f, err := os.OpenFile(filepath.Join(prefix, ".bnetctl.lock"), os.O_CREATE|os.O_RDWR, 0600)
126+ if err != nil {
127+ return nil, err
128+ }
129+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
130+ _ = f.Close()
131+ return nil, fmt.Errorf("prefix is locked by another bnetctl invocation: %w", err)
132+ }
133+ var once sync.Once
134+ release := func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }
135+ a.minimum = ""
136+ versions := map[string]string{}
137+ for _, name := range []string{prefixVersionFile, prefixVersionFile + ".migrating"} {
138+ v, err := readVersion(filepath.Join(prefix, name))
139+ if err != nil {
140+ release()
141+ return nil, err
142+ }
143+ versions[name] = v
144+ if v != "" {
145+ cmp := 1
146+ if a.minimum != "" {
147+ cmp, err = compareVersions(v, a.minimum)
148+ }
149+ if err != nil {
150+ release()
151+ return nil, err
152+ }
153+ if cmp > 0 {
154+ a.minimum = v
155+ }
156+ }
157+ }
158+ a.detected = nil
159+ rt, err := a.Detect()
160+ if err != nil {
161+ release()
162+ return nil, err
163+ }
164+ if versions[prefixVersionFile+".migrating"] != "" || versions[prefixVersionFile] != rt.Version {
165+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
166+ release()
167+ return nil, err
168+ }
169+ // New prefixes have no system.reg until wineboot initializes them.
170+ if _, err := os.Stat(filepath.Join(prefix, "pfx")); err == nil {
171+ if _, err := readPrefixArch(filepath.Join(prefix, "pfx")); err != nil {
172+ release()
173+ return nil, err
174+ }
175+ } else if !os.IsNotExist(err) {
176+ release()
177+ return nil, err
178+ }
179+ }
180+ return release, nil
181+}
A internal/adapters/wine/safety_test.go +151 -0
@@ -0,0 +1,151 @@
1+package wine
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/charmbracelet/log"
11+)
12+
13+func TestRunOncePreflight(t *testing.T) {
14+ for _, file := range []string{"system.reg", "user.reg"} {
15+ for _, key := range []string{
16+ `Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce`,
17+ `Software\\Microsoft\\Windows\\CurrentVersion\\RunServicesOnce`,
18+ `Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\RunOnce`,
19+ `Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\RunServicesOnce`,
20+ } {
21+ t.Run(file+key, func(t *testing.T) {
22+ a, prefix, calls := migrationFixture(t)
23+ mustWrite(t, filepath.Join(prefix, "pfx", file), "["+key+"] 123\n\"Battle.net\"=\"run me\"\n")
24+ err := a.UpdatePrefix(prefix, nil)
25+ if err == nil || !strings.Contains(err.Error(), "Battle.net") || !strings.Contains(err.Error(), file) {
26+ t.Fatalf("preflight: %v", err)
27+ }
28+ if _, err := os.Stat(calls); !os.IsNotExist(err) {
29+ t.Fatal("preflight ran Wine")
30+ }
31+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile+".migrating")); !os.IsNotExist(err) {
32+ t.Fatal("preflight mutated marker")
33+ }
34+ // An empty RunOnce key and populated Run key are safe with -r.
35+ mustWrite(t, filepath.Join(prefix, "pfx", file), "["+key+"] 123\n#time=abc\n[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run]\n\"Battle.net\"=\"run me\"\n")
36+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
37+ t.Fatal(err)
38+ }
39+ })
40+ }
41+ }
42+}
43+func fakeVersionInstall(t *testing.T, version string) string {
44+ t.Helper()
45+ dir := t.TempDir()
46+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
47+ mustWrite(t, filepath.Join(dir, name), "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo '"+version+"'; exit 0; fi\nexit 97\n")
48+ }
49+ return dir
50+}
51+func TestPrefixLockAndMinimum(t *testing.T) {
52+ dir := fakeVersionInstall(t, "wine-11.21")
53+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
54+ prefix := t.TempDir()
55+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-11.19\n")
56+ a := NewAdapter(log.New(io.Discard))
57+ release, err := a.LockPrefix(prefix)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ defer release()
62+ b := NewAdapter(log.New(io.Discard))
63+ if _, err := b.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "locked") {
64+ t.Fatalf("second lock: %v", err)
65+ }
66+ release()
67+ release()
68+ release2, err := b.LockPrefix(prefix)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ release2()
73+ // The in-progress version is also a floor, even with an older committed marker.
74+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-12.0\n")
75+ if _, err := a.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "refusing Wine downgrade") {
76+ t.Fatalf("recovery downgrade: %v", err)
77+ }
78+}
79+func TestMinimumWineSelection(t *testing.T) {
80+ dir := fakeVersionInstall(t, "wine-11.21")
81+ t.Setenv("PATH", dir)
82+ t.Setenv(wineOverrideEnv, "")
83+ path, version, err := chooseMinimumWine("old", "wine-10.0", "wine-11.21")
84+ if err != nil || path != filepath.Join(dir, "wine") || version != "wine-11.21" {
85+ t.Fatalf("selection: %s %s %v", path, version, err)
86+ }
87+ t.Setenv(wineOverrideEnv, "old")
88+ if _, _, err := chooseMinimumWine("old", "wine-10.0", "wine-11.21"); err == nil {
89+ t.Fatal("explicit downgrade accepted")
90+ }
91+ t.Setenv(wineOverrideEnv, "")
92+ if _, _, err := chooseMinimumWine("old", "wine-10.0", "wine-999.0"); err == nil {
93+ t.Fatal("unavailable minimum accepted")
94+ }
95+}
96+func TestVersionComparison(t *testing.T) {
97+ for _, tt := range []struct {
98+ a, b string
99+ want int
100+ }{{"wine-11.21", "wine-11.19", 1}, {"wine-10.0-20260425 (CachyOS)", "wine-11.21", -1}, {"wine-11.21.1", "wine-11.21", 1}, {"wine-11.21 (Staging)", "wine-11.21.0", 0}} {
101+ cmp, err := compareVersions(tt.a, tt.b)
102+ if err != nil || cmp != tt.want {
103+ t.Fatalf("%s vs %s: %d %v", tt.a, tt.b, cmp, err)
104+ }
105+ }
106+}
107+func TestUpdateRefusesDowngradeWithoutWine(t *testing.T) {
108+ a, prefix, calls := migrationFixture(t)
109+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-12.0\n")
110+ if err := a.UpdatePrefix(prefix, nil); err == nil || !strings.Contains(err.Error(), "downgrade") {
111+ t.Fatalf("result: %v", err)
112+ }
113+ if _, err := os.Stat(calls); !os.IsNotExist(err) {
114+ t.Fatal("downgrade ran Wine")
115+ }
116+}
117+
118+func TestLockPreflightDoesNotProbeWine(t *testing.T) {
119+ a, prefix, calls := migrationFixture(t)
120+ mustWrite(t, filepath.Join(prefix, "pfx", "user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n@=\"pending\"\n")
121+ dir := fakeVersionInstall(t, "wine-11.21")
122+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
123+ if _, err := a.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "pending") {
124+ t.Fatalf("preflight: %v", err)
125+ }
126+ if _, err := os.Stat(calls); !os.IsNotExist(err) {
127+ t.Fatal("preflight probed Wine")
128+ }
129+}
130+
131+func TestMatchingVersionAllowsRunOnce(t *testing.T) {
132+ a, prefix, _ := migrationFixture(t)
133+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-11.21\n")
134+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"allowed on normal launch\"\n")
135+ dir := fakeVersionInstall(t, "wine-11.21")
136+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
137+ release, err := a.LockPrefix(prefix)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ defer release()
142+ if err := a.UpdatePrefix(prefix, nil); err != nil {
143+ t.Fatalf("matching version refused RunOnce: %v", err)
144+ }
145+ release()
146+ // Recovery still needs the preflight even when the committed version matches.
147+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
148+ if _, err := a.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "RunOnce") {
149+ t.Fatalf("recovery skipped preflight: %v", err)
150+ }
151+}
@@ -0,0 +1,151 @@
1+package wine
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/charmbracelet/log"
11+)
12+
13+func TestRunOncePreflight(t *testing.T) {
14+ for _, file := range []string{"system.reg", "user.reg"} {
15+ for _, key := range []string{
16+ `Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce`,
17+ `Software\\Microsoft\\Windows\\CurrentVersion\\RunServicesOnce`,
18+ `Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\RunOnce`,
19+ `Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\RunServicesOnce`,
20+ } {
21+ t.Run(file+key, func(t *testing.T) {
22+ a, prefix, calls := migrationFixture(t)
23+ mustWrite(t, filepath.Join(prefix, "pfx", file), "["+key+"] 123\n\"Battle.net\"=\"run me\"\n")
24+ err := a.UpdatePrefix(prefix, nil)
25+ if err == nil || !strings.Contains(err.Error(), "Battle.net") || !strings.Contains(err.Error(), file) {
26+ t.Fatalf("preflight: %v", err)
27+ }
28+ if _, err := os.Stat(calls); !os.IsNotExist(err) {
29+ t.Fatal("preflight ran Wine")
30+ }
31+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile+".migrating")); !os.IsNotExist(err) {
32+ t.Fatal("preflight mutated marker")
33+ }
34+ // An empty RunOnce key and populated Run key are safe with -r.
35+ mustWrite(t, filepath.Join(prefix, "pfx", file), "["+key+"] 123\n#time=abc\n[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run]\n\"Battle.net\"=\"run me\"\n")
36+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
37+ t.Fatal(err)
38+ }
39+ })
40+ }
41+ }
42+}
43+func fakeVersionInstall(t *testing.T, version string) string {
44+ t.Helper()
45+ dir := t.TempDir()
46+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
47+ mustWrite(t, filepath.Join(dir, name), "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo '"+version+"'; exit 0; fi\nexit 97\n")
48+ }
49+ return dir
50+}
51+func TestPrefixLockAndMinimum(t *testing.T) {
52+ dir := fakeVersionInstall(t, "wine-11.21")
53+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
54+ prefix := t.TempDir()
55+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-11.19\n")
56+ a := NewAdapter(log.New(io.Discard))
57+ release, err := a.LockPrefix(prefix)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ defer release()
62+ b := NewAdapter(log.New(io.Discard))
63+ if _, err := b.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "locked") {
64+ t.Fatalf("second lock: %v", err)
65+ }
66+ release()
67+ release()
68+ release2, err := b.LockPrefix(prefix)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ release2()
73+ // The in-progress version is also a floor, even with an older committed marker.
74+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-12.0\n")
75+ if _, err := a.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "refusing Wine downgrade") {
76+ t.Fatalf("recovery downgrade: %v", err)
77+ }
78+}
79+func TestMinimumWineSelection(t *testing.T) {
80+ dir := fakeVersionInstall(t, "wine-11.21")
81+ t.Setenv("PATH", dir)
82+ t.Setenv(wineOverrideEnv, "")
83+ path, version, err := chooseMinimumWine("old", "wine-10.0", "wine-11.21")
84+ if err != nil || path != filepath.Join(dir, "wine") || version != "wine-11.21" {
85+ t.Fatalf("selection: %s %s %v", path, version, err)
86+ }
87+ t.Setenv(wineOverrideEnv, "old")
88+ if _, _, err := chooseMinimumWine("old", "wine-10.0", "wine-11.21"); err == nil {
89+ t.Fatal("explicit downgrade accepted")
90+ }
91+ t.Setenv(wineOverrideEnv, "")
92+ if _, _, err := chooseMinimumWine("old", "wine-10.0", "wine-999.0"); err == nil {
93+ t.Fatal("unavailable minimum accepted")
94+ }
95+}
96+func TestVersionComparison(t *testing.T) {
97+ for _, tt := range []struct {
98+ a, b string
99+ want int
100+ }{{"wine-11.21", "wine-11.19", 1}, {"wine-10.0-20260425 (CachyOS)", "wine-11.21", -1}, {"wine-11.21.1", "wine-11.21", 1}, {"wine-11.21 (Staging)", "wine-11.21.0", 0}} {
101+ cmp, err := compareVersions(tt.a, tt.b)
102+ if err != nil || cmp != tt.want {
103+ t.Fatalf("%s vs %s: %d %v", tt.a, tt.b, cmp, err)
104+ }
105+ }
106+}
107+func TestUpdateRefusesDowngradeWithoutWine(t *testing.T) {
108+ a, prefix, calls := migrationFixture(t)
109+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-12.0\n")
110+ if err := a.UpdatePrefix(prefix, nil); err == nil || !strings.Contains(err.Error(), "downgrade") {
111+ t.Fatalf("result: %v", err)
112+ }
113+ if _, err := os.Stat(calls); !os.IsNotExist(err) {
114+ t.Fatal("downgrade ran Wine")
115+ }
116+}
117+
118+func TestLockPreflightDoesNotProbeWine(t *testing.T) {
119+ a, prefix, calls := migrationFixture(t)
120+ mustWrite(t, filepath.Join(prefix, "pfx", "user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n@=\"pending\"\n")
121+ dir := fakeVersionInstall(t, "wine-11.21")
122+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
123+ if _, err := a.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "pending") {
124+ t.Fatalf("preflight: %v", err)
125+ }
126+ if _, err := os.Stat(calls); !os.IsNotExist(err) {
127+ t.Fatal("preflight probed Wine")
128+ }
129+}
130+
131+func TestMatchingVersionAllowsRunOnce(t *testing.T) {
132+ a, prefix, _ := migrationFixture(t)
133+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-11.21\n")
134+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"allowed on normal launch\"\n")
135+ dir := fakeVersionInstall(t, "wine-11.21")
136+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
137+ release, err := a.LockPrefix(prefix)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ defer release()
142+ if err := a.UpdatePrefix(prefix, nil); err != nil {
143+ t.Fatalf("matching version refused RunOnce: %v", err)
144+ }
145+ release()
146+ // Recovery still needs the preflight even when the committed version matches.
147+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
148+ if _, err := a.LockPrefix(prefix); err == nil || !strings.Contains(err.Error(), "RunOnce") {
149+ t.Fatalf("recovery skipped preflight: %v", err)
150+ }
151+}
A internal/adapters/wine/version.go +35 -0
@@ -0,0 +1,35 @@
1+package wine
2+
3+import (
4+ "regexp"
5+ "strconv"
6+)
7+
8+var wineVersionPattern = regexp.MustCompile(`^wine-([0-9]+)\.([0-9]+)(?:$|[-. ])`)
9+
10+func supportsWaylandHDR(version string) bool {
11+ parts := wineVersionPattern.FindStringSubmatch(version)
12+ if parts == nil {
13+ return false
14+ }
15+ major, err := strconv.Atoi(parts[1])
16+ if err != nil {
17+ return false
18+ }
19+ minor, err := strconv.Atoi(parts[2])
20+ if err != nil {
21+ return false
22+ }
23+ return major > 11 || major == 11 && minor >= 19
24+}
25+
26+// Keep the legacy preference unless a candidate meets the HDR minimum.
27+func selectHDRWine(preferred, pathWine string, version func(string) string) string {
28+ if supportsWaylandHDR(version(preferred)) {
29+ return preferred
30+ }
31+ if pathWine != "" && pathWine != preferred && supportsWaylandHDR(version(pathWine)) {
32+ return pathWine
33+ }
34+ return preferred
35+}
@@ -0,0 +1,35 @@
1+package wine
2+
3+import (
4+ "regexp"
5+ "strconv"
6+)
7+
8+var wineVersionPattern = regexp.MustCompile(`^wine-([0-9]+)\.([0-9]+)(?:$|[-. ])`)
9+
10+func supportsWaylandHDR(version string) bool {
11+ parts := wineVersionPattern.FindStringSubmatch(version)
12+ if parts == nil {
13+ return false
14+ }
15+ major, err := strconv.Atoi(parts[1])
16+ if err != nil {
17+ return false
18+ }
19+ minor, err := strconv.Atoi(parts[2])
20+ if err != nil {
21+ return false
22+ }
23+ return major > 11 || major == 11 && minor >= 19
24+}
25+
26+// Keep the legacy preference unless a candidate meets the HDR minimum.
27+func selectHDRWine(preferred, pathWine string, version func(string) string) string {
28+ if supportsWaylandHDR(version(preferred)) {
29+ return preferred
30+ }
31+ if pathWine != "" && pathWine != preferred && supportsWaylandHDR(version(pathWine)) {
32+ return pathWine
33+ }
34+ return preferred
35+}
A internal/adapters/wine/version_test.go +84 -0
@@ -0,0 +1,84 @@
1+package wine
2+
3+import (
4+ "bytes"
5+ "github.com/charmbracelet/log"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestSupportsWaylandHDR(t *testing.T) {
13+ for _, tt := range []struct {
14+ version string
15+ want bool
16+ }{
17+ {"wine-11.19", true}, {"wine-11.21", true}, {"wine-12.0", true},
18+ {"wine-10.0-20260425 (CachyOS)", false}, {"wine-11.19-20260425 (CachyOS)", true},
19+ {"wine-11.19 (Staging)", true}, {"wine-11.18 (Staging)", false},
20+ {"", false}, {"garbage", false}, {"wine-11", false}, {"wine-11.19oops", false},
21+ {"wine-9999999999999999999999999.0", false},
22+ } {
23+ t.Run(tt.version, func(t *testing.T) {
24+ if got := supportsWaylandHDR(tt.version); got != tt.want {
25+ t.Fatalf("got %v, want %v", got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestSelectHDRWine(t *testing.T) {
32+ for _, tt := range []struct{ name, preferredVersion, pathVersion, path, want string }{
33+ {"both capable", "wine-11.19", "wine-11.21", "path", "preferred"},
34+ {"path capable", "wine-10.0", "wine-11.21", "path", "path"},
35+ {"neither capable", "wine-10.0", "wine-11.18", "path", "preferred"},
36+ {"preferred malformed", "bad", "wine-11.21", "path", "path"},
37+ {"path malformed", "wine-10.0", "bad", "path", "preferred"},
38+ {"path absent", "wine-10.0", "", "", "preferred"},
39+ } {
40+ t.Run(tt.name, func(t *testing.T) {
41+ got := selectHDRWine("preferred", tt.path, func(path string) string {
42+ if path == "preferred" {
43+ return tt.preferredVersion
44+ }
45+ return tt.pathVersion
46+ })
47+ if got != tt.want {
48+ t.Fatalf("got %s, want %s", got, tt.want)
49+ }
50+ })
51+ }
52+}
53+
54+func TestWaylandOverrideAndCachedDetection(t *testing.T) {
55+ dir := t.TempDir()
56+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
57+ if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\necho wine-10.0\n"), 0755); err != nil {
58+ t.Fatal(err)
59+ }
60+ }
61+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
62+ var output bytes.Buffer
63+ adapter := NewWaylandAdapter(log.New(&output))
64+ first, err := adapter.Detect()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ second, err := adapter.Detect()
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if first != second {
73+ t.Fatal("runtime was not cached")
74+ }
75+ if first.WineBin != filepath.Join(dir, "wine") || first.WineBootBin != filepath.Join(dir, "wineboot") || first.WineServerBin != filepath.Join(dir, "wineserver") {
76+ t.Fatalf("override not preserved: %+v", first)
77+ }
78+ if strings.Count(output.String(), "selected wine runtime") != 1 {
79+ t.Fatalf("expected one selection log: %s", output.String())
80+ }
81+ if !strings.Contains(output.String(), "HDR on Wayland needs Wine >= 11.19 (found wine-10.0); set BNETCTL_WINE") {
82+ t.Fatalf("missing warning: %s", output.String())
83+ }
84+}
@@ -0,0 +1,84 @@
1+package wine
2+
3+import (
4+ "bytes"
5+ "github.com/charmbracelet/log"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestSupportsWaylandHDR(t *testing.T) {
13+ for _, tt := range []struct {
14+ version string
15+ want bool
16+ }{
17+ {"wine-11.19", true}, {"wine-11.21", true}, {"wine-12.0", true},
18+ {"wine-10.0-20260425 (CachyOS)", false}, {"wine-11.19-20260425 (CachyOS)", true},
19+ {"wine-11.19 (Staging)", true}, {"wine-11.18 (Staging)", false},
20+ {"", false}, {"garbage", false}, {"wine-11", false}, {"wine-11.19oops", false},
21+ {"wine-9999999999999999999999999.0", false},
22+ } {
23+ t.Run(tt.version, func(t *testing.T) {
24+ if got := supportsWaylandHDR(tt.version); got != tt.want {
25+ t.Fatalf("got %v, want %v", got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestSelectHDRWine(t *testing.T) {
32+ for _, tt := range []struct{ name, preferredVersion, pathVersion, path, want string }{
33+ {"both capable", "wine-11.19", "wine-11.21", "path", "preferred"},
34+ {"path capable", "wine-10.0", "wine-11.21", "path", "path"},
35+ {"neither capable", "wine-10.0", "wine-11.18", "path", "preferred"},
36+ {"preferred malformed", "bad", "wine-11.21", "path", "path"},
37+ {"path malformed", "wine-10.0", "bad", "path", "preferred"},
38+ {"path absent", "wine-10.0", "", "", "preferred"},
39+ } {
40+ t.Run(tt.name, func(t *testing.T) {
41+ got := selectHDRWine("preferred", tt.path, func(path string) string {
42+ if path == "preferred" {
43+ return tt.preferredVersion
44+ }
45+ return tt.pathVersion
46+ })
47+ if got != tt.want {
48+ t.Fatalf("got %s, want %s", got, tt.want)
49+ }
50+ })
51+ }
52+}
53+
54+func TestWaylandOverrideAndCachedDetection(t *testing.T) {
55+ dir := t.TempDir()
56+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
57+ if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\necho wine-10.0\n"), 0755); err != nil {
58+ t.Fatal(err)
59+ }
60+ }
61+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
62+ var output bytes.Buffer
63+ adapter := NewWaylandAdapter(log.New(&output))
64+ first, err := adapter.Detect()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ second, err := adapter.Detect()
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if first != second {
73+ t.Fatal("runtime was not cached")
74+ }
75+ if first.WineBin != filepath.Join(dir, "wine") || first.WineBootBin != filepath.Join(dir, "wineboot") || first.WineServerBin != filepath.Join(dir, "wineserver") {
76+ t.Fatalf("override not preserved: %+v", first)
77+ }
78+ if strings.Count(output.String(), "selected wine runtime") != 1 {
79+ t.Fatalf("expected one selection log: %s", output.String())
80+ }
81+ if !strings.Contains(output.String(), "HDR on Wayland needs Wine >= 11.19 (found wine-10.0); set BNETCTL_WINE") {
82+ t.Fatalf("missing warning: %s", output.String())
83+ }
84+}
M internal/ports/runtime.go +6 -0
@@ -8,9 +8,15 @@ import (
88
99 // RuntimePort defines operations for running Windows executables via a compatibility layer
1010 type RuntimePort interface {
11+ // LockPrefix locks maintenance through process start and selects a non-downgrading runtime.
12+ LockPrefix(prefixPath string) (release func(), err error)
13+
1114 // Detect checks if wine-cachyos is available on the system
1215 Detect() (*domain.WineRuntime, error)
1316
17+ // UpdatePrefix updates the prefix and graphics when the selected Wine version changes.
18+ UpdatePrefix(prefixPath string, env *domain.WineEnv) error
19+
1420 // CreatePrefix initializes a new Wine prefix at the given path
1521 CreatePrefix(prefixPath string) error
1622
@@ -8,9 +8,15 @@ import (
8 8
9 // RuntimePort defines operations for running Windows executables via a compatibility layer9 // RuntimePort defines operations for running Windows executables via a compatibility layer
10 type RuntimePort interface {10 type RuntimePort interface {
11+ // LockPrefix locks maintenance through process start and selects a non-downgrading runtime.
12+ LockPrefix(prefixPath string) (release func(), err error)
13+
11 // Detect checks if wine-cachyos is available on the system14 // Detect checks if wine-cachyos is available on the system
12 Detect() (*domain.WineRuntime, error)15 Detect() (*domain.WineRuntime, error)
13 16
17+ // UpdatePrefix updates the prefix and graphics when the selected Wine version changes.
18+ UpdatePrefix(prefixPath string, env *domain.WineEnv) error
19+
14 // CreatePrefix initializes a new Wine prefix at the given path20 // CreatePrefix initializes a new Wine prefix at the given path
15 CreatePrefix(prefixPath string) error21 CreatePrefix(prefixPath string) error
16 22
M internal/services/installer.go +9 -0
@@ -83,6 +83,11 @@ func (s *InstallerService) Install(progressFn func(InstallProgress)) (*domain.In
8383 }
8484 }
8585
86+ release, err := s.runtime.LockPrefix(s.cfg.PrefixDir)
87+ if err != nil {
88+ return nil, err
89+ }
90+ defer release()
8691 // Step 1: Detect runtime
8792 log.Debug("detecting wine runtime")
8893 rt, err := s.runtime.Detect()
@@ -139,12 +144,16 @@ func (s *InstallerService) Install(progressFn func(InstallProgress)) (*domain.In
139144 if s.envFn != nil {
140145 wineEnv = s.envFn()
141146 }
147+ if err := s.runtime.UpdatePrefix(s.cfg.PrefixDir, wineEnv); err != nil {
148+ return nil, fmt.Errorf("update Wine prefix: %w", err)
149+ }
142150 done, err := s.runtime.RunExeAsync(s.cfg.PrefixDir, setupPath, wineEnv, s.exeArgs...)
143151 if err != nil {
144152 log.Error("start installer failed", "error", err)
145153 return nil, fmt.Errorf("start Battle.net installer: %w", err)
146154 }
147155
156+ release()
148157 // Step 6: Poll until Battle.net.exe appears or installer process exits
149158 exePath := filepath.Join(s.cfg.PrefixDir, "pfx", domain.BattleNetExeRelPath)
150159 report(InstallWaitingForClient, "Waiting for Battle.net to install (this may take a few minutes)...")
@@ -83,6 +83,11 @@ func (s *InstallerService) Install(progressFn func(InstallProgress)) (*domain.In
83 }83 }
84 }84 }
85 85
86+ release, err := s.runtime.LockPrefix(s.cfg.PrefixDir)
87+ if err != nil {
88+ return nil, err
89+ }
90+ defer release()
86 // Step 1: Detect runtime91 // Step 1: Detect runtime
87 log.Debug("detecting wine runtime")92 log.Debug("detecting wine runtime")
88 rt, err := s.runtime.Detect()93 rt, err := s.runtime.Detect()
@@ -139,12 +144,16 @@ func (s *InstallerService) Install(progressFn func(InstallProgress)) (*domain.In
139 if s.envFn != nil {144 if s.envFn != nil {
140 wineEnv = s.envFn()145 wineEnv = s.envFn()
141 }146 }
147+ if err := s.runtime.UpdatePrefix(s.cfg.PrefixDir, wineEnv); err != nil {
148+ return nil, fmt.Errorf("update Wine prefix: %w", err)
149+ }
142 done, err := s.runtime.RunExeAsync(s.cfg.PrefixDir, setupPath, wineEnv, s.exeArgs...)150 done, err := s.runtime.RunExeAsync(s.cfg.PrefixDir, setupPath, wineEnv, s.exeArgs...)
143 if err != nil {151 if err != nil {
144 log.Error("start installer failed", "error", err)152 log.Error("start installer failed", "error", err)
145 return nil, fmt.Errorf("start Battle.net installer: %w", err)153 return nil, fmt.Errorf("start Battle.net installer: %w", err)
146 }154 }
147 155
156+ release()
148 // Step 6: Poll until Battle.net.exe appears or installer process exits157 // Step 6: Poll until Battle.net.exe appears or installer process exits
149 exePath := filepath.Join(s.cfg.PrefixDir, "pfx", domain.BattleNetExeRelPath)158 exePath := filepath.Join(s.cfg.PrefixDir, "pfx", domain.BattleNetExeRelPath)
150 report(InstallWaitingForClient, "Waiting for Battle.net to install (this may take a few minutes)...")159 report(InstallWaitingForClient, "Waiting for Battle.net to install (this may take a few minutes)...")
M internal/services/launcher.go +9 -0
@@ -53,6 +53,11 @@ func NewLaunchService(
5353 func (s *LaunchService) Launch() (*LaunchResult, error) {
5454 log := s.log
5555
56+ release, err := s.runtime.LockPrefix(s.cfg.PrefixDir)
57+ if err != nil {
58+ return nil, err
59+ }
60+ defer release()
5661 // Check wine is available
5762 log.Debug("checking wine runtime")
5863 rt, err := s.runtime.Detect()
@@ -80,6 +85,9 @@ func (s *LaunchService) Launch() (*LaunchResult, error) {
8085 if s.envFn != nil {
8186 env = s.envFn()
8287 }
88+ if err := s.runtime.UpdatePrefix(s.cfg.PrefixDir, env); err != nil {
89+ return nil, fmt.Errorf("update Wine prefix: %w", err)
90+ }
8391 log.Debug("ensuring battle.net config")
8492 if err := EnsureBattleNetConfig(s.cfg.PrefixDir, s.fs, s.log); err != nil {
8593 log.Error("ensure battle.net config failed", "error", err)
@@ -103,6 +111,7 @@ func (s *LaunchService) Launch() (*LaunchResult, error) {
103111 return nil, fmt.Errorf("launch Battle.net: %w", err)
104112 }
105113
114+ release()
106115 // Trap signals for graceful shutdown
107116 sigCh := make(chan os.Signal, 1)
108117 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@@ -53,6 +53,11 @@ func NewLaunchService(
53 func (s *LaunchService) Launch() (*LaunchResult, error) {53 func (s *LaunchService) Launch() (*LaunchResult, error) {
54 log := s.log54 log := s.log
55 55
56+ release, err := s.runtime.LockPrefix(s.cfg.PrefixDir)
57+ if err != nil {
58+ return nil, err
59+ }
60+ defer release()
56 // Check wine is available61 // Check wine is available
57 log.Debug("checking wine runtime")62 log.Debug("checking wine runtime")
58 rt, err := s.runtime.Detect()63 rt, err := s.runtime.Detect()
@@ -80,6 +85,9 @@ func (s *LaunchService) Launch() (*LaunchResult, error) {
80 if s.envFn != nil {85 if s.envFn != nil {
81 env = s.envFn()86 env = s.envFn()
82 }87 }
88+ if err := s.runtime.UpdatePrefix(s.cfg.PrefixDir, env); err != nil {
89+ return nil, fmt.Errorf("update Wine prefix: %w", err)
90+ }
83 log.Debug("ensuring battle.net config")91 log.Debug("ensuring battle.net config")
84 if err := EnsureBattleNetConfig(s.cfg.PrefixDir, s.fs, s.log); err != nil {92 if err := EnsureBattleNetConfig(s.cfg.PrefixDir, s.fs, s.log); err != nil {
85 log.Error("ensure battle.net config failed", "error", err)93 log.Error("ensure battle.net config failed", "error", err)
@@ -103,6 +111,7 @@ func (s *LaunchService) Launch() (*LaunchResult, error) {
103 return nil, fmt.Errorf("launch Battle.net: %w", err)111 return nil, fmt.Errorf("launch Battle.net: %w", err)
104 }112 }
105 113
114+ release()
106 // Trap signals for graceful shutdown115 // Trap signals for graceful shutdown
107 sigCh := make(chan os.Signal, 1)116 sigCh := make(chan os.Signal, 1)
108 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)117 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
M internal/services/launcher_test.go +4 -0
@@ -85,3 +85,7 @@ func TestLaunchDisablesSystrayBeforeStartingBattleNet(t *testing.T) {
8585 t.Fatal("DisableSystray and RunExeAsync received different Wine environments")
8686 }
8787 }
88+
89+func (f *fakeRuntime) UpdatePrefix(string, *domain.WineEnv) error { return nil }
90+
91+func (f *fakeRuntime) LockPrefix(string) (func(), error) { return func() {}, nil }
@@ -85,3 +85,7 @@ func TestLaunchDisablesSystrayBeforeStartingBattleNet(t *testing.T) {
85 t.Fatal("DisableSystray and RunExeAsync received different Wine environments")85 t.Fatal("DisableSystray and RunExeAsync received different Wine environments")
86 }86 }
87 }87 }
88+
89+func (f *fakeRuntime) UpdatePrefix(string, *domain.WineEnv) error { return nil }
90+
91+func (f *fakeRuntime) LockPrefix(string) (func(), error) { return func() {}, nil }

Comments00 unresolved

    no comments yet.

    Keyboard shortcuts

    Single-key shortcuts are on. ctrl k and esc work whatever this says.

    ctrl k :
    go to a section, branch, tag, file or repository
    ?
    this list
    j k
    move the row cursor down / up
    enter
    open the row under the cursor; on Files, a directory opens or closes in place
    ↑ ↓ ← → home end
    in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
    l h
    on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. On pages with a side panel: into the panel (on a merge request, its files) / back to the content; there j k move between its controls
    g g G
    first / last row
    /
    filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
    b
    switch branch or tag
    g s g f g c g b g m g i g r g a
    tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
    g h
    home
    w
    wide mode: use the whole width of the screen, or not (remembered)
    y
    copy a permalink pinned to the commit; in a file, click a line number to select it, shift-click for a range
    d u x
    diff: split / unified / collapse or expand all
    e E
    diff: the whole file in view / every file, with the lines the hunks hide
    n
    merge request: next note in the diff
    l
    merge request: the list of files (j k move, enter opens, esc back)
    M
    merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
    R
    merge request: mark the head read
    esc
    close a dialog, leave a field

    Branches 2

    Tags 2

    ↑↓ moveenter openesc close