diff --git a/Sources/cli/bootstrapmate.swift b/Sources/cli/bootstrapmate.swift index a5b3e00..fa530b1 100644 --- a/Sources/cli/bootstrapmate.swift +++ b/Sources/cli/bootstrapmate.swift @@ -271,9 +271,9 @@ struct BootstrapMate: ParsableCommand { // Handle userscript-only mode if effectiveConfig.userscriptOnly { Logger.info("Running in userscript-only mode") - ScriptManager.shared.runUserScriptOnly() + let userscriptSuccess = ScriptManager.shared.runUserScriptOnly() Logger.writeSessionSummary() - Foundation.exit(0) + Foundation.exit(userscriptSuccess ? 0 : 1) } // Run all stages diff --git a/Sources/core/Managers/ConfigManager.swift b/Sources/core/Managers/ConfigManager.swift index 025b21f..2003fcb 100644 --- a/Sources/core/Managers/ConfigManager.swift +++ b/Sources/core/Managers/ConfigManager.swift @@ -37,6 +37,8 @@ public struct BootstrapMateConfig { public var dialogIcon: String? public var blurScreen: Bool public var networkTimeout: Int + // Userland: how long to wait for a console user before skipping the stage + public var userlandLoginTimeout: Int public init( jsonUrl: String? = nil, @@ -60,7 +62,8 @@ public struct BootstrapMateConfig { dialogMessage: String = "Please wait while we configure your device...", dialogIcon: String? = nil, blurScreen: Bool = false, - networkTimeout: Int = 120 + networkTimeout: Int = 120, + userlandLoginTimeout: Int = 3600 ) { self.jsonUrl = jsonUrl self.authorizationHeader = authorizationHeader @@ -84,6 +87,7 @@ public struct BootstrapMateConfig { self.dialogIcon = dialogIcon self.blurScreen = blurScreen self.networkTimeout = networkTimeout + self.userlandLoginTimeout = userlandLoginTimeout } } @@ -456,6 +460,16 @@ public final class ConfigManager { config.networkTimeout = value } + // Userland: seconds to wait for a console user before skipping the + // stage. 0 or negative means wait indefinitely. + let loginTimeoutKeys = ["userlandLoginTimeout", "UserlandLoginTimeout"] + for key in loginTimeoutKeys { + if let value = CFPreferencesCopyAppValue(key as CFString, cfDomain) as? Int { + config.userlandLoginTimeout = value + break + } + } + return config.jsonUrl != nil } @@ -521,6 +535,7 @@ public final class ConfigManager { Logger.debug(" dialogIcon: \(config.dialogIcon ?? "default")") Logger.debug(" blurScreen: \(config.blurScreen)") Logger.debug(" networkTimeout: \(config.networkTimeout)") + Logger.debug(" userlandLoginTimeout: \(config.userlandLoginTimeout)") } } diff --git a/Sources/core/Managers/IAOrchestrator.swift b/Sources/core/Managers/IAOrchestrator.swift index bce415f..86a99ff 100644 --- a/Sources/core/Managers/IAOrchestrator.swift +++ b/Sources/core/Managers/IAOrchestrator.swift @@ -271,9 +271,20 @@ public final class IAOrchestrator { StatusManager.shared.setPhaseStatus(phase: .userland, stage: .starting) DialogManager.shared.notifyPhaseStarted(phase: "Userland") - // Wait for user session - waitForUserSession() - + // Wait for user session. A machine nobody logs into (a spare, a lab Mac + // imaged ahead of term) must still finish the run and report, so the + // wait is bounded and the stage is recorded as skipped on expiry. + guard waitForUserSession() else { + Logger.writeSkipped("Userland stage - no user logged in before the timeout expired") + DialogManager.shared.updateProgressText(text: "No user logged in - skipping userland setup") + StatusManager.shared.setPhaseStatus( + phase: .userland, + stage: .skipped, + errorMessage: "No user logged in before the login timeout expired" + ) + return false + } + StatusManager.shared.setPhaseStatus(phase: .userland, stage: .running) // Add all items to dialog @@ -405,9 +416,18 @@ public final class IAOrchestrator { DialogManager.shared.notifyPackageFailure(packageName: displayName, error: "Download failed") return false } - - let success = ScriptManager.shared.runScript(item) - + + // A userscript has to run in the console user's context. Falling back to + // root would silently put per-user work in root's home and defaults + // domain while still reporting success, so fail the item instead. + guard let consoleUser = SessionManager.shared.getValidConsoleUser() else { + Logger.writeError("\(displayName) failed - no console user to run as") + DialogManager.shared.notifyPackageFailure(packageName: displayName, error: "No console user") + return false + } + + let success = ScriptManager.shared.runAsUser(item, uid: consoleUser.uid, username: consoleUser.username) + if success { Logger.writeSuccess("\(displayName) completed") DialogManager.shared.notifyPackageSuccess(packageName: displayName) @@ -415,10 +435,10 @@ public final class IAOrchestrator { Logger.writeError("\(displayName) failed") DialogManager.shared.notifyPackageFailure(packageName: displayName, error: "Script failed") } - + return success } - + // MARK: - Helper Methods private func countTotalPackages(_ manifest: BootstrapManifest) -> Int { @@ -429,25 +449,34 @@ public final class IAOrchestrator { return count } - private func waitForUserSession() { - Logger.info("Waiting for user session...") + /// Wait for a real console user to log in, bounded by the + /// `userlandLoginTimeout` managed preference (0 or negative waits forever). + /// Returns false when the wait expired with nobody logged in. + private func waitForUserSession() -> Bool { + let timeout = TimeInterval(ConfigManager.shared.config.userlandLoginTimeout) + let deadline: Date? = timeout > 0 ? Date().addingTimeInterval(timeout) : nil + + if deadline != nil { + Logger.info("Waiting for user session (timeout: \(Int(timeout))s)...") + } else { + Logger.info("Waiting for user session (no timeout configured)...") + } DialogManager.shared.updateProgressText(text: "Waiting for user to log in...") - + while true { - let (username, uid) = SessionManager.shared.getConsoleUser() - // Skip system users - if let user = username, - user != "loginwindow", - user != "_mbsetupuser", - user != "root", - !user.hasPrefix("_") { - Logger.success("User session detected: \(user) (uid: \(uid ?? 0))") - DialogManager.shared.updateProgressText(text: "User \(user) logged in, continuing...") + if let consoleUser = SessionManager.shared.getValidConsoleUser() { + Logger.success("User session detected: \(consoleUser.username) (uid: \(consoleUser.uid))") + DialogManager.shared.updateProgressText(text: "User \(consoleUser.username) logged in, continuing...") Thread.sleep(forTimeInterval: 2) // Brief delay for UI stability - return + return true } - + + if let deadline = deadline, Date() >= deadline { + Logger.warning("No user logged in after \(Int(timeout))s - giving up on the userland stage") + return false + } + Logger.debug("No valid user session yet, waiting...") Thread.sleep(forTimeInterval: 2) } diff --git a/Sources/core/Managers/ScriptManager.swift b/Sources/core/Managers/ScriptManager.swift index ca98492..91b488a 100644 --- a/Sources/core/Managers/ScriptManager.swift +++ b/Sources/core/Managers/ScriptManager.swift @@ -12,16 +12,55 @@ public final class ScriptManager { private init() {} - public func runUserScriptOnly() { + /// Run only the manifest's `userscript` items. + /// Returns true when every script succeeded, false when any failed. + @discardableResult + public func runUserScriptOnly() -> Bool { Logger.info("Running user script only mode.") guard let manifest = ManifestManager.shared.getManifest(), let userland = manifest.userland else { Logger.warning("No userland items to run.") - return + return true } - for item in userland where item.type == "userscript" { - _ = runScript(item) + + let scripts = userland.filter { $0.type == "userscript" } + if scripts.isEmpty { + Logger.warning("No userland items to run.") + return true } + + // Invoked as root (from the daemon) every script has to be handed to the + // console user; invoked as the user already (from a LaunchAgent) it runs + // in the right context and needs no dispatch. + let needsUserDispatch = geteuid() == 0 + let consoleUser = SessionManager.shared.getValidConsoleUser() + + var failures: [String] = [] + + for item in scripts { + let displayName = item.name ?? item.file + + if needsUserDispatch { + guard let user = consoleUser else { + Logger.error("Cannot run \(displayName) as a user: no console user is logged in") + failures.append(displayName) + continue + } + if !runAsUser(item, uid: user.uid, username: user.username) { + failures.append(displayName) + } + } else if !runScript(item) { + failures.append(displayName) + } + } + + if failures.isEmpty { + Logger.success("All \(scripts.count) user script(s) completed successfully") + return true + } + + Logger.error("\(failures.count) of \(scripts.count) user script(s) failed: \(failures.joined(separator: ", "))") + return false } /// Run a script and return success/failure (true = exit 0, false = non-zero) @@ -129,48 +168,88 @@ public final class ScriptManager { return exitCode } - /// Run a script for the current console user - public func runAsUser(_ item: ManifestItem, uid: uid_t) -> Bool { - Logger.debug("Running user script as uid \(uid): \(item.file)") - + /// Run a script in the console user's context. + /// + /// `launchctl asuser` only moves the process into the target user's GUI + /// bootstrap namespace — it does not drop privileges — so the script is + /// handed to `sudo -u` as well. Without that the script would still run as + /// root, with root's HOME and user defaults domain, which is exactly what a + /// `userscript` must not do. + public func runAsUser(_ item: ManifestItem, uid: uid_t, username: String) -> Bool { + Logger.debug("Running user script as \(username) (uid \(uid)): \(item.file)") + let ok = ManifestManager.shared.downloadIfNeeded(item) if !ok { Logger.error("Failed to prepare user script: \(item.file)") return false } - + do { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: item.file) } catch { Logger.warning("Could not set executable permission: \(error.localizedDescription)") } - - // Use launchctl to run as user + + // Use launchctl to enter the user's GUI domain, and sudo to become them let task = Process() task.executableURL = URL(fileURLWithPath: "/bin/launchctl") - task.arguments = ["asuser", String(uid), item.file] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - + task.arguments = ["asuser", String(uid), "/usr/bin/sudo", "-u", username, item.file] + + // Set working directory to script location + task.currentDirectoryURL = URL(fileURLWithPath: item.file).deletingLastPathComponent() + + // Set environment + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + task.environment = environment + + // Async execution (donotwait): fire and forget, no pipes to drain + if item.donotwait == true { + do { + try task.run() + Logger.info("Launched user script asynchronously: \(item.file)") + return true + } catch { + Logger.error("Could not launch user script: \(error.localizedDescription)") + return false + } + } + + // Capture output + let outputPipe = Pipe() + let errorPipe = Pipe() + task.standardOutput = outputPipe + task.standardError = errorPipe + do { try task.run() - - if item.donotwait != true { - task.waitUntilExit() - - if task.terminationStatus != 0 { - Logger.error("User script failed with exit code \(task.terminationStatus)") - return false - } - } - - Logger.success("User script completed: \(item.file)") - return true } catch { Logger.error("Could not run user script: \(error.localizedDescription)") return false } + + // Drain the pipes before waiting: a script that writes more than the + // pipe buffer holds would otherwise block forever on a full pipe with + // waitUntilExit() never returning. + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + + // Log output + Logger.output( + from: item.file, + stdout: String(data: outputData, encoding: .utf8), + stderr: String(data: errorData, encoding: .utf8) + ) + + let exitCode = task.terminationStatus + + if exitCode == 0 { + Logger.success("User script completed: \(item.file)") + return true + } + + Logger.error("User script exited with code \(exitCode): \(item.file)") + return false } } diff --git a/Sources/core/Managers/SessionManager.swift b/Sources/core/Managers/SessionManager.swift index 8f0f7fe..03d943b 100644 --- a/Sources/core/Managers/SessionManager.swift +++ b/Sources/core/Managers/SessionManager.swift @@ -12,4 +12,21 @@ public final class SessionManager { let user = SCDynamicStoreCopyConsoleUser(nil, &uid, &gid) as String? return (user, uid) } + + /// The console user that per-user work may be dispatched to, or nil when + /// the console is held by a system account (loginwindow, _mbsetupuser, + /// root, any underscore-prefixed service account) or by nobody at all. + public func getValidConsoleUser() -> (username: String, uid: uid_t)? { + let (username, uid) = getConsoleUser() + guard let user = username, + let userUid = uid, + userUid != 0, + user != "loginwindow", + user != "_mbsetupuser", + user != "root", + !user.hasPrefix("_") else { + return nil + } + return (user, userUid) + } }