Skip to content

Commit 84cc1bf

Browse files
dev-jacksonclaude
andcommitted
Make "run it with its interface" actually run something
Reported plainly: "you never see the installer". That was exactly right, and the reason was one line. guard let winPath = wrapper.windowsPath(for: installer) else { return } `windowsPath` only maps C:, inside the wrapper. An installer lives in Downloads, so it returned nil and the guard turned the entire function into a silent no-op — the button existed, the message told the person to answer a window, and no window was ever launched. Wine maps the whole filesystem as Z:, which is how the silent attempt reached that same file in the first place, so that is the fallback now. The installer is also copied into the app when this happens, so `proteus fix` works on it afterwards and the wrapper stops depending on a file in Downloads that may well be tidied away. Around it, the failure stops being a dead end. The error carries the half-built app and the installer, the app is no longer rolled back in this one case — deleting it left the person holding an explanation and nothing to act on — and the screen offers to open the installer instead of a "Try again" that would take the identical silent path and stop in the identical place. Also removes the empty quotation marks from the error text. Without Screen Recording permission there is no title to name, and `it stopped on ""` reads as a bug rather than as a fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1pWLDQqcrSEAq7AYSYvam
1 parent 458cdb8 commit 84cc1bf

7 files changed

Lines changed: 183 additions & 82 deletions

File tree

Sources/ProteusApp/AppState.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ final class AppState: ObservableObject {
2323
case installing(InstallPipeline.Stage)
2424
case done(InstallPipeline.Outcome)
2525
case failed(String)
26+
/// A failure with a way out: the installer refuses to run unattended,
27+
/// and the half-built app is still there to run it in, visibly.
28+
case needsInstallerUI(message: String, app: URL, installer: URL)
2629
}
2730

2831
@Published var phase: Phase = .idle
@@ -84,6 +87,26 @@ final class AppState: ObservableObject {
8487
}
8588
}
8689

90+
/// Runs an installer with its own interface, for the case where silent
91+
/// mode has nobody to answer it. Blocks until the person finishes, then
92+
/// completes the wrapper around whatever they installed.
93+
func showInstaller(app: URL, installer: URL) {
94+
phase = .installing(.init(en: "Opening the installer — answer it and it will carry on",
95+
es: "Abriendo el instalador — respóndelo y seguirá",
96+
fraction: nil))
97+
currentTask = Task {
98+
do {
99+
try await pipeline.runInstallerInteractively(app: app, installer: installer)
100+
let outcome = try await pipeline.finishInterrupted(app: app) { stage in
101+
Task { @MainActor in self.phase = .installing(stage) }
102+
}
103+
phase = .done(outcome)
104+
} catch {
105+
phase = .failed(readable(error))
106+
}
107+
}
108+
}
109+
87110
func install() {
88111
guard case .review(let analysis, let source) = phase else { return }
89112
let name = editedName.trimmingCharacters(in: .whitespacesAndNewlines)

Sources/ProteusApp/ContentView.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ struct ContentView: View {
4646
DoneCard(outcome: outcome)
4747
case .failed(let message):
4848
FailureCard(message: message)
49+
case .needsInstallerUI(let message, let app, let installer):
50+
NeedsInstallerUICard(message: message, app: app, installer: installer)
4951
}
5052
}
5153

@@ -64,6 +66,7 @@ struct ContentView: View {
6466
case .installing: return "installing"
6567
case .done: return "done"
6668
case .failed: return "failed"
69+
case .needsInstallerUI: return "needsInstallerUI"
6770
}
6871
}
6972

