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
4 changes: 2 additions & 2 deletions Sources/cli/bootstrapmate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion Sources/core/Managers/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -84,6 +87,7 @@ public struct BootstrapMateConfig {
self.dialogIcon = dialogIcon
self.blurScreen = blurScreen
self.networkTimeout = networkTimeout
self.userlandLoginTimeout = userlandLoginTimeout
}
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)")
}
}

73 changes: 51 additions & 22 deletions Sources/core/Managers/IAOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -405,20 +416,29 @@ 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)
} else {
Logger.writeError("\(displayName) failed")
DialogManager.shared.notifyPackageFailure(packageName: displayName, error: "Script failed")
}

return success
}

// MARK: - Helper Methods

private func countTotalPackages(_ manifest: BootstrapManifest) -> Int {
Expand All @@ -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)
}
Expand Down
137 changes: 108 additions & 29 deletions Sources/core/Managers/ScriptManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
17 changes: 17 additions & 0 deletions Sources/core/Managers/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}