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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions Sources/SakeKit/WineTool.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 9 additions & 0 deletions Sources/sake/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion Sources/sake/BottleDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion Sources/sake/LibraryWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions Sources/sake/ScrollableDetail.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion Sources/sake/TitleDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions Tests/SakeKitTests/WineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Binary file modified assets/library.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
43 changes: 41 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down