1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-27 14:30:08 +01:00

Add new alert for startup

This commit is contained in:
2026-02-25 15:13:22 +01:00
parent caffc9da42
commit cb9580c4db
14 changed files with 734 additions and 67 deletions

View File

@@ -221,6 +221,38 @@
03EC943D2F47297B00231276 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC943B2F47297100231276 /* ErrorView.swift */; };
03EC943E2F47297B00231276 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC943B2F47297100231276 /* ErrorView.swift */; };
03EC943F2F47297B00231276 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC943B2F47297100231276 /* ErrorView.swift */; };
EE4FA5D074AD5C9D09C25A55 /* StartupAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900AAECE035041E9BE09DCF /* StartupAlertViewModel.swift */; };
7E19EE251E743286BC95EBA3 /* StartupAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900AAECE035041E9BE09DCF /* StartupAlertViewModel.swift */; };
FEA10615F601BEC79BBDA45B /* StartupAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900AAECE035041E9BE09DCF /* StartupAlertViewModel.swift */; };
1D1173AE9AC9C8315E899D06 /* StartupAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900AAECE035041E9BE09DCF /* StartupAlertViewModel.swift */; };
959BE2F3A5743862AC8D1E7D /* StartupAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EE572012BA8A554E276E4 /* StartupAlertView.swift */; };
C57636BF2B81A9D147B28D1C /* StartupAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EE572012BA8A554E276E4 /* StartupAlertView.swift */; };
9297DB3F2A8903D70E1D6426 /* StartupAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EE572012BA8A554E276E4 /* StartupAlertView.swift */; };
88DD3A08057A23955913FB70 /* StartupAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EE572012BA8A554E276E4 /* StartupAlertView.swift */; };
E56493248D049CA2EBB82B21 /* StartupAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D51912F2DA8FD7D8A1578644 /* StartupAlertWindowController.swift */; };
148796E43977CCBC22AAB5CC /* StartupAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D51912F2DA8FD7D8A1578644 /* StartupAlertWindowController.swift */; };
8A8AB3D1A6DAE99C91DFBEFF /* StartupAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D51912F2DA8FD7D8A1578644 /* StartupAlertWindowController.swift */; };
A42F1F6EF455F42769E63FF5 /* StartupAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D51912F2DA8FD7D8A1578644 /* StartupAlertWindowController.swift */; };
BFF65E73753B67381C23D65E /* MarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C0BBF856B022EA51606492 /* MarkdownText.swift */; };
288730259CFF37771C773EA2 /* MarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C0BBF856B022EA51606492 /* MarkdownText.swift */; };
4AAB0E1F7037BEFAE0037AFF /* MarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C0BBF856B022EA51606492 /* MarkdownText.swift */; };
C277F9F96197AD07FA91E3CB /* MarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C0BBF856B022EA51606492 /* MarkdownText.swift */; };
56306F63B84C9E99C2C26375 /* StartupAlertHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF8DC9BC21B1EFB1F6CBBA5 /* StartupAlertHeaderView.swift */; };
5D754EAE78FF1E264AD02A46 /* StartupAlertHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF8DC9BC21B1EFB1F6CBBA5 /* StartupAlertHeaderView.swift */; };
95BB6DF72FB35A5A8F4E24E7 /* StartupAlertHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF8DC9BC21B1EFB1F6CBBA5 /* StartupAlertHeaderView.swift */; };
B54FBBEFE5E1782AFF7C434E /* StartupAlertHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF8DC9BC21B1EFB1F6CBBA5 /* StartupAlertHeaderView.swift */; };
67E571F5200F9C57CC673499 /* StartupFixCommandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68063C53EE0F48E91ADFE92F /* StartupFixCommandView.swift */; };
F644E222C9D096B94E363E60 /* StartupFixCommandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68063C53EE0F48E91ADFE92F /* StartupFixCommandView.swift */; };
632A06CFEEC7A76C34992F38 /* StartupFixCommandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68063C53EE0F48E91ADFE92F /* StartupFixCommandView.swift */; };
F4B7E3F9B46C5AD8B8B3FEBA /* StartupFixCommandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68063C53EE0F48E91ADFE92F /* StartupFixCommandView.swift */; };
CCF5FE7B49F3B0BC77BAF7D7 /* StartupOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E531FA9DE31575AF518941 /* StartupOutputView.swift */; };
2F9D926CBFE51F3B21A76536 /* StartupOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E531FA9DE31575AF518941 /* StartupOutputView.swift */; };
FF788D404D414CC970301C81 /* StartupOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E531FA9DE31575AF518941 /* StartupOutputView.swift */; };
FB95EF9B99E4ED160E751E44 /* StartupOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E531FA9DE31575AF518941 /* StartupOutputView.swift */; };
1C91CB7232F304AA7F6296CB /* StartupAlertButtonRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBEBCF2527961D5D13F68A8 /* StartupAlertButtonRow.swift */; };
1224231FF18A15CB876EEF50 /* StartupAlertButtonRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBEBCF2527961D5D13F68A8 /* StartupAlertButtonRow.swift */; };
DDAA59E032BB88D66D71F736 /* StartupAlertButtonRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBEBCF2527961D5D13F68A8 /* StartupAlertButtonRow.swift */; };
F6E9803FDF532887CF899ED9 /* StartupAlertButtonRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBEBCF2527961D5D13F68A8 /* StartupAlertButtonRow.swift */; };
03FE39E72E81682800B7B5AC /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 03FE39E52E81682800B7B5AC /* AppIcon.icon */; };
03FE39E82E81682800B7B5AC /* AppIconEAP.icon in Resources */ = {isa = PBXBuildFile; fileRef = 03FE39E62E81682800B7B5AC /* AppIconEAP.icon */; };
03FE39EA2E81694500B7B5AC /* AppIconUD.icon in Resources */ = {isa = PBXBuildFile; fileRef = 03FE39E92E81694500B7B5AC /* AppIconUD.icon */; };
@@ -1151,6 +1183,14 @@
03D846312EB64E35006EFE3C /* CrashReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporter.swift; sourceTree = "<group>"; };
03DAD3A52EB3B08A003417BD /* DomainListVC+Certs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DomainListVC+Certs.swift"; sourceTree = "<group>"; };
03EC943B2F47297100231276 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
B900AAECE035041E9BE09DCF /* StartupAlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAlertViewModel.swift; sourceTree = "<group>"; };
349EE572012BA8A554E276E4 /* StartupAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAlertView.swift; sourceTree = "<group>"; };
D51912F2DA8FD7D8A1578644 /* StartupAlertWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAlertWindowController.swift; sourceTree = "<group>"; };
A4C0BBF856B022EA51606492 /* MarkdownText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownText.swift; sourceTree = "<group>"; };
7EF8DC9BC21B1EFB1F6CBBA5 /* StartupAlertHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAlertHeaderView.swift; sourceTree = "<group>"; };
68063C53EE0F48E91ADFE92F /* StartupFixCommandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupFixCommandView.swift; sourceTree = "<group>"; };
E3E531FA9DE31575AF518941 /* StartupOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupOutputView.swift; sourceTree = "<group>"; };
2DBEBCF2527961D5D13F68A8 /* StartupAlertButtonRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAlertButtonRow.swift; sourceTree = "<group>"; };
03FE39E52E81682800B7B5AC /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = "<group>"; };
03FE39E62E81682800B7B5AC /* AppIconEAP.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIconEAP.icon; sourceTree = "<group>"; };
03FE39E92E81694500B7B5AC /* AppIconUD.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIconUD.icon; sourceTree = "<group>"; };
@@ -2346,6 +2386,7 @@
isa = PBXGroup;
children = (
03EC943B2F47297100231276 /* ErrorView.swift */,
A4C0BBF856B022EA51606492 /* MarkdownText.swift */,
031D747B2F46225600D4FF48 /* SimpleButton.swift */,
C44264BD2850B86C007400F1 /* SwiftUIHelper.swift */,
C451AFF52969E40F0078E617 /* HelpButton.swift */,
@@ -2511,9 +2552,24 @@
path = Onboarding;
sourceTree = "<group>";
};
3169708AAEC47AE3F4C33F74 /* Startup */ = {
isa = PBXGroup;
children = (
B900AAECE035041E9BE09DCF /* StartupAlertViewModel.swift */,
349EE572012BA8A554E276E4 /* StartupAlertView.swift */,
7EF8DC9BC21B1EFB1F6CBBA5 /* StartupAlertHeaderView.swift */,
68063C53EE0F48E91ADFE92F /* StartupFixCommandView.swift */,
E3E531FA9DE31575AF518941 /* StartupOutputView.swift */,
2DBEBCF2527961D5D13F68A8 /* StartupAlertButtonRow.swift */,
D51912F2DA8FD7D8A1578644 /* StartupAlertWindowController.swift */,
);
path = Startup;
sourceTree = "<group>";
};
C4EE55B027708BB2001DF387 /* SwiftUI */ = {
isa = PBXGroup;
children = (
3169708AAEC47AE3F4C33F74 /* Startup */,
C4B609182853AAA700C95265 /* Domains */,
C4B609172853AA9E00C95265 /* Menu */,
C4B609162853AA9A00C95265 /* Common */,
@@ -3022,6 +3078,14 @@
C4C0E8E727F88B41002D32A9 /* DomainScanner.swift in Sources */,
C4C3ED4327834C5200AB15D8 /* CustomPrefs.swift in Sources */,
03EC943D2F47297B00231276 /* ErrorView.swift in Sources */,
7E19EE251E743286BC95EBA3 /* StartupAlertViewModel.swift in Sources */,
C57636BF2B81A9D147B28D1C /* StartupAlertView.swift in Sources */,
148796E43977CCBC22AAB5CC /* StartupAlertWindowController.swift in Sources */,
288730259CFF37771C773EA2 /* MarkdownText.swift in Sources */,
5D754EAE78FF1E264AD02A46 /* StartupAlertHeaderView.swift in Sources */,
F644E222C9D096B94E363E60 /* StartupFixCommandView.swift in Sources */,
2F9D926CBFE51F3B21A76536 /* StartupOutputView.swift in Sources */,
1224231FF18A15CB876EEF50 /* StartupAlertButtonRow.swift in Sources */,
54B48B5F275F66AE006D90C5 /* Application.swift in Sources */,
C4B97B78275CF1B5003F3378 /* App+ActivationPolicy.swift in Sources */,
C4CE3BB827B31F2E0086CA49 /* MainMenu+Switcher.swift in Sources */,
@@ -3383,6 +3447,14 @@
032DAC2D2E8BEB6B0018E01C /* WebApiProtocol.swift in Sources */,
C4513F902B13E2E6001AD760 /* PhpExtensionManagerWindowController.swift in Sources */,
03EC943F2F47297B00231276 /* ErrorView.swift in Sources */,
FEA10615F601BEC79BBDA45B /* StartupAlertViewModel.swift in Sources */,
9297DB3F2A8903D70E1D6426 /* StartupAlertView.swift in Sources */,
8A8AB3D1A6DAE99C91DFBEFF /* StartupAlertWindowController.swift in Sources */,
4AAB0E1F7037BEFAE0037AFF /* MarkdownText.swift in Sources */,
95BB6DF72FB35A5A8F4E24E7 /* StartupAlertHeaderView.swift in Sources */,
632A06CFEEC7A76C34992F38 /* StartupFixCommandView.swift in Sources */,
FF788D404D414CC970301C81 /* StartupOutputView.swift in Sources */,
DDAA59E032BB88D66D71F736 /* StartupAlertButtonRow.swift in Sources */,
C471E81528F9BAE80021E251 /* ArrayExtension.swift in Sources */,
C471E7DA28F9BA8F0021E251 /* TestableCommand.swift in Sources */,
C471E7E528F9BAC20021E251 /* Events.swift in Sources */,
@@ -3522,6 +3594,14 @@
03C29A7A2EC88E3100FBA25E /* ValetServicesDataManager.swift in Sources */,
C471E8D928F9BB8F0021E251 /* HotkeyPreferenceView.swift in Sources */,
03EC943E2F47297B00231276 /* ErrorView.swift in Sources */,
1D1173AE9AC9C8315E899D06 /* StartupAlertViewModel.swift in Sources */,
88DD3A08057A23955913FB70 /* StartupAlertView.swift in Sources */,
A42F1F6EF455F42769E63FF5 /* StartupAlertWindowController.swift in Sources */,
C277F9F96197AD07FA91E3CB /* MarkdownText.swift in Sources */,
B54FBBEFE5E1782AFF7C434E /* StartupAlertHeaderView.swift in Sources */,
F4B7E3F9B46C5AD8B8B3FEBA /* StartupFixCommandView.swift in Sources */,
FB95EF9B99E4ED160E751E44 /* StartupOutputView.swift in Sources */,
F6E9803FDF532887CF899ED9 /* StartupAlertButtonRow.swift in Sources */,
C4611E5D2AEAD2FA0010BE24 /* ConfigManagerView.swift in Sources */,
C471E8DA28F9BB8F0021E251 /* Keys.swift in Sources */,
C471E8DB28F9BB8F0021E251 /* TerminalProgressWindowController.swift in Sources */,
@@ -3715,6 +3795,14 @@
C4F319C927B034A500AFF46F /* Stats.swift in Sources */,
C4F30B04278E16BA00755FCE /* HomebrewService.swift in Sources */,
03EC943C2F47297B00231276 /* ErrorView.swift in Sources */,
EE4FA5D074AD5C9D09C25A55 /* StartupAlertViewModel.swift in Sources */,
959BE2F3A5743862AC8D1E7D /* StartupAlertView.swift in Sources */,
E56493248D049CA2EBB82B21 /* StartupAlertWindowController.swift in Sources */,
BFF65E73753B67381C23D65E /* MarkdownText.swift in Sources */,
56306F63B84C9E99C2C26375 /* StartupAlertHeaderView.swift in Sources */,
67E571F5200F9C57CC673499 /* StartupFixCommandView.swift in Sources */,
CCF5FE7B49F3B0BC77BAF7D7 /* StartupOutputView.swift in Sources */,
1C91CB7232F304AA7F6296CB /* StartupAlertButtonRow.swift in Sources */,
54D9E0B527E4F51E003B9AD9 /* Key.swift in Sources */,
C4AF9F7B2754499000D44ED0 /* Valet.swift in Sources */,
C4C1019C27C65C6F001FACC2 /* Process.swift in Sources */,

View File

@@ -19,10 +19,6 @@ class CommandTracker: ObservableObject {
commands.filter { !$0.isCompleted }
}
var isActive: Bool {
!activeCommands.isEmpty
}
@discardableResult
func track(_ command: String, id: UUID = UUID()) -> UUID {
let tracked = LoggedCommand(id: id, command: command, startedAt: Date())

View File

@@ -14,7 +14,8 @@ import Foundation
*/
struct EnvironmentCheck {
let command: (_ container: Container) async -> Bool
let fixCommand: ((_ container: Container) async throws -> Void)?
let fixCommand: ((_ container: Container, _ didReceiveOutput: @escaping (String, ShellStream) -> Void) async throws -> Void)?
let fixDescription: String?
let name: String
let titleText: String
let subtitleText: String
@@ -24,7 +25,8 @@ struct EnvironmentCheck {
init(
command: @escaping (_ container: Container) async -> Bool,
fix: ((_ container: Container) async throws -> Void)? = nil,
fix: ((_ container: Container, _ didReceiveOutput: @escaping (String, ShellStream) -> Void) async throws -> Void)? = nil,
fixDescription: String? = nil,
name: String,
titleText: String,
subtitleText: String,
@@ -34,6 +36,7 @@ struct EnvironmentCheck {
) {
self.command = command
self.fixCommand = fix
self.fixDescription = fixDescription
self.name = name
self.titleText = titleText
self.subtitleText = subtitleText

View File

@@ -15,7 +15,7 @@ extension Startup {
The potential outcome of an environment check failure alert.
*/
enum EnvironmentAlertOutcome {
/** The automatic fix was requested, will try and continue if it worked. */
/** The automatic fix ran and succeeded. Continue to the next check. */
case shouldRunFix
/** No automatic fix was requested, show alert and require retry of all startup checks. */
@@ -23,11 +23,11 @@ extension Startup {
}
/**
Displays an alert for a particular check. There are two types of alerts:
- ones that require an app restart, which prompt the user to exit the app
- ones that allow the app to continue, which allow the user to retry
Displays an alert for a particular check. For checks that require an app restart,
a simple NVAlert is shown with a quit button. For all other checks, the new
StartupAlertWindowController is used to show the enhanced startup alert.
*/
@MainActor internal func showAlert(for check: EnvironmentCheck) -> EnvironmentAlertOutcome {
@MainActor internal func showAlert(for check: EnvironmentCheck) async -> EnvironmentAlertOutcome {
// Ensure that the timeout does not fire until we restart
Self.startupTimer?.invalidate()
@@ -43,29 +43,8 @@ extension Startup {
}).show(urgency: .bringToFront)
}
// Verify if an automatic fix is available
let hasAutomaticFix = check.fixCommand != nil
// Present an alert with one or two buttons (depending on fix)
let outcome = NVAlert()
.withInformation(
title: check.titleText,
subtitle: check.subtitleText,
description: check.descriptionText
)
.withPrimary(text: hasAutomaticFix ? "startup.fix_for_me".localized : "startup.fix_manually".localized)
.withSecondary(if: hasAutomaticFix, text: "startup.fix_manually".localized)
.withTertiary(if: hasAutomaticFix, text: "", action: { _ in
NSWorkspace.shared.open(Constants.Urls.FrequentlyAskedQuestions)
})
.runModal(urgency: .bringToFront)
// If there's an automatic fix and we chose to fix it, return outcome
if hasAutomaticFix && outcome == .alertFirstButtonReturn {
return .shouldRunFix
}
// In any other situation, we will require a retry of the startup
return .shouldRetryStartup
// Create and show the enhanced startup alert window
let controller = StartupAlertWindowController.create(for: check)
return await controller.showModal()
}
}

View File

@@ -48,26 +48,12 @@ class Startup {
// We will present the user with an option (potentially)
let outcome = await showAlert(for: check)
// If the user requested an automatic fix, do this
// The fix ran and succeeded continue to the next check
if outcome == .shouldRunFix {
// Verify a fix actually exists
guard let command = check.fixCommand else {
return false
}
// We will try to run the fix, it may fail!
do {
try await command(App.shared.container)
guard await check.succeeds() else {
return false
}
continue // continue to the next check!
} catch {
return false
}
continue
}
// No fix requested, this is just a failure
// No fix requested or fix failed requires full restart
return false
}
} else {
@@ -113,10 +99,15 @@ class Startup {
return await !container.shell
.pipe("ls \(container.paths.optPath) | grep php").out.contains("php")
},
fix: { container in
fix: { container, didReceiveOutput in
let brew = container.paths.brew
await container.shell.pipe("\(brew) tap shivammathur/php && \(brew) install shivammathur/php/php")
try await container.shell.attach(
"\(brew) tap shivammathur/php && \(brew) install shivammathur/php/php",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew tap shivammathur/php && brew install shivammathur/php/php",
name: "`ls \(App.shared.container.paths.optPath) | grep php` returned php result",
titleText: "startup.errors.php_opt.title".localized,
subtitleText: "startup.errors.php_opt.subtitle".localized(
@@ -131,11 +122,15 @@ class Startup {
command: { container in
return !container.filesystem.fileExists(container.paths.php)
},
fix: { container in
// See if we can't link PHP
fix: { container, didReceiveOutput in
let brew = container.paths.brew
await container.shell.pipe("\(brew) link php")
try await container.shell.attach(
"\(brew) link php",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew link php",
name: "`\(App.shared.container.paths.php)` exists",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
@@ -152,10 +147,15 @@ class Startup {
return await container.shell.pipe("\(container.paths.binPath)/php -v").err
.contains("Library not loaded") && container.phpEnvs.currentInstall != nil
},
fix: { container in
let brew = App.shared.container.paths.brew
await container.shell.pipe("\(brew) tap shivammathur/php && \(brew) reinstall shivammathur/php/php && \(brew) link php")
fix: { container, didReceiveOutput in
let brew = container.paths.brew
try await container.shell.attach(
"\(brew) tap shivammathur/php && \(brew) reinstall shivammathur/php/php && \(brew) link php",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew reinstall php && brew link php",
name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized(
@@ -187,10 +187,15 @@ class Startup {
await container.phpEnvs.determinePhpAlias()
return PhpEnvironments.brewPhpAlias == nil
},
fix: { container in
fix: { container, didReceiveOutput in
let brew = container.paths.brew
await container.shell.pipe("\(brew) update")
try await container.shell.attach(
"\(brew) update",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "brew update",
name: "`brew` alias is not nil and valid",
titleText: "startup.errors.could_not_determine_alias.title".localized,
subtitleText: "startup.errors.could_not_determine_alias.subtitle".localized,
@@ -224,10 +229,12 @@ class Startup {
.pipe("cat /private/etc/sudoers.d/brew")
.out.contains(container.paths.brew)
},
fix: { container in
fix: { container, didReceiveOutput in
let valet = container.paths.binPath.appending("/valet")
try AppleScript.runShellAsAdmin("\(valet) trust")
let result = try AppleScript.runShellAsAdmin("\(valet) trust")
didReceiveOutput(result, .stdOut)
},
fixDescription: "valet trust",
name: "`/private/etc/sudoers.d/brew` contains brew",
titleText: "startup.errors.sudoers_brew.title".localized,
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
@@ -250,10 +257,15 @@ class Startup {
command: { container in
return !container.filesystem.directoryExists("~/.config/valet")
},
fix: { container in
fix: { container, didReceiveOutput in
let valet = container.paths.binPath.appending("/valet")
await container.shell.pipe("\(valet) install")
try await container.shell.attach(
"\(valet) install",
didReceiveOutput: didReceiveOutput,
withTimeout: 120
)
},
fixDescription: "valet install",
name: "`.config/valet` not empty (Valet installed)",
titleText: "startup.errors.valet_not_installed.title".localized,
subtitleText: "startup.errors.valet_not_installed.subtitle".localized,

View File

@@ -0,0 +1,27 @@
//
// MarkdownText.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
extension Text {
init(markdown string: String, fontSize: CGFloat? = nil) {
if var attributed = try? AttributedString(
markdown: string,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
for run in attributed.runs {
if run.inlinePresentationIntent?.contains(.code) == true {
attributed[run.range].backgroundColor = Color(nsColor: .quaternaryLabelColor)
}
}
self.init(attributed)
} else {
self.init(string)
}
}
}

View File

@@ -0,0 +1,78 @@
//
// StartupAlertButtonRow.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct StartupAlertButtonRow: View {
let state: StartupAlertViewModel.State
let hasFix: Bool
let onQuit: () -> Void
let onRetry: () -> Void
let onFix: () -> Void
var body: some View {
HStack {
Button("startup.alert.quit".localized) {
onQuit()
}
.disabled(state == .running)
Spacer()
switch state {
case .idle where hasFix:
Button("startup.fix_manually".localized) {
onRetry()
}
Button("startup.alert.fix_automatically".localized) {
onFix()
}
.buttonStyle(.custom)
case .running:
HStack(spacing: 10) {
Text("Applying. Please wait...")
.font(.subheadline)
.foregroundStyle(.secondary)
ProgressView()
.controlSize(.small)
}
case .idle, .completed:
Button("startup.alert.retry".localized) {
onRetry()
}
}
}
.padding(20)
}
}
#Preview("Fix available") {
StartupAlertButtonRow(
state: .idle, hasFix: true,
onQuit: {}, onRetry: {}, onFix: {}
)
.frame(width: 460)
}
#Preview("Running") {
StartupAlertButtonRow(
state: .running, hasFix: true,
onQuit: {}, onRetry: {}, onFix: {}
)
.frame(width: 460)
}
#Preview("No fix") {
StartupAlertButtonRow(
state: .completed, hasFix: false,
onQuit: {}, onRetry: {}, onFix: {}
)
.frame(width: 460)
}

View File

@@ -0,0 +1,44 @@
//
// StartupAlertHeaderView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct StartupAlertHeaderView: View {
let titleText: String
let subtitleText: String
var body: some View {
HStack(spacing: 12) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 60, height: 60)
VStack(alignment: .leading, spacing: 5) {
Text(markdown: titleText)
.font(.system(size: 15, weight: .bold))
.textSelection(.enabled)
Text(markdown: subtitleText)
.font(.system(size: 12))
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
Spacer()
}
.padding(15)
.padding(.top, 0)
}
}
#Preview {
StartupAlertHeaderView(
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized
)
.frame(width: 460)
}

View File

@@ -0,0 +1,145 @@
//
// StartupAlertView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct StartupAlertView: View {
@ObservedObject var viewModel: StartupAlertViewModel
var body: some View {
VStack(spacing: 0) {
StartupAlertHeaderView(
titleText: viewModel.check.titleText,
subtitleText: viewModel.check.subtitleText
)
Divider()
VStack(alignment: .leading, spacing: 12) {
if viewModel.state == .running {
StartupOutputView(
lines: viewModel.outputLines,
isRunning: true
)
} else if viewModel.hasFix, viewModel.state == .idle {
StartupFixCommandView(
command: viewModel.check.fixDescription ?? ""
)
}
if !viewModel.check.descriptionText.isEmpty, viewModel.state != .running {
Text(markdown: viewModel.check.descriptionText, fontSize: 12)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
if !viewModel.outputLines.isEmpty, viewModel.state == .idle {
StartupOutputView(
lines: viewModel.outputLines,
isRunning: false
)
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
StartupAlertButtonRow(
state: viewModel.state,
hasFix: viewModel.hasFix,
onQuit: { viewModel.quit() },
onRetry: { viewModel.retry() },
onFix: { viewModel.runFix() }
)
}
.frame(width: 460)
}
}
// MARK: - Previews
#Preview("Fix Available — brew link php") {
let check = EnvironmentCheck(
command: { _ in return true },
fix: { _, output in output("Running brew link php...", .stdOut) },
fixDescription: "brew link php",
name: "preview_php_binary",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
descriptionText: "startup.errors.php_binary.desc".localized(
App.shared.container.paths.php
)
)
let vm = StartupAlertViewModel(check: check)
return StartupAlertView(viewModel: vm)
}
#Preview("Fix Available — valet trust") {
let check = EnvironmentCheck(
command: { _ in return true },
fix: { _, output in output("Password required...", .stdOut) },
fixDescription: "valet trust",
name: "preview_sudoers_brew",
titleText: "startup.errors.sudoers_brew.title".localized,
subtitleText: "startup.errors.sudoers_brew.subtitle".localized,
descriptionText: "startup.errors.sudoers_brew.desc".localized
)
let vm = StartupAlertViewModel(check: check)
return StartupAlertView(viewModel: vm)
}
#Preview("Fix Running — brew link php") {
StartupAlertView(viewModel: StartupAlertViewModel(
check: EnvironmentCheck(
command: { _ in return true },
fix: { _, output in output("Running...", .stdOut) },
fixDescription: "brew link php",
name: "preview_php_binary_running",
titleText: "startup.errors.php_binary.title".localized,
subtitleText: "startup.errors.php_binary.subtitle".localized,
descriptionText: "startup.errors.php_binary.desc".localized(
App.shared.container.paths.php
)
),
state: .running,
outputLines: [
OutputLine(text: "==> Linking Binary 'php' to '/opt/homebrew/bin/php'", stream: .stdOut),
OutputLine(text: "==> Downloading https://formulae.brew.sh/api/formula.jws.json", stream: .stdOut),
OutputLine(text: "Already downloaded: /Users/nico/Library/Caches/Homebrew/downloads/abc123.json", stream: .stdOut),
OutputLine(text: "Warning: php is keg-only and must be linked with --force", stream: .stdErr),
OutputLine(text: "==> Linking php... linked 25 files", stream: .stdOut),
]
))
}
#Preview("No Fix — Valet version unsupported") {
let check = EnvironmentCheck(
command: { _ in return true },
name: "preview_valet_version",
titleText: "startup.errors.valet_version_not_supported.title".localized,
subtitleText: "startup.errors.valet_version_not_supported.subtitle".localized,
descriptionText: "startup.errors.valet_version_not_supported.desc".localized
)
let vm = StartupAlertViewModel(check: check)
return StartupAlertView(viewModel: vm)
}
#Preview("No Fix — Herd running") {
let check = EnvironmentCheck(
command: { _ in return true },
name: "preview_herd_running",
titleText: "startup.errors.herd_running.title".localized,
subtitleText: "startup.errors.herd_running.subtitle".localized,
descriptionText: "startup.errors.herd_running.desc".localized
)
let vm = StartupAlertViewModel(check: check)
return StartupAlertView(viewModel: vm)
}

View File

@@ -0,0 +1,93 @@
//
// StartupAlertViewModel.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Foundation
struct OutputLine: Identifiable {
let id = UUID()
let text: String
let stream: ShellStream
}
class StartupAlertViewModel: ObservableObject {
enum State {
case idle
case running
case completed
}
@Published var state: State = .idle
@Published var outputLines: [OutputLine] = []
let check: EnvironmentCheck
/// Callback to dismiss the window with a result
var onComplete: ((Startup.EnvironmentAlertOutcome) -> Void)?
init(check: EnvironmentCheck) {
self.check = check
self.state = check.fixCommand != nil ? .idle : .completed
}
init(check: EnvironmentCheck, state: State, outputLines: [OutputLine] = []) {
self.check = check
self.state = state
self.outputLines = outputLines
}
var hasFix: Bool {
return check.fixCommand != nil
}
@MainActor
func runFix() {
guard let fixCommand = check.fixCommand else { return }
state = .running
outputLines = []
Task {
do {
try await fixCommand(App.shared.container) { [weak self] text, stream in
DispatchQueue.main.async {
self?.outputLines.append(OutputLine(text: text, stream: stream))
}
}
// Fix completed re-run the check
let stillFails = await check.succeeds()
await MainActor.run {
if !stillFails {
// Check still fails after fix
self.state = .idle
self.outputLines.append(
OutputLine(text: "\nFix did not resolve the issue.", stream: .stdErr)
)
} else {
// Check passed
self.onComplete?(.shouldRunFix)
}
}
} catch {
await MainActor.run {
self.outputLines.append(
OutputLine(text: "\nError: \(error.localizedDescription)", stream: .stdErr)
)
self.state = .idle
}
}
}
}
func quit() {
exit(1)
}
func retry() {
onComplete?(.shouldRetryStartup)
}
}

View File

@@ -0,0 +1,69 @@
//
// StartupAlertWindowController.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import Cocoa
import SwiftUI
class StartupAlertWindowController: PMWindowController {
override var windowName: String {
return "StartupAlert"
}
private var viewModel: StartupAlertViewModel?
private var didResolve = false
static func create(for check: EnvironmentCheck) -> StartupAlertWindowController {
let windowController = StartupAlertWindowController()
let viewModel = StartupAlertViewModel(check: check)
windowController.viewModel = viewModel
let window = NSWindow()
window.title = ""
window.styleMask = [.titled, .closable]
window.titlebarAppearsTransparent = true
window.delegate = windowController
window.contentView = NSHostingView(rootView: StartupAlertView(viewModel: viewModel))
window.setContentSize(window.contentView!.fittingSize)
window.isReleasedWhenClosed = false
windowController.window = window
return windowController
}
@MainActor
func showModal() async -> Startup.EnvironmentAlertOutcome {
return await withCheckedContinuation { continuation in
guard let viewModel = self.viewModel else {
continuation.resume(returning: .shouldRetryStartup)
return
}
viewModel.onComplete = { [weak self] outcome in
guard let self, !self.didResolve else { return }
self.didResolve = true
self.close()
continuation.resume(returning: outcome)
}
self.showWindow(nil)
self.window?.center()
NSApp.activate(ignoringOtherApps: true)
self.window?.orderFrontRegardless()
}
}
override func windowWillClose(_ notification: Notification) {
super.windowWillClose(notification)
// If closed via the close button without resolving, quit the app
if !didResolve {
exit(1)
}
}
}

View File

@@ -0,0 +1,59 @@
//
// StartupFixCommandView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct StartupFixCommandView: View {
let command: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("AUTOMATIC FIX")
.foregroundStyle(Color.app)
.font(.system(size: 10))
HStack(spacing: 8) {
Image(systemName: "terminal")
.foregroundStyle(.secondary)
Text(command)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.primary)
Spacer()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(command, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(Color.black)
.foregroundStyle(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
#Preview("brew link php") {
StartupFixCommandView(command: "brew link php")
.padding(20)
.frame(width: 460)
}
#Preview("valet trust") {
StartupFixCommandView(command: "valet trust")
.padding(20)
.frame(width: 460)
}
#Preview("Long command") {
StartupFixCommandView(command: "brew tap shivammathur/php && brew install shivammathur/php/php")
.padding(20)
.frame(width: 460)
}

View File

@@ -0,0 +1,70 @@
//
// StartupOutputView.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 25/02/2026.
// Copyright © 2026 Nico Verbruggen. All rights reserved.
//
import SwiftUI
struct StartupOutputView: View {
let lines: [OutputLine]
let isRunning: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(lines) { line in
Text(line.text)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(line.stream == .stdErr ? Color.red : .white)
.textSelection(.enabled)
.id(line.id)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(height: 160)
.background(Color.black)
.foregroundStyle(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 6))
.onChange(of: lines.count) { _ in
if let last = lines.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
}
#Preview("With output") {
StartupOutputView(
lines: [
OutputLine(text: "==> Linking Binary 'php' to '/opt/homebrew/bin/php'", stream: .stdOut),
OutputLine(text: "==> Downloading https://formulae.brew.sh/api/formula.jws.json", stream: .stdOut),
OutputLine(text: "Already downloaded: /Users/nico/Library/Caches/Homebrew/downloads/abc123.json", stream: .stdOut),
OutputLine(text: "Warning: php is keg-only and must be linked with --force", stream: .stdErr),
OutputLine(text: "==> Linking php... linked 25 files", stream: .stdOut),
],
isRunning: true
)
.padding(20)
.frame(width: 460)
}
#Preview("Idle with prior output") {
StartupOutputView(
lines: [
OutputLine(text: "==> Linking php... linked 25 files", stream: .stdOut),
OutputLine(text: "\nFix did not resolve the issue.", stream: .stdErr),
],
isRunning: false
)
.padding(20)
.frame(width: 460)
}

View File

@@ -665,7 +665,7 @@ You can do this by running `composer global update` in your terminal. After that
// PHP binary not found
"startup.errors.php_binary.title" = "PHP is not correctly installed";
"startup.errors.php_binary.subtitle" = "You must install PHP via Homebrew. The app will not work correctly until you resolve this issue.";
"startup.errors.php_binary.desc" = "Usually running `brew link php` in your Terminal will resolve this issue.\n\nTo diagnose what is wrong, you can try running `which php` in your Terminal, it should return `%@`.";
"startup.errors.php_binary.desc" = "Usually, running `brew link php` in your Terminal will resolve this issue.\n\nTo diagnose what is wrong, you can try running `which php` in your Terminal, it should return `%@`.";
// Invalid brew info php output
"startup.errors.php_brew_info_invalid.title" = "Homebrew returned invalid output for `brew info php --json` which requires valid JSON as output.";
@@ -966,6 +966,10 @@ PHP Monitor will tell Valet to unsecure and re-secure all expired domains for yo
"startup.fix_for_me" = "Fix For Me";
"startup.fix_manually" = "I Fixed It";
"startup.alert.quit" = "Quit";
"startup.alert.fix_automatically" = "Fix Automatically";
"startup.alert.retry" = "Retry";
// COMMAND HISTORY
"command_history.title" = "Command History";