@@ -373,6 +376,46 @@ struct DoneCard: View {
373376

374377
// MARK: - Failure
375378

379+
/// The failure that is not a dead end.
380+
///
381+
/// An installer that stops to ask a question has not broken — it is waiting,
382+
/// and the answer is a person. Proteus can already run an installer with its
383+
/// interface visible; what was missing was saying so at the moment it matters,
384+
/// instead of showing the same red cross and a "Try again" that would take the
385+
/// identical silent path and stop in the identical place.
386+
struct NeedsInstallerUICard: View {
387+
@EnvironmentObject var state: AppState
388+
let message: String
389+
let app: URL
390+
let installer: URL
391+
392+
var body: some View {
393+
VStack(spacing: 16) {
394+
Spacer()
395+
Image(systemName: "hand.raised.fill")
396+
.font(.system(size: 42))
397+
.foregroundStyle(.orange)
398+
Text(S.installerAsksTitle).font(.title2.weight(.semibold))
399+
Text(message)
400+
.multilineTextAlignment(.center)
401+
.foregroundStyle(.secondary)
402+
.frame(maxWidth: 430)
403+
.textSelection(.enabled)
404+
Button(S.showInstaller) { state.showInstaller(app: app, installer: installer) }
405+
.buttonStyle(.borderedProminent)
406+
Text(S.showInstallerHint)
407+
.font(.footnote)
408+
.foregroundStyle(.secondary)
409+
.multilineTextAlignment(.center)
410+
.frame(maxWidth: 400)
411+
Button(S.cancel) { state.reset() }
412+
.buttonStyle(.link)
413+
Spacer()
414+
}
415+
.padding(30)
416+
}
417+
}
418+
376419
struct FailureCard: View {
377420
@EnvironmentObject var state: AppState
378421
let message: String

Sources/ProteusApp/Strings.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ enum S {
115115
Lang.pick("Saved. It takes effect next time you play.",
116116
"Guardado. Se aplica la próxima vez que juegues.")
117117
}
118+
static var installerAsksTitle: String {
119+
Lang.pick("This installer is asking something", "Este instalador está preguntando algo")
120+
}
121+
static var showInstaller: String {
122+
Lang.pick("Show me the installer", "Muéstrame el instalador")
123+
}
124+
static var showInstallerHint: String {
125+
Lang.pick("Its own window will open. Answer it, and Proteus finishes the rest.",
126+
"Se abrirá su propia ventana. Respóndela y Proteus termina el resto.")
127+
}
128+
118129
static var close: String { Lang.pick("Close", "Cerrar") }
119130
static var doubleClickToPlay: String {
120131
Lang.pick("Double-click to play", "Doble clic para jugar")

Sources/ProteusCore/Pipeline.swift

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ public actor InstallPipeline {
6161
case installerFailed(String)
6262
case notEnoughSpace(needed: Int64, free: Int64)
6363
/// The installer is not failing, it is asking — and a silent install
64-
/// has nobody to answer it.
65-
case installerNeedsAttention(String)
64+
/// has nobody to answer it. Carries the half-built app, because the
65+
/// only way past this is to run the installer inside it with its
66+
/// interface showing, and that app must therefore survive.
67+
case installerNeedsAttention(question: String, app: URL, installer: URL)
6668

6769
public var description: String {
6870
switch self {
@@ -73,9 +75,13 @@ public actor InstallPipeline {
7375
case .notEnoughSpace(let needed, let free):
7476
return "this game needs about \(InstallPipeline.readableSize(needed)) and there is only "
7577
+ "\(InstallPipeline.readableSize(free)) free. Free up some space and try again."
76-
case .installerNeedsAttention(let question):
77-
return "this installer will not run unattended — it stopped on \"\(question)\" "
78-
+ "and waited. Run it with its own interface and answer it."
78+
case .installerNeedsAttention(let question, _, _):
79+
// Named only when the title could be read; without Screen
80+
// Recording permission it cannot be, and an empty pair of
81+
// quotation marks reads as a bug rather than as a fact.
82+
let about = question.isEmpty ? "" : " on \"\(question)\""
83+
return "this installer will not run unattended — it stopped\(about) and waited. "
84+
+ "It needs to be run with its own interface so someone can answer it."
7985
}
8086
}
8187
}
@@ -363,8 +369,18 @@ public actor InstallPipeline {
363369
let gameDir: URL
364370
if source.needsInstaller {
365371
progress(.init(en: "Running the installer", es: "Ejecutando el instalador", fraction: 0.72))
366-
gameDir = try runInstaller(source: source, wrapper: wrapper, engine: engine,
367-
name: gameName, warnings: &warnings, progress: progress)
372+
do {
373+
gameDir = try runInstaller(source: source, wrapper: wrapper, engine: engine,
374+
name: gameName, warnings: &warnings, progress: progress)
375+
} catch let error as PipelineError {
376+
// An installer that stopped to ask is the one failure where
377+
// the half-built app must survive: running that installer
378+
// again, with its interface showing, is the only way past it,
379+
// and it has to be run inside this wrapper. Rolling back would
380+
// leave the person holding an explanation and no way to act.
381+
if case .installerNeedsAttention = error { succeeded = true }
382+
throw error
383+
}
368384
} else {
369385
progress(.init(en: "Copying game files", es: "Copiando archivos del juego", fraction: 0.72))
370386
gameDir = try copyPortable(source: source, into: wrapper, name: gameName)
@@ -675,12 +691,29 @@ public actor InstallPipeline {
675691
public func runInstallerInteractively(app: URL, installer: URL) throws {
676692
let wrapper = Wrapper(bundle: app)
677693
let engine = WineEngine(wrapper: wrapper)
678-
guard let winPath = wrapper.windowsPath(for: installer) else { return }
694+
695+
// The installer is almost never inside the wrapper — it is wherever the
696+
// person keeps their downloads. `windowsPath` only maps C:, so it
697+
// returned nil for the ordinary case and the guard turned the whole
698+
// function into a silent no-op: the window never appeared, and the
699+
// report was "you never see the installer", which was exactly right.
700+
//
701+
// Wine maps the entire filesystem as Z:, which is how the silent run
702+
// reached this same file in the first place.
703+
let winPath = wrapper.windowsPath(for: installer) ?? Self.zDrivePath(for: installer)
704+
705+
// Nothing hidden and nothing answered on the person's behalf: this is
706+
// the run where they are supposed to see it and decide.
679707
_ = try engine.run([winPath], timeout: 3600)
680708
engine.waitForServerIdle()
681709
engine.killServer()
682710
}
683711

712+
/// A macOS path as Wine sees it on the Z: drive.
713+
static func zDrivePath(for url: URL) -> String {
714+
"Z:" + url.standardizedFileURL.path.replacingOccurrences(of: "/", with: "\\")
715+
}
716+
684717
// MARK: - Steps
685718

686719
func copyPortable(source: GameSource, into wrapper: Wrapper, name: String) throws -> URL {
@@ -785,9 +818,15 @@ public actor InstallPipeline {
785818
// interface, which is the only thing that can get past this.
786819
if let question = result.waitingOn {
787820
engine.killServer()
788-
warnings.append("The installer stopped to ask something (\"\(question)\"). "
821+
warnings.append("The installer stopped to ask something. "
789822
+ "It needs to be run with its own interface.")
790-
throw PipelineError.installerNeedsAttention(question)
823+
// Kept inside the app, so `proteus fix` works on it later and
824+
// the wrapper does not depend on a file in Downloads that the
825+
// person may well tidy away. The original path is still used
826+
// if copying fails — a stub of a stub helps nobody.
827+
let reachable = stashInstaller(installer, in: wrapper) ?? installer
828+
throw PipelineError.installerNeedsAttention(
829+
question: question, app: wrapper.bundle, installer: reachable)
791830
}
792831

793832
if installedSomething(at: targetURL) { return targetURL }

Sources/ProteusCore/WineEngine.swift

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,13 @@ public struct WineEngine {
448448
return found ? "" : nil
449449
}
450450

451+
/// The path with every symlink resolved, the way the kernel reports it.
452+
static func realPath(_ path: String) -> String {
453+
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
454+
guard realpath(path, &buffer) != nil else { return path }
455+
return String(cString: buffer)
456+
}
457+
451458
/// The size of wine's own virtual-desktop window, which every process gets
452459
/// and which therefore means nothing on its own.
453460
static let wineDesktopSize: Double = 500
@@ -480,10 +487,15 @@ public struct WineEngine {
480487
/// `…/SharedSupport/wine/Runtime.app/Contents/MacOS/`. A path inside this
481488
/// bundle is exact, cheap, and cannot be lost by reparenting.
482489
static func processes(inBundle bundlePath: String) -> (cpu: Double, pids: Set<pid_t>) {
483-
// Resolved, because `proc_pidpath` always reports the real path and a
484-
// bundle reached through a symlink would never match it. `/tmp` is
485-
// `/private/tmp`, which is enough to make this silently find nothing.
486-
let root = URL(fileURLWithPath: bundlePath).resolvingSymlinksInPath().path
490+
// Resolved with `realpath`, and not with `resolvingSymlinksInPath`,
491+
// which was the first attempt and does not work.
492+
//
493+
// `proc_pidpath` always reports the real path — `/private/tmp/…` — and
494+
// Foundation deliberately leaves `/tmp` alone, treating it as the
495+
// canonical spelling. So the comparison silently matched nothing, the
496+
// process list came back empty, and with it the CPU reading and the
497+
// dialogue check. Caught by a reproduction installed under /tmp.
498+
let root = Self.realPath(bundlePath)
487499
let listing = Shell.run("/bin/ps", ["-Ao", "pid=,pcpu="])
488500
guard listing.exitCode == 0 else { return (0, []) }
489501

Tests/ProteusCoreTests/InstallProgressTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,30 @@ final class InstallLivenessTests: XCTestCase {
227227
XCTAssertEqual(whole, 40_000, "uncapped, every byte is counted")
228228
}
229229
}
230+
231+
/// The path that turned "run it with its interface" into a button that did
232+
/// nothing at all.
233+
final class InteractiveInstallerPathTests: XCTestCase {
234+
235+
/// An installer sitting in Downloads — which is where every installer
236+
/// sits — is not inside the wrapper, so the C: mapping cannot name it.
237+
/// `windowsPath` returning nil used to end the function early, silently,
238+
/// and the window the person was told to answer never appeared.
239+
func testAnInstallerOutsideTheWrapperStillGetsAWindowsPath() {
240+
let wrapper = Wrapper(bundle: URL(fileURLWithPath: "/Applications/Game.app"))
241+
let installer = URL(fileURLWithPath: "/Users/someone/Downloads/setup.exe")
242+
243+
XCTAssertNil(wrapper.windowsPath(for: installer),
244+
"it is outside C: — that is the premise of this test")
245+
246+
let fallback = InstallPipeline.zDrivePath(for: installer)
247+
XCTAssertEqual(fallback, "Z:\\Users\\someone\\Downloads\\setup.exe")
248+
}
249+
250+
func testAnInstallerInsideTheWrapperKeepsItsDriveCPath() {
251+
let wrapper = Wrapper(bundle: URL(fileURLWithPath: "/Applications/Game.app"))
252+
let inside = wrapper.driveC.appendingPathComponent("Games/Thing/setup.exe")
253+
254+
XCTAssertEqual(wrapper.windowsPath(for: inside), "C:\\Games\\Thing\\setup.exe")
255+
}
256+
}

Tests/fixtures/stuck-installer.iss

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,6 @@
4444
; purpose. So there is probably no free game with this shape to be found, and
4545
; looking harder is not the answer — building it is.
4646
;
47-
; ## Status: incomplete
48-
;
49-
; Honest note, because a fixture that is believed to work and does not is worse
50-
; than none — that is the mistake this whole exercise came out of.
51-
;
52-
; This compiles and runs. It does **not** yet hang under
53-
; `/VERYSILENT /SUPPRESSMSGBOXES`, which is the behaviour being chased. Two
54-
; placements were tried: `InitializeSetup`, which runs before there is a GUI to
55-
; put a window on, and `CurStepChanged(ssInstall)`, which runs but whose
56-
; `ShowModal` returns immediately when Inno is in silent mode.
57-
;
58-
; So a real repack is doing something further: a custom unpacker with its own
59-
; window (ISDone.dll and similar are common), or a form shown from a thread
60-
; Inno's silent handling does not own. Finding out which is the remaining work.
61-
;
62-
; What is already reproducible for free, and what the integration test uses, is
63-
; OpenTTD's installer run *without* silent flags: a genuine installer sitting
64-
; on a genuine dialogue, detected by geometry at 298×134 while the wine desktop
65-
; windows at 500×500 are ignored.
66-
;
6747
; ## Building it
6848
;
6949
; ISCC.exe stuck-installer.iss
@@ -89,51 +69,17 @@ Source: "stuck-payload.txt"; DestDir: "{app}"
8969
Source: "stuck-payload.txt"; DestDir: "{app}"; DestName: "selective-english.txt"
9070
Source: "stuck-payload.txt"; DestDir: "{app}"; DestName: "selective-french.txt"
9171

92-
[Code]
93-
// The component chooser, as a form the script owns.
94-
//
95-
// Inno's silent switches do not reach this. A repack that asks which language
96-
// packs to install does exactly this, and that is why such an installer sits
97-
// there forever under automation instead of failing.
98-
// Shown once the install is actually under way.
99-
//
100-
// `InitializeSetup` runs before there is a GUI to put a window on, and the
101-
// form never appears — the first attempt at this exited cleanly and proved
102-
// nothing. `ssInstall` is the moment a real repack asks which language packs
103-
// it should unpack, which is exactly where they stop.
104-
procedure CurStepChanged(CurStep: TSetupStep);
105-
var
106-
Form: TSetupForm;
107-
Prompt: TNewStaticText;
108-
Proceed: TNewButton;
109-
begin
110-
if CurStep <> ssInstall then Exit;
111-
Form := TSetupForm.Create(nil);
112-
try
113-
Form.Caption := 'Setup';
114-
Form.ClientWidth := ScaleX(320);
115-
Form.ClientHeight := ScaleY(130);
116-
117-
Prompt := TNewStaticText.Create(Form);
118-
Prompt.Parent := Form;
119-
Prompt.Left := ScaleX(16);
120-
Prompt.Top := ScaleY(20);
121-
Prompt.Width := ScaleX(288);
122-
Prompt.WordWrap := True;
123-
Prompt.Caption := 'Choose which language packs to install.';
124-
125-
Proceed := TNewButton.Create(Form);
126-
Proceed.Parent := Form;
127-
Proceed.Left := ScaleX(220);
128-
Proceed.Top := ScaleY(88);
129-
Proceed.Width := ScaleX(84);
130-
Proceed.Height := ScaleY(26);
131-
Proceed.Caption := 'Continue';
132-
Proceed.ModalResult := mrOk;
133-
134-
// Nothing answers this when no one is watching.
135-
Form.ShowModal;
136-
finally
137-
Form.Free;
138-
end;
139-
end;
72+
[Run]
73+
; This is the mechanism, and it is not a wizard page.
74+
;
75+
; `/VERYSILENT` governs Inno's own interface. It has no authority over a
76+
; *separate program* the installer is told to run and wait for — [Run] entries
77+
; execute in silent mode too, unless marked `skipifsilent`, and this one is
78+
; not. So Setup launches something with a window of its own and blocks until
79+
; it closes, which nobody is there to do.
80+
;
81+
; That is what a repack does with its unpacker: a second executable, its own
82+
; window, the installer waiting on it. Notepad stands in for the unpacker
83+
; because it ships with Wine and does the one thing that matters — it opens a
84+
; window and waits.
85+
Filename: "{sys}\notepad.exe"; Flags: waituntilterminated

0 commit comments

Comments
 (0)