diff --git a/README.md b/README.md index 7ad92db..f30a653 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ at a time: 6. put a game in it — its own installer, the one you downloaded, runs inside the bottle After that the library is where you live. A bottle holds titles you added; a title is a -program in that bottle, its name, and the arguments it starts with. Picking a program that +program in that bottle, its name, the arguments it starts with and any environment of its +own. Picking a program that carries `libcef.dll` fills those arguments in with what a Chromium client needs, because that is the one thing this stack is known to require and easy to forget. A title's name and arguments can be changed afterwards, a bottle can be renamed or thrown away, and the app diff --git a/Sources/SakeKit/WineTool.swift b/Sources/SakeKit/WineTool.swift new file mode 100644 index 0000000..c5cd1b9 --- /dev/null +++ b/Sources/SakeKit/WineTool.swift @@ -0,0 +1,63 @@ +import Foundation + +/// One of Wine's own programs, run in a bottle. +/// +/// sake does not reimplement what these offer. The Windows version, the DLL overrides, the +/// drives and the audio device are all winecfg's panels, and what they set is the bottle's +/// registry — which `docs/runtime.md` records as being flushed lazily by wineserver, so a +/// copy of it in a SwiftUI form would be a second copy that lies. What sake does is hand +/// the bottle over. +public enum WineTool: String, CaseIterable, Sendable, Identifiable { + case configuration = "winecfg" + case registry = "regedit" + case programs = "uninstaller" + case processes = "taskmgr" + + public var id: String { rawValue } + + public var name: String { + switch self { + case .configuration: "Wine Configuration" + case .registry: "Registry Editor" + case .programs: "Installed Programs" + case .processes: "Task Manager" + } + } + + /// Wine resolves the bare name from the engine's own `x86_64-windows` directory, which + /// is where the tools ship; there is no binary for them in `bin/`. + public func command(in bottle: Bottle) -> Command { + bottle.command("wine", [rawValue], workingDirectory: bottle.driveC) + } + + public func logURL(in paths: Paths) -> URL { + paths.build.appending(path: "tool-\(rawValue).log") + } + + /// Started and then left alone: these are windows somebody closes themselves, so there + /// is nothing to report progress on and nothing to wait for. + public func start(in bottle: Bottle, runner: ProcessRunner = ProcessRunner()) async { + try? FileManager.default.createDirectory( + at: bottle.paths.build, withIntermediateDirectories: true + ) + let log = try? LogFile(at: logURL(in: bottle.paths)) + defer { log?.close() } + _ = try? await runner.run(command(in: bottle)) { line in log?.write(line.text + "\n") } + } + + /// Whether any of them can be started at all, as a sentence. + /// + /// Asked before the menu is offered rather than reported after a failure: there is no + /// useful recovery from "the engine is not built", and a menu item that can only fail + /// is worse than no menu. + public static func missingPrerequisite(in bottle: Bottle) -> String? { + guard FileManager.default.fileExists(atPath: bottle.paths.engine.appending(path: "bin/wine").path) + else { + return "Wine is not built yet, so there are no tools to run. Finish setting up first." + } + guard bottle.exists else { + return "There is no bottle to run them in yet. Create one first." + } + return nil + } +} diff --git a/Sources/sake/AppModel.swift b/Sources/sake/AppModel.swift index 20113c4..2cc510a 100644 --- a/Sources/sake/AppModel.swift +++ b/Sources/sake/AppModel.swift @@ -661,6 +661,15 @@ final class AppModel { return environment } + /// Start one of Wine's own tools in a bottle. + /// + /// Not tracked as a run: winecfg stays open for as long as somebody wants it, and a + /// spinner that never stops is worse than none. + func runTool(_ tool: WineTool, in bottle: String) { + let bottle = Bottle(paths: paths, name: bottle) + Task { await tool.start(in: bottle) } + } + /// Only what was added by hand can be taken out of the library, and taking it out /// leaves the game where it is: this is a list sake keeps, not the install. func isRemovable(_ title: Title, in bottle: String) -> Bool { diff --git a/Sources/sake/BottleDetail.swift b/Sources/sake/BottleDetail.swift index 9aa15a1..6fe9d59 100644 --- a/Sources/sake/BottleDetail.swift +++ b/Sources/sake/BottleDetail.swift @@ -13,6 +13,8 @@ struct BottleDetail: View { /// Whether there is a CrossOver bottle to import from at all. Without one the sheet can /// only say why it cannot work, so the way in is not offered. let canImport: Bool + /// Whether Wine's own tools can be started in this bottle. + let canRunTools: Bool /// How many things a CrossOver bottle has that this one does not, or `nil` when that /// has not been worked out for this bottle. let importCandidates: Int? @@ -21,6 +23,7 @@ struct BottleDetail: View { let addingTitle: () -> Void let renaming: () -> Void let deleting: () -> Void + let runTool: (WineTool) -> Void var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -47,6 +50,18 @@ struct BottleDetail: View { .controlSize(.large) Button("Delete…", action: deleting) .controlSize(.large) + if canRunTools { + // A menu rather than a button each: this row already truncates its + // labels at the window's own minimum width. + Menu("Wine Tools") { + ForEach(WineTool.allCases) { tool in + Button(tool.name) { runTool(tool) } + } + } + .menuStyle(.button) + .controlSize(.large) + .fixedSize() + } } VStack(alignment: .leading, spacing: 4) { @@ -76,7 +91,8 @@ struct BottleDetail: View { Spacer(minLength: 0) } .padding(28) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .scrollableDetail() } private var holds: String { diff --git a/Sources/sake/LibraryWindow.swift b/Sources/sake/LibraryWindow.swift index c47ebb8..d2b8e10 100644 --- a/Sources/sake/LibraryWindow.swift +++ b/Sources/sake/LibraryWindow.swift @@ -170,12 +170,14 @@ struct LibraryWindow: View { size: model.bottleSizes[name], problem: model.problem(with: name), canImport: !model.importSources.isEmpty, + canRunTools: WineTool.missingPrerequisite(in: bottle) == nil, importCandidates: name == model.importTarget ? model.importCandidates.count : nil, importing: { model.beginImport(into: name) }, installing: { model.beginInstall(into: name) }, addingTitle: { model.beginAddTitle(into: name) }, renaming: { model.beginRename(bottle) }, - deleting: { model.isDeletingBottle = true } + deleting: { model.isDeletingBottle = true }, + runTool: { model.runTool($0, in: name) } ) } case .none: diff --git a/Sources/sake/ScrollableDetail.swift b/Sources/sake/ScrollableDetail.swift new file mode 100644 index 0000000..a68c9bb --- /dev/null +++ b/Sources/sake/ScrollableDetail.swift @@ -0,0 +1,22 @@ +import SwiftUI + +extension View { + /// A detail pane that scrolls rather than pushing itself out of its own window. + /// + /// **A `Text` with `.fixedSize(horizontal: false, vertical: true)` in the pane makes the + /// pane demand a height the window does not have to offer.** The demand reaches the + /// `NavigationSplitView`, which is then laid out taller than the window and centred in + /// it, so the content leaves the visible area upwards and the window draws empty — + /// while the accessibility tree still reports every string. Measured against the + /// library window on 2026-09-21: the pane stopped tracking the window's height as soon + /// as a title with arguments was selected, and tracked it again with the modifier gone. + /// + /// Scrolling keeps the modifier, which is there to let a long value wrap instead of + /// being truncated, and bounds what the demand can do. `SetupWindow` already does this. + func scrollableDetail() -> some View { + ScrollView { + self.frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/Sources/sake/TitleDetail.swift b/Sources/sake/TitleDetail.swift index 6d2cd8c..6fe8c5a 100644 --- a/Sources/sake/TitleDetail.swift +++ b/Sources/sake/TitleDetail.swift @@ -58,7 +58,8 @@ struct TitleDetail: View { Spacer(minLength: 0) } .padding(28) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .scrollableDetail() } private func block(_ heading: String, _ body: String) -> some View { diff --git a/Tests/SakeKitTests/WineTests.swift b/Tests/SakeKitTests/WineTests.swift index 19cc1a6..e56eb8a 100644 --- a/Tests/SakeKitTests/WineTests.swift +++ b/Tests/SakeKitTests/WineTests.swift @@ -325,3 +325,38 @@ private func failure(in events: [WineEvent]) -> (reason: String, log: URL?)? { #expect(!FileManager.default.fileExists(atPath: paths.wineBuild.appending(path: "Makefile").path)) #expect(!builder.isBuilt) } + +/// Wine's own tools, run in a bottle. The engine ships them as Windows programs under +/// `lib/wine/x86_64-windows/`, so what starts them is the bare name and nothing else. +@Test func eachWineToolIsStartedByItsBareNameInTheBottle() throws { + let root = FileManager.default.temporaryDirectory.appending(path: "sake-tool-\(UUID().uuidString)") + let paths = Paths(root: root.appending(path: "support"), cache: root.appending(path: "cache")) + defer { try? FileManager.default.removeItem(at: root) } + + let bottle = Bottle(paths: paths) + #expect(WineTool.missingPrerequisite(in: bottle)?.contains("Wine is not built") == true) + + try FileManager.default.createDirectory( + at: paths.engine.appending(path: "bin"), withIntermediateDirectories: true + ) + try Data().write(to: paths.engine.appending(path: "bin/wine")) + // Still no bottle, which is a different sentence from a missing engine. + #expect(WineTool.missingPrerequisite(in: bottle)?.contains("no bottle") == true) + + // A bottle is its prefix, and what says the prefix is there is its registry. + try FileManager.default.createDirectory(at: bottle.driveC, withIntermediateDirectories: true) + try Data().write(to: bottle.systemRegistry) + #expect(WineTool.missingPrerequisite(in: bottle) == nil) + + #expect(WineTool.allCases.map(\.rawValue) == ["winecfg", "regedit", "uninstaller", "taskmgr"]) + for tool in WineTool.allCases { + let command = tool.command(in: bottle) + #expect(command.executable.lastPathComponent == "wine") + #expect(command.arguments == [tool.rawValue]) + #expect(command.environment?["WINEPREFIX"] == bottle.url.path) + #expect(command.workingDirectory?.lastPathComponent == "drive_c") + // `arch` would strip every DYLD_* variable, and wine is x86_64 already. + #expect(command.architecture == .native) + #expect(tool.logURL(in: paths).lastPathComponent == "tool-\(tool.rawValue).log") + } +} diff --git a/assets/library.png b/assets/library.png index eff04ee..16cf221 100644 Binary files a/assets/library.png and b/assets/library.png differ diff --git a/docs/layout.md b/docs/layout.md index 208fe05..8db839d 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -96,7 +96,8 @@ after `~/.wine`, kills nothing of the user's and exits 0. ### Which is why the titles added by hand live in the prefix `sake-titles.json`, at the root of the bottle, holds what somebody added to the library -themselves — a name, the arguments, and the executable **relative to `drive_c`**. Nothing +themselves — a name, the arguments, the environment, and the executable **relative to +`drive_c`**. Nothing in it names the prefix, so it inherits everything the section above measured: a rename stays a `moveItem`, and throwing the bottle away takes its titles with it. Keeping the list under `~/Library/Sake` instead would make both of those an operation on two places @@ -106,6 +107,11 @@ Wine ignores what it does not recognise at a prefix's root — it keeps its own `.update-timestamp` there — and a bottle nobody has added anything to has no `sake-titles.json` at all. +**A field added to that file has to be optional going in.** `TitleStore.load()` turns any +decoding failure into an empty list rather than an error, so a required field would empty +the library of every bottle written before it existed, with nothing said. `environment` +was added that way on 2026-09-21 and the tests pin it. + **This file is the whole of it.** sake has no titles of its own to merge with: a bottle shows what somebody added to it and nothing else, so the file is the answer rather than one half of it. diff --git a/docs/roadmap.md b/docs/roadmap.md index 35e0c42..ee004dd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,7 +111,9 @@ patches in `patches/`. `runtime.md` has the measurement and what to look for. What the second title taught about profiles: Steam needed **nothing** per-title once the engine could host a swapchain across processes — no flags, no environment, no registry. The three Chromium flags sake offers are Battle.net's, measured on its 32-bit CEF, and Steam's -client cannot even take them. So a title profile is still name, executable and arguments, +client cannot even take them. So a title profile was name, executable and arguments until +2026-09-21, when it gained an environment of its own — `runtime.md` has what that may and +may not set — and the argument suggestion is a heuristic for one launcher rather than a rule for Chromium. Nothing yet knows that starting Diablo IV directly fails on the token — `runtime.md` says so, the app does not. @@ -199,6 +201,33 @@ running, and a bottle just created does not offer what could be imported into it something else surveys — `create` sets `importTarget` after the survey that would have used it. +**A bottle hands over Wine's own tools rather than growing settings of its own, from +2026-09-21.** The Windows version, the DLL overrides, the drives and the audio device are +winecfg's panels, and what they set is the bottle's registry — which `runtime.md` records as +being flushed lazily by wineserver, so a copy of it in a SwiftUI form would be a second copy +that lies. The engine already ships fourteen of these programs; the bottle offers four of +them, the ones that mean something to a bottle with a game in it: winecfg, regedit, the +uninstaller and the task manager. The rest are either not useful here or better done on the +macOS side. + +Offered, not recommended. winecfg's Windows-version dropdown can break a game, which is the +same objection the CrossOver-version knob has in the open questions below: exposing it +invites a combination nobody has run. The difference is that these are Wine's own surfaces +and sake reimplementing them would not make them safer, only harder to keep true. + +The wineserver a tool starts is not something the rename guard knows about — it asks what +sake started, which is the narrowness the open question about prefixes already describes. +Running winecfg therefore does not refuse a rename the way a running game does. + +**And one layout trap, found the same day.** A `Text` with +`.fixedSize(horizontal: false, vertical: true)` in a detail pane makes the pane demand a +height the window does not have to offer; the demand reaches the `NavigationSplitView`, +which is laid out taller than the window and centred in it, so the content leaves the +visible area upwards and the window draws empty while the accessibility tree still reports +every string. The panes scroll now, which keeps the modifier — it is there so a long value +wraps instead of being truncated — and bounds what the demand can do. The setup wizard had +been doing this from the start. + ## The Swift/subprocess boundary Settled during planning on 2026-09-18, recorded here so it is not relitigated. @@ -252,7 +281,17 @@ easier to read than it was interleaved with `configure` flags. after an uninstall are all judgements, and all in the app target — `scripts/test.sh` only reaches `SakeKit`. `CLAUDE.md` says logic in a view stops being tested; this is the same thing one layer down. Either these move behind types that do not know about SwiftUI, or - the app target gets tests of its own. + the app target gets tests of its own. `typedEnvironment()`, added 2026-09-21, is another + of these: which variable names a title may not set is a judgement, and it lives in the app + target where the tests cannot reach it. +- **Two buttons say "Check Again" in the setup wizard.** The bottom bar adds one when the + step is the machine step, and `primary` adds another because that step is not done, so + both render the same verb. The comment above the first says it is there for the step + "worth repeating after it has passed" — which is the condition the code does not check. + Found 2026-09-21, not fixed. +- **Editing a title moves it to the end of the sidebar.** `TitleStore.add()` filters the id + out and appends, so saving Options reorders the library. Harmless and confusing, and it + cost a measurement on 2026-09-21: a row addressed by index was no longer the row it was. - **Where the CrossOver version lives.** It is a knob users may need — a newer CrossOver may fix or break a given game — but exposing it invites them to pick a combination nobody has run. Steam gave the knob a concrete reason on 2026-09-20: two of sake's patches are diff --git a/docs/runtime.md b/docs/runtime.md index ac12819..7dddd06 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -303,6 +303,14 @@ about the token. Implication for sake: a "launch the game directly" button cannot work for this title on its own. The launcher's own flow has to be driven at least once per session. +**There is a third way in that nobody here has tried.** Blizzard installs its own +`Diablo IV Launcher.exe` beside the game, and the desktop shortcut the installer leaves +points at that with no arguments at all — read out of +`drive_c/users/Public/Desktop/Diablo IV.lnk` on 2026-09-21, 250 bytes, target and working +directory and nothing else. So "start Diablo IV" as a title in sake need not mean starting +`Diablo IV.exe`: it can mean starting the launcher Blizzard ships, which talks to the +client the way the Play button does. **Untested** — the shortcut was read, not run. + ## Controllers need SDL2 `winebus.sys` has two backends. **IOHID** is built either way and handles anything behaving