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.
#1 feat(wine): native Wayland HDR with safe Wine prefix upgrades
M README.md +54 -1
| @@ -43,7 +43,7 @@ bnetctl clean -a # Full purge (cache, prefix, desktop entry, data) | |||
| 43 | 43 | ||
| 44 | 44 | ## What it does | |
| 45 | 45 | ||
| 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 | |
| 47 | 47 | - Creates a Wine prefix with DXVK + VKD3D-proton auto-installed | |
| 48 | 48 | - Downloads and runs the official Battle.net installer | |
| 49 | 49 | - 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 | 52 | - Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries | |
| 53 | 53 | - 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 | 108 | ## Directories | |
| 56 | 109 | ||
| 57 | 110 | | Type | Path | | |
| @@ -43,7 +43,7 @@ bnetctl clean -a # Full purge (cache, prefix, desktop entry, data) | |||||
| 43 | 43 | ||||
| 44 | ## What it does | 44 | ## What it does | ||
| 45 | 45 | ||||
| 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 |
| 47 | - Creates a Wine prefix with DXVK + VKD3D-proton auto-installed | 47 | - Creates a Wine prefix with DXVK + VKD3D-proton auto-installed | ||
| 48 | - Downloads and runs the official Battle.net installer | 48 | - 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 XWayland | 49 | - 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 entries | 52 | - Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries | ||
| 53 | - Graceful process management with orphan cleanup | 53 | - 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 | ## Directories | 108 | ## 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 { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | runtime := wine.NewAdapter(log) | |
| 61 | + | if driver == env.DisplayDriverWayland { | |
| 62 | + | runtime = wine.NewWaylandAdapter(log) | |
| 63 | + | } | |
| 61 | 64 | downloader := http.NewDownloader() | |
| 62 | 65 | ||
| 63 | 66 | 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 ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | var displayDriver string | |
| 18 | + | var noHDR bool | |
| 18 | 19 | ||
| 19 | 20 | var launchCmd = &cobra.Command{ | |
| 20 | 21 | Use: "launch", | |
| @@ -25,6 +26,7 @@ var launchCmd = &cobra.Command{ | |||
| 25 | 26 | ||
| 26 | 27 | func init() { | |
| 27 | 28 | registerDisplayDriverFlag(launchCmd, &displayDriver) | |
| 29 | + | launchCmd.Flags().BoolVar(&noHDR, "no-hdr", false, "Disable automatic Wayland HDR") | |
| 28 | 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 | 46 | runtime := wine.NewAdapter(log) | |
| 47 | + | if driver == env.DisplayDriverWayland { | |
| 48 | + | runtime = wine.NewWaylandAdapter(log) | |
| 49 | + | } | |
| 45 | 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 | 52 | fmt.Println(styles.StepPrefix.Render("Launching Battle.net...")) | |
| 52 | 53 | ||
| 53 | 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 | 58 | }, log, env.BattleNetArgsForDisplay(driver)...) | |
| 56 | 59 | ||
| 57 | 60 | log.Info("launching battle.net") | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | var displayDriver string | 17 | 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 { | |||
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 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 | 97 | env := &domain.WineEnv{ | |
| 93 | 98 | Vars: make(map[string]string), | |
| 94 | 99 | } | |
| @@ -110,6 +115,11 @@ func BuildGPUEnvForDisplay(driver DisplayDriver) *domain.WineEnv { | |||
| 110 | 115 | if driver == DisplayDriverWayland { | |
| 111 | 116 | // Without an X11 display, Wine selects its native Wayland driver. | |
| 112 | 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 | 125 | // 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 | 146 | 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 | + | } | |
| @@ -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 Vulkan | 125 | // 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 env | 146 | 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 ( | |||
| 25 | 25 | ||
| 26 | 26 | // Adapter implements ports.RuntimePort using wine-cachyos directly | |
| 27 | 27 | type Adapter struct { | |
| 28 | - | log *log.Logger | |
| 28 | + | log *log.Logger | |
| 29 | + | wayland bool | |
| 30 | + | detected *domain.WineRuntime | |
| 31 | + | minimum string | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | var _ ports.RuntimePort = (*Adapter)(nil) | |
| @@ -35,11 +38,19 @@ func NewAdapter(log *log.Logger) *Adapter { | |||
| 35 | 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 | 46 | // Detect checks if wine-cachyos is available and returns runtime info | |
| 39 | 47 | func (a *Adapter) Detect() (*domain.WineRuntime, error) { | |
| 40 | 48 | 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 | 54 | if err != nil { | |
| 44 | 55 | return nil, err | |
| 45 | 56 | } | |
| @@ -53,6 +64,17 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) { | |||
| 53 | 64 | version := strings.TrimSpace(string(out)) | |
| 54 | 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 | 78 | // Check NTSync availability | |
| 57 | 79 | hasNTSync := false | |
| 58 | 80 | if _, err := os.Stat("/dev/ntsync"); err == nil { | |
| @@ -67,7 +89,11 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) { | |||
| 67 | 89 | // Check VKD3D-proton setup availability | |
| 68 | 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 | 97 | WineBin: wineBin, | |
| 72 | 98 | WineBootBin: wineBootBin, | |
| 73 | 99 | WineServerBin: wineServerBin, | |
| @@ -77,7 +103,8 @@ func (a *Adapter) Detect() (*domain.WineRuntime, error) { | |||
| 77 | 103 | DXVKSetupBin: dxvkSetupBin, | |
| 78 | 104 | HasVKD3DSetup: vkd3dSetupBin != "", | |
| 79 | 105 | VKD3DSetupBin: vkd3dSetupBin, | |
| 80 | - | }, nil | |
| 106 | + | } | |
| 107 | + | return a.detected, nil | |
| 81 | 108 | } | |
| 82 | 109 | ||
| 83 | 110 | // CreatePrefix initializes a new Wine prefix at the given path | |
| @@ -89,6 +116,9 @@ func (a *Adapter) CreatePrefix(prefixPath string) error { | |||
| 89 | 116 | return err | |
| 90 | 117 | } | |
| 91 | 118 | ||
| 119 | + | if err := writeVersionRecord(prefixPath, prefixVersionFile+".migrating", rt.Version); err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 92 | 122 | pfxDir := filepath.Join(prefixPath, "pfx") | |
| 93 | 123 | log.Info("creating wine prefix", "path", pfxDir) | |
| 94 | 124 | if err := os.MkdirAll(pfxDir, 0o755); err != nil { | |
| @@ -116,35 +146,16 @@ func (a *Adapter) CreatePrefix(prefixPath string) error { | |||
| 116 | 146 | return fmt.Errorf("wineserver -w failed: %w", err) | |
| 117 | 147 | } | |
| 118 | 148 | ||
| 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 | |
| 133 | 151 | } | |
| 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 | |
| 146 | 154 | } | |
| 147 | 155 | ||
| 156 | + | if err := os.Remove(filepath.Join(prefixPath, prefixVersionFile+".migrating")); err != nil { | |
| 157 | + | return err | |
| 158 | + | } | |
| 148 | 159 | // Disable Wine's systray window (tiled as a full-size empty column on | |
| 149 | 160 | // Wayland compositors) before the prefix is used. | |
| 150 | 161 | log.Debug("disabling wine systray") | |
| @@ -241,7 +252,7 @@ func (a *Adapter) RunExeAsync(prefixPath string, exePath string, wineEnv *domain | |||
| 241 | 252 | func (a *Adapter) IsProcessRunning(prefixPath string) bool { | |
| 242 | 253 | log := a.log | |
| 243 | 254 | ||
| 244 | - | _, _, wineServerBin, err := resolveBinaries() | |
| 255 | + | _, _, wineServerBin, err := a.binaries() | |
| 245 | 256 | if err != nil { | |
| 246 | 257 | return false | |
| 247 | 258 | } | |
| @@ -259,7 +270,7 @@ func (a *Adapter) IsProcessRunning(prefixPath string) bool { | |||
| 259 | 270 | func (a *Adapter) stopWineserver(prefixPath string) error { | |
| 260 | 271 | log := a.log | |
| 261 | 272 | ||
| 262 | - | _, _, wineServerBin, err := resolveBinaries() | |
| 273 | + | _, _, wineServerBin, err := a.binaries() | |
| 263 | 274 | if err != nil { | |
| 264 | 275 | return err | |
| 265 | 276 | } | |
| @@ -482,7 +493,7 @@ func processNames(processes []prefixProcess) domain.StoppedProcesses { | |||
| 482 | 493 | // The waiter is killed and reaped once the deadline passes, so it cannot outlive | |
| 483 | 494 | // the call, and the wineserver binary is never confused with a leftover to sweep. | |
| 484 | 495 | func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) error { | |
| 485 | - | _, _, wineServerBin, err := resolveBinaries() | |
| 496 | + | _, _, wineServerBin, err := a.binaries() | |
| 486 | 497 | if err != nil { | |
| 487 | 498 | return err | |
| 488 | 499 | } | |
| @@ -506,7 +517,7 @@ func (a *Adapter) waitForWineserver(prefixPath string, timeout time.Duration) er | |||
| 506 | 517 | ||
| 507 | 518 | // forceKillPrefix sends SIGKILL to the prefix's wineserver. | |
| 508 | 519 | func (a *Adapter) forceKillPrefix(prefixPath string) error { | |
| 509 | - | _, _, wineServerBin, err := resolveBinaries() | |
| 520 | + | _, _, wineServerBin, err := a.binaries() | |
| 510 | 521 | if err != nil { | |
| 511 | 522 | 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 | 686 | 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 | 695 | if wineEnv != nil { | |
| 678 | 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 | 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 | 712 | wine = os.Getenv(wineOverrideEnv) | |
| 691 | 713 | if wine == "" { | |
| 692 | 714 | if isExecutable(cachyOSWineBinary) { | |
| @@ -699,6 +721,16 @@ func resolveBinaries() (wine, wineboot, wineserver string, err error) { | |||
| 699 | 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 | 734 | if !isExecutable(wine) { | |
| 703 | 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 | 763 | 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 | + | } | |
| @@ -25,7 +25,10 @@ const ( | |||||
| 25 | 25 | ||||
| 26 | // Adapter implements ports.RuntimePort using wine-cachyos directly | 26 | // Adapter implements ports.RuntimePort using wine-cachyos directly | ||
| 27 | type Adapter struct { | 27 | type Adapter struct { | ||
| 28 | - | log *log.Logger | 28 | + | 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 info | 46 | // 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.log | 48 | 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, err | 55 | 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 availability | 78 | // Check NTSync availability | ||
| 57 | hasNTSync := false | 79 | 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 availability | 89 | // 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 | - | }, nil | 106 | + | } |
| 107 | + | return a.detected, nil | |||
| 81 | } | 108 | } | ||
| 82 | 109 | ||||
| 83 | // CreatePrefix initializes a new Wine prefix at the given path | 110 | // CreatePrefix initializes a new Wine prefix at the given path | ||
| @@ -89,6 +116,9 @@ func (a *Adapter) CreatePrefix(prefixPath string) error { | |||||
| 89 | return err | 116 | 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 available | 149 | + | 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 available | 153 | + | 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 on | 159 | // 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.log | 253 | log := a.log | ||
| 243 | 254 | ||||
| 244 | - | _, _, wineServerBin, err := resolveBinaries() | 255 | + | _, _, wineServerBin, err := a.binaries() |
| 245 | if err != nil { | 256 | if err != nil { | ||
| 246 | return false | 257 | 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.log | 271 | log := a.log | ||
| 261 | 272 | ||||
| 262 | - | _, _, wineServerBin, err := resolveBinaries() | 273 | + | _, _, wineServerBin, err := a.binaries() |
| 263 | if err != nil { | 274 | if err != nil { | ||
| 264 | return err | 275 | 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 outlive | 493 | // 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 err | 498 | 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 err | 522 | 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"] = pfxDir | 686 | 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 result | 763 | 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) { | |||
| 398 | 398 | done := make(chan error, 1) | |
| 399 | 399 | go func() { done <- cmd.Wait() }() | |
| 400 | 400 | select { | |
| 401 | - | case <-done: | |
| 401 | + | case err := <-done: | |
| 402 | + | if err != nil { | |
| 403 | + | t.Logf("process exited: %v", err) | |
| 404 | + | } | |
| 402 | 405 | case <-time.After(2 * time.Second): | |
| 403 | 406 | t.Fatalf("process %d survived the kill", cmd.Process.Pid) | |
| 404 | 407 | } | |
| @@ -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 ( | |||
| 8 | 8 | ||
| 9 | 9 | // RuntimePort defines operations for running Windows executables via a compatibility layer | |
| 10 | 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 | 14 | // Detect checks if wine-cachyos is available on the system | |
| 12 | 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 | 20 | // CreatePrefix initializes a new Wine prefix at the given path | |
| 15 | 21 | CreatePrefix(prefixPath string) error | |
| 16 | 22 | ||
| @@ -8,9 +8,15 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // RuntimePort defines operations for running Windows executables via a compatibility layer | 9 | // 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 system | 14 | // 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 path | 20 | // CreatePrefix initializes a new Wine prefix at the given path | ||
| 15 | CreatePrefix(prefixPath string) error | 21 | 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 | |||
| 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 | 91 | // Step 1: Detect runtime | |
| 87 | 92 | log.Debug("detecting wine runtime") | |
| 88 | 93 | rt, err := s.runtime.Detect() | |
| @@ -139,12 +144,16 @@ func (s *InstallerService) Install(progressFn func(InstallProgress)) (*domain.In | |||
| 139 | 144 | if s.envFn != nil { | |
| 140 | 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 | 150 | done, err := s.runtime.RunExeAsync(s.cfg.PrefixDir, setupPath, wineEnv, s.exeArgs...) | |
| 143 | 151 | if err != nil { | |
| 144 | 152 | log.Error("start installer failed", "error", err) | |
| 145 | 153 | return nil, fmt.Errorf("start Battle.net installer: %w", err) | |
| 146 | 154 | } | |
| 147 | 155 | ||
| 156 | + | release() | |
| 148 | 157 | // Step 6: Poll until Battle.net.exe appears or installer process exits | |
| 149 | 158 | exePath := filepath.Join(s.cfg.PrefixDir, "pfx", domain.BattleNetExeRelPath) | |
| 150 | 159 | 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 runtime | 91 | // 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 exits | 157 | // 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( | |||
| 53 | 53 | func (s *LaunchService) Launch() (*LaunchResult, error) { | |
| 54 | 54 | 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 | 61 | // Check wine is available | |
| 57 | 62 | log.Debug("checking wine runtime") | |
| 58 | 63 | rt, err := s.runtime.Detect() | |
| @@ -80,6 +85,9 @@ func (s *LaunchService) Launch() (*LaunchResult, error) { | |||
| 80 | 85 | if s.envFn != nil { | |
| 81 | 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 | 91 | log.Debug("ensuring battle.net config") | |
| 84 | 92 | if err := EnsureBattleNetConfig(s.cfg.PrefixDir, s.fs, s.log); err != nil { | |
| 85 | 93 | log.Error("ensure battle.net config failed", "error", err) | |
| @@ -103,6 +111,7 @@ func (s *LaunchService) Launch() (*LaunchResult, error) { | |||
| 103 | 111 | return nil, fmt.Errorf("launch Battle.net: %w", err) | |
| 104 | 112 | } | |
| 105 | 113 | ||
| 114 | + | release() | |
| 106 | 115 | // Trap signals for graceful shutdown | |
| 107 | 116 | sigCh := make(chan os.Signal, 1) | |
| 108 | 117 | 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.log | 54 | 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 available | 61 | // 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 shutdown | 115 | // 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) { | |||
| 85 | 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 } | |
| @@ -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.