mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Apple's container runtime: the release Knotchy installs, and how an installed one is judged.
|
||||
public enum ContainerRuntime {
|
||||
public struct Release: Equatable, Sendable {
|
||||
public let version: String
|
||||
public let url: URL
|
||||
public let sha256: String
|
||||
public let size: Int64
|
||||
|
||||
public init(version: String, url: URL, sha256: String, size: Int64) {
|
||||
self.version = version
|
||||
self.url = url
|
||||
self.sha256 = sha256
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
/// The only package Knotchy ever installs. Bump it only after testing a new release.
|
||||
public static let pinned = Release(
|
||||
version: "1.4.1",
|
||||
url: URL(string: "https://github.com/apple/container/releases/download/1.4.1/container-1.4.1-installer-signed.pkg")!,
|
||||
sha256: "c0d2716afefbb194c93fae662e9cae7cc186bcbcf746816608ec673dd648a6a4",
|
||||
size: 117_773_865)
|
||||
public static let minimumVersion = "1.4.0"
|
||||
|
||||
public static func version(in output: String) -> String? {
|
||||
output.firstMatch(of: #/container CLI version (\d+(?:\.\d+)+)/#).map { String($0.1) }
|
||||
}
|
||||
|
||||
/// Output Knotchy can't read counts as current, so a new format can never cause a downgrade.
|
||||
public static func status(versionOutput: String?) -> RuntimeStatus {
|
||||
guard let versionOutput else { return .missing }
|
||||
guard let found = version(in: versionOutput) else { return .current(nil) }
|
||||
return isOlder(found, than: minimumVersion) ? .outdated(found) : .current(found)
|
||||
}
|
||||
|
||||
public static func isOlder(_ version: String, than other: String) -> Bool {
|
||||
let lhs = version.split(separator: ".").map { Int($0) ?? 0 }
|
||||
let rhs = other.split(separator: ".").map { Int($0) ?? 0 }
|
||||
for index in 0..<max(lhs.count, rhs.count) {
|
||||
let a = index < lhs.count ? lhs[index] : 0
|
||||
let b = index < rhs.count ? rhs[index] : 0
|
||||
if a != b { return a < b }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public enum RuntimeStatus: Equatable, Sendable {
|
||||
case missing
|
||||
case outdated(String)
|
||||
/// nil: installed, but its version couldn't be read.
|
||||
case current(String?)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
/// Knotchy is built for Apple silicon only; macOS itself refuses to open it on an Intel Mac.
|
||||
public enum Unsupported: Equatable, Sendable {
|
||||
case noVirtualization
|
||||
}
|
||||
|
||||
public struct HostFacts: Equatable, Sendable {
|
||||
public var unsupported: Unsupported?
|
||||
public var osVersion: String
|
||||
public var bytesFree: Int64?
|
||||
|
||||
public init(unsupported: Unsupported?, osVersion: String, bytesFree: Int64?) {
|
||||
self.unsupported = unsupported
|
||||
self.osVersion = osVersion
|
||||
self.bytesFree = bytesFree
|
||||
}
|
||||
}
|
||||
|
||||
public protocol HostChecking: Sendable {
|
||||
func facts() async -> HostFacts
|
||||
}
|
||||
|
||||
public struct SystemHost: HostChecking {
|
||||
public init() {}
|
||||
|
||||
/// Synchronous, so the app can decide on minimal mode before it registers anything.
|
||||
@MainActor
|
||||
public static func unsupported() -> Unsupported? {
|
||||
VZVirtualMachine.isSupported ? nil : .noVirtualization
|
||||
}
|
||||
|
||||
public func facts() async -> HostFacts {
|
||||
let unsupported = await Self.unsupported()
|
||||
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||
var os = "macOS \(version.majorVersion).\(version.minorVersion)"
|
||||
if version.patchVersion > 0 { os += ".\(version.patchVersion)" }
|
||||
// The runtime keeps its data under the home folder.
|
||||
let values = try? URL(fileURLWithPath: NSHomeDirectory())
|
||||
.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
return HostFacts(unsupported: unsupported, osVersion: os, bytesFree: values?.volumeAvailableCapacityForImportantUsage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public struct DownloadedPackage: Equatable, Sendable {
|
||||
public let directory: URL
|
||||
/// Apple's own file name, which Installer shows.
|
||||
public let name: String
|
||||
public var file: URL { directory.appendingPathComponent(name) }
|
||||
|
||||
public init(directory: URL, name: String) {
|
||||
self.directory = directory
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public enum DownloadError: Error, Equatable, Sendable {
|
||||
case failed(String)
|
||||
/// Size or SHA-256 differ from the pinned release; the download is already deleted.
|
||||
case mismatch
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol InstallerSession: AnyObject {
|
||||
var hasQuit: Bool { get }
|
||||
}
|
||||
|
||||
public enum InstallerOpening {
|
||||
case opened(any InstallerSession)
|
||||
/// Size or SHA-256 no longer match; the download is already deleted.
|
||||
case changed
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public protocol RuntimeInstalling: Sendable {
|
||||
func download(_ release: ContainerRuntime.Release,
|
||||
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage
|
||||
/// Re-checks the size and hash off the main thread, then opens the package in Installer.
|
||||
@MainActor func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening
|
||||
func discard(_ package: DownloadedPackage)
|
||||
}
|
||||
|
||||
public struct SystemRuntimeInstaller: RuntimeInstalling {
|
||||
static let installerApp = URL(fileURLWithPath: "/System/Library/CoreServices/Installer.app")
|
||||
|
||||
public init() {}
|
||||
|
||||
public func download(_ release: ContainerRuntime.Release,
|
||||
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
|
||||
Self.removeLeftovers()
|
||||
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("knotchy-runtime-\(UUID().uuidString)", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
} catch {
|
||||
throw DownloadError.failed(error.localizedDescription)
|
||||
}
|
||||
let package = DownloadedPackage(directory: directory, name: release.url.lastPathComponent)
|
||||
do {
|
||||
try await PackageDownload.fetch(release.url, to: package.file, progress: progress)
|
||||
} catch {
|
||||
discard(package)
|
||||
throw DownloadError.failed(error.localizedDescription)
|
||||
}
|
||||
guard Self.matches(package.file, release) else {
|
||||
discard(package)
|
||||
throw DownloadError.mismatch
|
||||
}
|
||||
return package
|
||||
}
|
||||
|
||||
public func discard(_ package: DownloadedPackage) {
|
||||
try? FileManager.default.removeItem(at: package.directory)
|
||||
}
|
||||
|
||||
/// Folders left by quitting mid-download or a crash.
|
||||
public static func removeLeftovers() {
|
||||
let temporary = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let names = (try? FileManager.default.contentsOfDirectory(atPath: temporary.path)) ?? []
|
||||
for name in names where name.hasPrefix("knotchy-runtime-") {
|
||||
try? FileManager.default.removeItem(at: temporary.appendingPathComponent(name))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
|
||||
let file = package.file
|
||||
guard await Task.detached(operation: { Self.matches(file, release) }).value else {
|
||||
discard(package)
|
||||
return .changed
|
||||
}
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.activates = true
|
||||
do {
|
||||
let app = try await NSWorkspace.shared.open([file], withApplicationAt: Self.installerApp,
|
||||
configuration: configuration)
|
||||
return .opened(RunningInstaller(app: app))
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func matches(_ file: URL, _ release: ContainerRuntime.Release) -> Bool {
|
||||
guard let size = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber,
|
||||
size.int64Value == release.size,
|
||||
let handle = try? FileHandle(forReadingFrom: file) else { return false }
|
||||
defer { try? handle.close() }
|
||||
var hash = SHA256()
|
||||
while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty { hash.update(data: chunk) }
|
||||
return hash.finalize().map { String(format: "%02x", $0) }.joined() == release.sha256
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RunningInstaller: InstallerSession {
|
||||
let app: NSRunningApplication
|
||||
|
||||
init(app: NSRunningApplication) {
|
||||
self.app = app
|
||||
}
|
||||
|
||||
var hasQuit: Bool { app.isTerminated }
|
||||
}
|
||||
|
||||
/// A data task streamed into the package file, so the partial download lives only in its own folder.
|
||||
private final class PackageDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
||||
// Touched only on the session's serial delegate queue.
|
||||
private var continuation: CheckedContinuation<Void, any Error>?
|
||||
/// A completion that arrived before anyone waited: a task cancelled before it started.
|
||||
private var outcome: Result<Void, any Error>?
|
||||
private var handle: FileHandle?
|
||||
private var written: Int64 = 0
|
||||
private var writeError: (any Error)?
|
||||
private let destination: URL
|
||||
private let progress: @Sendable (Int64) -> Void
|
||||
|
||||
private init(destination: URL, progress: @escaping @Sendable (Int64) -> Void) {
|
||||
self.destination = destination
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
static func fetch(_ url: URL, to destination: URL, progress: @escaping @Sendable (Int64) -> Void) async throws {
|
||||
let delegate = PackageDownload(destination: destination, progress: progress)
|
||||
let queue = OperationQueue()
|
||||
queue.maxConcurrentOperationCount = 1
|
||||
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: queue)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
// Created up front: cancelling before it exists would invalidate the session, and creating it then throws.
|
||||
let task = session.dataTask(with: url)
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
queue.addOperation {
|
||||
if let outcome = delegate.outcome {
|
||||
continuation.resume(with: outcome)
|
||||
} else {
|
||||
delegate.continuation = continuation
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
|
||||
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) {
|
||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||
writeError = URLError(.badServerResponse)
|
||||
return completionHandler(.cancel)
|
||||
}
|
||||
do {
|
||||
guard FileManager.default.createFile(atPath: destination.path, contents: nil) else {
|
||||
throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: destination.path])
|
||||
}
|
||||
handle = try FileHandle(forWritingTo: destination)
|
||||
} catch {
|
||||
writeError = error
|
||||
return completionHandler(.cancel)
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard let handle, writeError == nil else { return }
|
||||
do {
|
||||
try handle.write(contentsOf: data)
|
||||
written += Int64(data.count)
|
||||
progress(written)
|
||||
} catch {
|
||||
writeError = error
|
||||
dataTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
// Ours comes first: cancelling after it adds a "cancelled" error.
|
||||
let result: Result<Void, any Error> = if let failure = writeError ?? error { .failure(failure) } else { .success(()) }
|
||||
if let waiting = continuation {
|
||||
continuation = nil
|
||||
waiting.resume(with: result)
|
||||
} else {
|
||||
outcome = result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/// What the setup flow needs from the session controller.
|
||||
@MainActor
|
||||
public protocol SetupDriving: AnyObject {
|
||||
var state: SessionState { get }
|
||||
var setupLog: String? { get }
|
||||
var setupOutput: [String] { get }
|
||||
func runtimeStatus() async -> RuntimeStatus
|
||||
func checkSetup() async -> SetupRunOutcome?
|
||||
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome?
|
||||
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal>
|
||||
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome
|
||||
func waitWhileSettingUp() async
|
||||
}
|
||||
|
||||
extension SessionController: SetupDriving {}
|
||||
@@ -0,0 +1,360 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// The setup window's rows and the order of its steps. The window binds only to this.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class SetupFlow {
|
||||
public enum Status: Equatable, Sendable {
|
||||
case pending(String)
|
||||
case running(String, fraction: Double?)
|
||||
case done(String)
|
||||
case failed(String, details: [String])
|
||||
}
|
||||
|
||||
public enum RuntimeAction: Equatable, Sendable {
|
||||
case install
|
||||
case update
|
||||
}
|
||||
|
||||
public static let requiredFreeBytes: Int64 = 3_000_000_000
|
||||
static let vmDetail = "About 3 minutes · downloads about 225 MB"
|
||||
|
||||
public private(set) var mac: Status = .pending("Checking…")
|
||||
public private(set) var unsupported: Unsupported?
|
||||
public private(set) var runtimeAction: RuntimeAction?
|
||||
public private(set) var isRunning = false
|
||||
/// Quitting now leaves a partial download behind.
|
||||
@ObservationIgnored public private(set) var isDownloading = false
|
||||
private var runtimeBase: Status = .pending("")
|
||||
private var vmBase: Status = .pending(SetupFlow.vmDetail)
|
||||
|
||||
@ObservationIgnored private let host: any HostChecking
|
||||
@ObservationIgnored private let installer: any RuntimeInstalling
|
||||
@ObservationIgnored private let driver: any SetupDriving
|
||||
@ObservationIgnored private let release: ContainerRuntime.Release
|
||||
@ObservationIgnored private let retryDelay: Duration
|
||||
@ObservationIgnored private var facts: HostFacts?
|
||||
@ObservationIgnored private var runtimeStatus: RuntimeStatus?
|
||||
|
||||
public init(host: any HostChecking, installer: any RuntimeInstalling, driver: any SetupDriving,
|
||||
release: ContainerRuntime.Release = ContainerRuntime.pinned, retryDelay: Duration = .milliseconds(500)) {
|
||||
self.host = host
|
||||
self.installer = installer
|
||||
self.driver = driver
|
||||
self.release = release
|
||||
self.retryDelay = retryDelay
|
||||
runtimeBase = .pending(Self.installDetail(release))
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// With the controller's live progress on top, so a setup the notch started shows here too.
|
||||
public var runtime: Status {
|
||||
if case .settingUp(let issue) = driver.state, issue == .serviceStopped || issue == .kernelMissing {
|
||||
return .running("Starting the runtime…", fraction: nil)
|
||||
}
|
||||
return runtimeBase
|
||||
}
|
||||
|
||||
public var vm: Status {
|
||||
if case .settingUp(let issue) = driver.state, Self.isImageIssue(issue) {
|
||||
return .running(driver.setupLog ?? "Starting the build…", fraction: nil)
|
||||
}
|
||||
return vmBase
|
||||
}
|
||||
|
||||
public var allRequiredDone: Bool {
|
||||
if case .done = mac, case .done = runtime, case .done = vm { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var canSetUp: Bool {
|
||||
guard !isRunning, unsupported == nil, !allRequiredDone else { return false }
|
||||
if case .failed = mac { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@ObservationIgnored private var work: Task<Void, Never>?
|
||||
|
||||
/// For the Set Up and Try Again buttons: runs `setUp()` where quitting can cancel it.
|
||||
public func start() {
|
||||
guard !isRunning else { return }
|
||||
work = Task { await setUp() }
|
||||
}
|
||||
|
||||
/// Quitting: stops a download. An open Installer is left to finish.
|
||||
public func cancel() {
|
||||
work?.cancel()
|
||||
}
|
||||
|
||||
/// Re-checks everything. Starts a stopped service, as a link would, but never installs or builds.
|
||||
public func refresh() async {
|
||||
guard !isRunning else { return }
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
await inspect()
|
||||
}
|
||||
|
||||
/// The Set Up and Try Again buttons: runs whatever is left, in order.
|
||||
public func setUp() async {
|
||||
guard !isRunning else { return }
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
await inspect()
|
||||
guard unsupported == nil else { return }
|
||||
// The service didn't start: building would only try again.
|
||||
if case .failed = runtimeBase { return }
|
||||
if case .failed = mac { return }
|
||||
if let action = runtimeAction {
|
||||
await installRuntime(action)
|
||||
} else if case .done = vmBase {
|
||||
return
|
||||
} else {
|
||||
await buildVM()
|
||||
}
|
||||
updateMac()
|
||||
}
|
||||
|
||||
// MARK: - Steps
|
||||
|
||||
private func inspect() async {
|
||||
let facts = await host.facts()
|
||||
self.facts = facts
|
||||
unsupported = facts.unsupported
|
||||
guard unsupported == nil else { return }
|
||||
let status = await driver.runtimeStatus()
|
||||
runtimeStatus = status
|
||||
switch status {
|
||||
case .missing:
|
||||
runtimeAction = .install
|
||||
runtimeBase = .pending(Self.installDetail(release))
|
||||
vmBase = .pending(Self.vmDetail)
|
||||
case .outdated(let found):
|
||||
runtimeAction = .update
|
||||
runtimeBase = .pending("container \(found) is too old · updates to \(release.version) with Apple's Installer")
|
||||
vmBase = .pending(Self.vmDetail)
|
||||
case .current(let version):
|
||||
runtimeAction = nil
|
||||
runtimeBase = .done(Self.installedText(version))
|
||||
switch await driver.checkSetup() {
|
||||
case .ready?: vmBase = .done("Ready")
|
||||
case .needs(let issue)? where Self.isImageIssue(issue): vmBase = .pending(Self.vmDetail)
|
||||
case .failed(let issue, let message)?: fail(issue, message)
|
||||
case nil where Self.isBrowserOpen(driver.state): vmBase = .done("Ready") // A running VM has an image.
|
||||
case .needs?, nil: break // Busy, or the runtime row's business: the live rows show it.
|
||||
}
|
||||
}
|
||||
updateMac()
|
||||
}
|
||||
|
||||
private func installRuntime(_ action: RuntimeAction) async {
|
||||
switch driver.state {
|
||||
case .starting, .live:
|
||||
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
|
||||
return
|
||||
case .destroying, .failed(.destroyStuck):
|
||||
runtimeBase = .failed("Wait for the Knotchy browser to finish closing, then try again.", details: [])
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
runtimeBase = .running(Self.downloadText(0, of: release.size), fraction: 0)
|
||||
let package: DownloadedPackage
|
||||
do {
|
||||
package = try await download()
|
||||
} catch DownloadError.mismatch {
|
||||
runtimeBase = .failed("The download didn't match the expected package, so it wasn't installed.", details: [])
|
||||
return
|
||||
} catch {
|
||||
runtimeBase = .failed("Couldn't download the runtime. Check your connection and try again.",
|
||||
details: [String(describing: error)])
|
||||
return
|
||||
}
|
||||
let found: String? = if case .outdated(let version) = runtimeStatus { version } else { nil }
|
||||
let hold = await driver.beginRuntimeInstall(replacing: found)
|
||||
if case .failure = hold { installer.discard(package) }
|
||||
switch hold {
|
||||
case .success:
|
||||
break
|
||||
case .failure(.sessionOpen):
|
||||
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
|
||||
return
|
||||
case .failure(.setupRunning):
|
||||
runtimeBase = .failed("Wait for the current setup step to finish, then try again.", details: [])
|
||||
return
|
||||
case .failure(.stopFailed(let message)):
|
||||
runtimeBase = .failed("Couldn't stop the runtime for the update.", details: [message])
|
||||
return
|
||||
}
|
||||
|
||||
runtimeBase = .running("Opening Installer…", fraction: nil)
|
||||
let session: any InstallerSession
|
||||
switch await installer.openInInstaller(package, release: release) {
|
||||
case .opened(let opened):
|
||||
session = opened
|
||||
case .changed:
|
||||
return await failInstall("The download changed after it was checked, so it wasn't opened.", details: [])
|
||||
case .failed(let message):
|
||||
installer.discard(package)
|
||||
return await failInstall("Couldn't open Installer.", details: [message])
|
||||
}
|
||||
|
||||
runtimeBase = .running("Finish the install in Installer…", fraction: nil)
|
||||
let status = await waitForInstall(session)
|
||||
// Installer reads the package until it quits.
|
||||
Task { [installer] in
|
||||
while !session.hasQuit { try? await Task.sleep(for: .seconds(2)) }
|
||||
installer.discard(package)
|
||||
}
|
||||
// Quitting: Installer carries on by itself.
|
||||
if Task.isCancelled { return }
|
||||
guard case .current(let version)? = status else {
|
||||
_ = await driver.finishRuntimeInstall(.cancelled)
|
||||
runtimeBase = .pending("Installing was cancelled.")
|
||||
return
|
||||
}
|
||||
runtimeStatus = status
|
||||
runtimeAction = nil
|
||||
runtimeBase = .done(Self.installedText(version))
|
||||
apply(await driver.finishRuntimeInstall(.installed))
|
||||
}
|
||||
|
||||
/// The current runtime once it appears, or nil when Installer quits without it.
|
||||
private func waitForInstall(_ session: any InstallerSession) async -> RuntimeStatus? {
|
||||
while !Task.isCancelled {
|
||||
let status = await driver.runtimeStatus()
|
||||
if case .current = status { return status }
|
||||
if session.hasQuit {
|
||||
// Installer may have finished between the two checks.
|
||||
let last = await driver.runtimeStatus()
|
||||
if case .current = last { return last }
|
||||
return nil
|
||||
}
|
||||
try? await Task.sleep(for: retryDelay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func download() async throws -> DownloadedPackage {
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
return try await installer.download(release) { [weak self, release] bytes in
|
||||
Task { @MainActor in self?.downloaded(bytes, of: release.size) }
|
||||
}
|
||||
}
|
||||
|
||||
private func failInstall(_ message: String, details: [String]) async {
|
||||
_ = await driver.finishRuntimeInstall(.failed(message))
|
||||
runtimeBase = .failed(message, details: details)
|
||||
}
|
||||
|
||||
private func buildVM() async {
|
||||
// A setup the notch started, or a session winding down, gets up to a minute to finish.
|
||||
for _ in 0..<120 {
|
||||
if Task.isCancelled { return }
|
||||
guard let checked = await driver.checkSetup() else {
|
||||
if case .settingUp = driver.state {
|
||||
await driver.waitWhileSettingUp()
|
||||
} else {
|
||||
try? await Task.sleep(for: retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch checked {
|
||||
case .needs(let issue) where Self.isImageIssue(issue):
|
||||
guard let result = await driver.runSetup(issue, continuing: true) else { continue }
|
||||
return apply(result)
|
||||
case .needs:
|
||||
return await inspect()
|
||||
default:
|
||||
return apply(checked)
|
||||
}
|
||||
}
|
||||
vmBase = .failed("Close the Knotchy browser first, then try again.", details: [])
|
||||
}
|
||||
|
||||
private func apply(_ outcome: SetupRunOutcome) {
|
||||
switch outcome {
|
||||
case .ready:
|
||||
vmBase = .done("Ready")
|
||||
case .needs(let issue):
|
||||
if Self.isImageIssue(issue) { vmBase = .pending(Self.vmDetail) }
|
||||
case .failed(let issue, let message):
|
||||
fail(issue, message)
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ issue: SetupIssue, _ message: String) {
|
||||
if Self.isImageIssue(issue) {
|
||||
vmBase = .failed(message, details: driver.setupOutput)
|
||||
} else {
|
||||
runtimeBase = .failed(message, details: driver.setupOutput)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloaded(_ bytes: Int64, of total: Int64) {
|
||||
// Late callbacks mustn't overwrite what came after the download.
|
||||
guard case .running(let text, _) = runtimeBase, text.hasPrefix("Downloading") else { return }
|
||||
if bytes >= total {
|
||||
runtimeBase = .running("Verifying…", fraction: nil)
|
||||
} else {
|
||||
runtimeBase = .running(Self.downloadText(bytes, of: total), fraction: Double(bytes) / Double(max(total, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMac() {
|
||||
guard let facts else { return }
|
||||
let free = facts.bytesFree.map { "\(Self.gigabytes($0)) GB free" }
|
||||
if needsSpace, let bytes = facts.bytesFree, bytes < Self.requiredFreeBytes {
|
||||
mac = .failed("Setting up needs about 3 GB free. \(Self.gigabytes(bytes)) GB is free.", details: [])
|
||||
} else {
|
||||
mac = .done([facts.osVersion, free].compactMap { $0 }.joined(separator: " · "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the install and the build use space; starting the service doesn't.
|
||||
private var needsSpace: Bool {
|
||||
if runtimeAction != nil { return true }
|
||||
if case .done = vmBase { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
static func installDetail(_ release: ContainerRuntime.Release) -> String {
|
||||
"Apple's container \(release.version) · \(megabytes(release.size)) MB · installs with Apple's Installer"
|
||||
}
|
||||
|
||||
static func installedText(_ version: String?) -> String {
|
||||
version.map { "container \($0) installed" } ?? "container installed"
|
||||
}
|
||||
|
||||
static func downloadText(_ bytes: Int64, of total: Int64) -> String {
|
||||
"Downloading… \(megabytes(bytes)) of \(megabytes(total)) MB"
|
||||
}
|
||||
|
||||
static func megabytes(_ bytes: Int64) -> Int {
|
||||
Int((Double(bytes) / 1_000_000).rounded())
|
||||
}
|
||||
|
||||
static func gigabytes(_ bytes: Int64) -> String {
|
||||
bytes >= 10_000_000_000 ? "\(bytes / 1_000_000_000)" : String(format: "%.1f", Double(bytes) / 1_000_000_000)
|
||||
}
|
||||
|
||||
static func isBrowserOpen(_ state: SessionState) -> Bool {
|
||||
switch state {
|
||||
case .starting, .live: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
static func isImageIssue(_ issue: SetupIssue) -> Bool {
|
||||
switch issue {
|
||||
case .imageMissing, .imageOutdated, .imageStale: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// When the setup window opens by itself at launch.
|
||||
public enum SetupLaunchRule {
|
||||
public static let completedKey = "setupCompleted"
|
||||
|
||||
public static func shouldOpen(hostSupported: Bool, setupCompleted: Bool, runtime: RuntimeStatus) -> Bool {
|
||||
guard hostSupported, setupCompleted else { return true }
|
||||
switch runtime {
|
||||
case .missing, .outdated: return true
|
||||
case .current: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user