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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ repository has actually taken.

## Using it

Open the app. The setup wizard comes up when the seven steps are not finished and stays out of
the way when they are. It runs for tens of minutes, mostly compiling, and sends you to
Apple's download page once, for the toolkit.
Open the app. The setup wizard comes up until the seven steps are first finished, and stays out
of the way afterwards: when an update leaves a step to do again, the sidebar says so instead.
It runs for tens of minutes, mostly compiling, and sends you to Apple's download page once, for
the toolkit.

When it is done, the library has a bottle in it. Select the bottle and **Install from an
Installer…** runs the game's installer inside it, with Rename and Delete alongside. Once
Expand Down
40 changes: 35 additions & 5 deletions Sources/SakeKit/Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ public enum StepState: Sendable, Equatable {
case ready
/// Why it cannot be started yet, as a sentence.
case blocked(String)
/// Done once and not any more: why, as sentences.
case outdated(String)

public var isDone: Bool { self == .done }

/// What the wizard says about the step, when there is anything to say.
public var reason: String? {
switch self {
case .blocked(let why), .outdated(let why): why
case .done, .ready: nil
}
}
}

/// How far setup has got, read from what is on disk.
Expand All @@ -30,10 +40,16 @@ public enum StepState: Sendable, Equatable {
public struct Setup: Sendable {
private let paths: Paths
private let runtimeSources: URL?
private let patches: URL?

public init(paths: Paths = .default, runtimeSources: URL? = GDKRuntimeBuilder.bundled) {
public init(
paths: Paths = .default,
runtimeSources: URL? = GDKRuntimeBuilder.bundled,
patches: URL? = WinePatcher.bundled
) {
self.paths = paths
self.runtimeSources = runtimeSources
self.patches = patches
}

static let machineNotReady = "This Mac is not ready yet."
Expand Down Expand Up @@ -63,9 +79,9 @@ public struct Setup: Sendable {
return .ready

case .wine:
return state(WineBuilder(paths: paths).isBuilt,
WineBuilder(paths: paths).missingPrerequisite,
machineIsReady)
let builder = WineBuilder(paths: paths, patcher: WinePatcher(directory: patches))
return state(builder.isBuilt, builder.missingPrerequisite, machineIsReady,
outdated: builder.outdatedReason)

case .d3dMetal:
return state(D3DMetalInstaller(paths: paths).isInstalled,
Expand All @@ -82,10 +98,13 @@ public struct Setup: Sendable {
}
}

private func state(_ isDone: Bool, _ missing: String?, _ machineIsReady: Bool) -> StepState {
private func state(
_ isDone: Bool, _ missing: String?, _ machineIsReady: Bool, outdated: String? = nil
) -> StepState {
if isDone { return .done }
guard machineIsReady else { return .blocked(Self.machineNotReady) }
if let missing { return .blocked(missing) }
if let outdated { return .outdated(outdated) }
return .ready
}

Expand All @@ -98,4 +117,15 @@ public struct Setup: Sendable {
public func isComplete(machineIsReady: Bool) -> Bool {
SetupStep.allCases.allSatisfy { state(of: $0, machineIsReady: machineIsReady).isDone }
}

/// Whether the wizard opens itself at launch: only until setup has been finished once,
/// which a bottle says, making one being the last step. A step an update leaves to do
/// again is pointed out instead, because the window was in the way.
public func opensItself(machineIsReady: Bool) -> Bool {
!isComplete(machineIsReady: machineIsReady) && Bottle.all(in: paths).isEmpty
}

public func needsAttention(machineIsReady: Bool) -> Bool {
!isComplete(machineIsReady: machineIsReady) && !Bottle.all(in: paths).isEmpty
}
}
75 changes: 73 additions & 2 deletions Sources/SakeKit/WineBuilder.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

public enum WinePhase: String, Sendable {
case unpack
case patch
case configure
case sonames
Expand Down Expand Up @@ -63,25 +64,62 @@ public struct WineBuilder: Sendable {
private let paths: Paths
private let runner: ProcessRunner
private let patcher: WinePatcher
private let unpacker: Unpacker

public init(
paths: Paths = .default,
runner: ProcessRunner = ProcessRunner(),
patcher: WinePatcher = WinePatcher()
patcher: WinePatcher = WinePatcher(),
unpacker: Unpacker = Unpacker()
) {
self.paths = paths
self.runner = runner
self.patcher = patcher
self.unpacker = unpacker
}

public var prefix: URL { paths.engine }

public var logURL: URL { paths.build.appending(path: "wine.log") }

public var isBuilt: Bool {
var stampURL: URL { prefix.appending(path: "lib/wine/sake-patches.sha256") }

/// Built, and from the patches this copy of sake carries. Without patches to compare
/// with, an engine that is there counts.
public var isBuilt: Bool { hasWine && !isOutdated }

/// Built, but from other patches, or by a sake that kept no record of them. See
/// docs/wine-build.md.
public var isOutdated: Bool {
guard hasWine, let expected = expectedPatches else { return false }
return recordedPatches != expected
}

/// Why the step has to be done again, when it does.
public var outdatedReason: String? {
guard isOutdated else { return nil }
let which = recordedPatches == nil
? "This engine was built by an earlier sake and may lack patches this one carries."
: "This engine was built from other patches than this copy of sake carries."
return which + " Building it again takes as long as the first build, and D3DMetal goes back in afterwards with one press."
}

private var hasWine: Bool {
FileManager.default.fileExists(atPath: prefix.appending(path: "bin/wine").path)
}

private var recordedPatches: String? {
(try? String(contentsOf: stampURL, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines)
}

/// The patches inside the app are hashed once per process: the wizard asks every step's
/// state many times each time it draws.
private var expectedPatches: String? {
patcher.directory == WinePatcher.bundled ? Self.bundledPatches : patcher.fingerprint()
}

private static let bundledPatches: String? = WinePatcher().fingerprint()

/// What has to exist before this can start, as a sentence, or `nil` when it can.
public var missingPrerequisite: String? {
if let recipe = BuildRecipe.all.first(where: { !$0.isBuilt(in: prefix) }) {
Expand Down Expand Up @@ -207,6 +245,13 @@ public struct WineBuilder: Sendable {
let make = URL(filePath: "/usr/bin/make")
let jobs = ProcessInfo.processInfo.activeProcessorCount

// Only a step that is not done gets here, so an engine that is there is one from
// other patches.
if hasWine {
onPhase(.unpack)
try await unpackAgain(source, log: log, onOutput: onOutput)
}

onPhase(.patch)
try await patcher.apply(to: source) { line in
log.write(line + "\n")
Expand Down Expand Up @@ -243,6 +288,32 @@ public struct WineBuilder: Sendable {

onPhase(.verify)
try await verify(log: log)

// Last, so that a build stopped part way does not count as one from these patches.
if let expected = expectedPatches {
try Data("\(expected)\n".utf8).write(to: stampURL, options: .atomic)
log.write("=== record \(stampURL.path)\n")
}
}

/// Back to CrossOver's own tree: a patch that changed or went cannot be taken off a tree
/// that has it. With no archive to start from, the tree is patched as it is, which is
/// enough for a patch that was only added.
private func unpackAgain(
_ source: URL,
log: LogFile,
onOutput: @Sendable (String) -> Void
) async throws {
let component = Component.crossover
let archive = component.archiveURL(in: paths)
guard FileManager.default.fileExists(atPath: archive.path) else {
log.write("=== unpack no \(archive.path), so the tree is patched as it is\n")
return
}
log.write("=== unpack \(archive.path)\n")
onOutput("unpacking \(archive.lastPathComponent)")
try FileManager.default.removeItem(at: source)
try await unpacker.unpack(archive, into: component.destinationURL(in: paths), members: component.members)
}

private func runPhase(
Expand Down
13 changes: 13 additions & 0 deletions Sources/SakeKit/WinePatcher.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation

public struct WinePatch: Sendable, Equatable, Identifiable {
Expand Down Expand Up @@ -85,6 +86,18 @@ public struct WinePatcher: Sendable {
return patches
}

/// Every patch's name and contents as one hash, which the engine keeps to say what it was
/// built from, or `nil` when there are no patches to hash. See docs/wine-build.md.
public func fingerprint() -> String? {
guard let patches = try? patches() else { return nil }
var hasher = SHA256()
for patch in patches {
guard let digest = try? SourceFetcher.sha256(of: patch.url) else { return nil }
hasher.update(data: Data("\(patch.id)\0\(digest)\n".utf8))
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}

/// Apply every patch to `tree`, skipping the ones already in it.
///
/// Whether a patch is already applied is asked of `patch` itself -- if it reverses
Expand Down
21 changes: 19 additions & 2 deletions Sources/sake/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ final class AppModel {
var d3dMetal: D3DMetalStatus?
var gdkRuntime: GDKRuntimeStatus?

/// Setup as ``survey()`` last read it. A view that asks ``Setup`` itself reads the disk,
/// which SwiftUI does not watch, so the library went on saying a step was left after the
/// wizard had done it.
var setupIsComplete = false
var setupNeedsAttention = false

/// Keyed by bottle name, the way `sources` and `prefix` are keyed by component: the
/// wizard watches `default` while the library may be making another one.
var bottleStatus: [String: BottleStatus] = [:]
Expand Down Expand Up @@ -261,7 +267,12 @@ final class AppModel {
for recipe in BuildRecipe.all where recipe.isBuilt(in: paths.engine) {
prefix[recipe.id] = .alreadyBuilt
}
if WineBuilder(paths: paths).isBuilt { wine = .alreadyBuilt }
let wineBuilder = WineBuilder(paths: paths)
if wineBuilder.isBuilt {
wine = .alreadyBuilt
} else if wineBuilder.isOutdated {
wine = .outdated
}

let installer = D3DMetalInstaller(paths: paths)
if installer.isInstalled {
Expand Down Expand Up @@ -295,6 +306,9 @@ final class AppModel {
measureSelectedBottle()

surveyImportSources()

setupIsComplete = setup.isComplete(machineIsReady: machineIsReady)
setupNeedsAttention = setup.needsAttention(machineIsReady: machineIsReady)
}

/// Called from ``survey()`` and again when the selection moves: without the second
Expand Down Expand Up @@ -460,7 +474,10 @@ final class AppModel {
case .phase(let phase): wine = .working(phase: phase.rawValue, line: "")
case .output(let line):
if case .working(let phase, _) = wine { wine = .working(phase: phase, line: line) }
case .installed(let version): wine = .built(version: version)
case .installed(let version):
wine = .built(version: version)
// make install puts Wine's own d3d DLLs back, so what survey() found is gone.
if !D3DMetalInstaller(paths: paths).isInstalled { d3dMetal = nil }
case .failed(let reason, _): wine = .failed(reason)
case .finished: break
}
Expand Down
35 changes: 24 additions & 11 deletions Sources/sake/LibraryWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,25 @@ struct LibraryWindow: View {
.safeAreaInset(edge: .bottom) {
// Only what is about the library rather than about one bottle: putting a
// game in, or taking a bottle away, belongs to the bottle it happens to.
Button {
model.isCreatingBottle = true
} label: {
Label("New Bottle…", systemImage: "plus")
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 8) {
if model.setupNeedsAttention {
Button {
openWindow(id: WindowID.setup)
} label: {
Label("Setup needs attention", systemImage: "exclamationmark.circle")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.borderless)
.foregroundStyle(.orange)
}
Button {
model.isCreatingBottle = true
} label: {
Label("New Bottle…", systemImage: "plus")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.borderless)
}
.buttonStyle(.borderless)
.padding(10)
}
} detail: {
Expand Down Expand Up @@ -99,8 +111,11 @@ struct LibraryWindow: View {
model.watchForSignIns()
await model.check()
// First run lands here with nothing built, so the wizard opens itself rather
// than leaving an empty window and a button to find.
if !isReady { openWindow(id: WindowID.setup) }
// than leaving an empty window and a button to find. Only then: a step an update
// leaves to do again gets the line above New Bottle… instead.
if model.setup.opensItself(machineIsReady: model.machineIsReady) {
openWindow(id: WindowID.setup)
}
}
}

Expand Down Expand Up @@ -201,9 +216,7 @@ struct LibraryWindow: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var isReady: Bool {
model.setup.isComplete(machineIsReady: model.machineIsReady)
}
private var isReady: Bool { model.setupIsComplete }

private var selected: Bottle? { model.bottle(named: model.selectedBottle) }

Expand Down
4 changes: 2 additions & 2 deletions Sources/sake/SetupWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ struct SetupWindow: View {
switch model.state(of: candidate) {
case .done: "checkmark.circle.fill"
case .blocked: "lock"
case .ready: candidate == step ? "arrowtriangle.right.fill" : "circle"
case .ready, .outdated: candidate == step ? "arrowtriangle.right.fill" : "circle"
}
}

Expand All @@ -75,7 +75,7 @@ struct SetupWindow: View {
.fixedSize(horizontal: false, vertical: true)
}

if case .blocked(let why) = model.state(of: step) {
if let why = model.state(of: step).reason {
Text(why)
.font(.callout)
.foregroundStyle(.orange)
Expand Down
5 changes: 5 additions & 0 deletions Sources/sake/WineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum WineStatus: Equatable {
case working(phase: String, line: String)
case built(version: String)
case alreadyBuilt
case outdated
case failed(String)
}

Expand Down Expand Up @@ -43,6 +44,10 @@ struct WineView: View {
Text("already built")
.font(.callout)
.foregroundStyle(.secondary)
case .outdated:
Text("needs building again")
.font(.callout)
.foregroundStyle(.secondary)
case .failed(let reason):
Text(reason)
.font(.callout)
Expand Down
Loading
Loading