Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ dist/
*.egg-info/
*.egg

# Swift / macOS GUI build outputs
.build/
.swiftpm/
DerivedData/
*.xcuserstate
xcuserdata/

# Local dependencies and AirPyrt env
.deps/
.airpyrt-venv/
Expand Down
40 changes: 40 additions & 0 deletions macos/TimeCapsuleSMB/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// swift-tools-version: 5.9

import Foundation
import PackageDescription

let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "/Applications/Xcode.app/Contents/Developer"
let xcodeFrameworkPath = "\(developerDir)/Platforms/MacOSX.platform/Developer/Library/Frameworks"
let xcodeFrameworkFlags = FileManager.default.fileExists(atPath: xcodeFrameworkPath)
? ["-F", xcodeFrameworkPath]
: []
let xcodeSwiftSettings: [SwiftSetting] = xcodeFrameworkFlags.isEmpty ? [] : [.unsafeFlags(xcodeFrameworkFlags)]
let xcodeLinkerSettings: [LinkerSetting] = xcodeFrameworkFlags.isEmpty ? [] : [.unsafeFlags(xcodeFrameworkFlags)]

let package = Package(
name: "TimeCapsuleSMBMac",
defaultLocalization: "en",
platforms: [.macOS(.v13)],
products: [
.executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"])
],
targets: [
.target(
name: "TimeCapsuleSMBApp",
path: "Sources/TimeCapsuleSMBApp",
resources: [.process("Resources")]
),
.executableTarget(
name: "TimeCapsuleSMBExecutable",
dependencies: ["TimeCapsuleSMBApp"],
path: "Sources/TimeCapsuleSMBExecutable"
),
.testTarget(
name: "TimeCapsuleSMBAppTests",
dependencies: ["TimeCapsuleSMBApp"],
path: "Tests/TimeCapsuleSMBAppTests",
swiftSettings: xcodeSwiftSettings,
linkerSettings: xcodeLinkerSettings
)
]
)
128 changes: 128 additions & 0 deletions macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

@MainActor
final class BackendClient: ObservableObject {
@Published var helperPath: String
@Published var events: [BackendEvent] = []
@Published var isRunning = false
@Published var lastExitCode: Int32?
@Published var pendingConfirmation: PendingConfirmation?
@Published var currentStage: String?
@Published var currentRisk: String?
@Published var currentCancellable: Bool?

private let runner: any HelperRunning
private var runTask: Task<Void, Never>?
private var activeCall: BackendCall?

init(
runner: any HelperRunning = HelperRunner(),
helperPath: String = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? ""
) {
self.runner = runner
self.helperPath = helperPath
}

func clear() {
events.removeAll()
lastExitCode = nil
pendingConfirmation = nil
currentStage = nil
currentRisk = nil
currentCancellable = nil
}

var canCancel: Bool {
isRunning && (currentCancellable ?? true)
}

func run(operation: String, params: [String: JSONValue] = [:]) {
guard !isRunning else { return }
isRunning = true
lastExitCode = nil
pendingConfirmation = nil
currentStage = nil
currentRisk = nil
currentCancellable = nil
activeCall = BackendCall(operation: operation, params: params)
let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines)
let runner = self.runner
let updateTarget = BackendClientUpdateTarget(
appendEvent: { [weak self] event in
self?.appendEvent(event)
},
finishRun: { [weak self] exitCode in
self?.finishRun(exitCode: exitCode)
}
)
runTask = Task.detached(priority: .userInitiated) { [runner, updateTarget, helperPath, operation, params] in
let result = await runner.run(
helperPath: helperPath.isEmpty ? nil : helperPath,
operation: operation,
params: params
) { event in
await updateTarget.appendEvent(event)
}
await updateTarget.finishRun(exitCode: result.exitCode)
}
}

func cancel() {
guard canCancel else { return }
runTask?.cancel()
}

func confirmPending() {
guard let confirmation = pendingConfirmation, !isRunning else { return }
pendingConfirmation = nil
run(operation: confirmation.operation, params: confirmation.params)
}

fileprivate func appendEvent(_ event: BackendEvent) {
if event.type == "stage" {
currentStage = event.stage
currentRisk = event.risk
currentCancellable = event.cancellable
}
if let activeCall, let confirmation = PendingConfirmation(
confirmationEvent: event,
originalParams: activeCall.params
) {
pendingConfirmation = confirmation
}
events.append(event)
}

fileprivate func finishRun(exitCode: Int32) {
lastExitCode = exitCode
isRunning = false
runTask = nil
activeCall = nil
}
}

private struct BackendCall: Sendable {
let operation: String
let params: [String: JSONValue]
}

private final class BackendClientUpdateTarget: Sendable {
private let appendEventOnMain: @MainActor @Sendable (BackendEvent) -> Void
private let finishRunOnMain: @MainActor @Sendable (Int32) -> Void

init(
appendEvent: @escaping @MainActor @Sendable (BackendEvent) -> Void,
finishRun: @escaping @MainActor @Sendable (Int32) -> Void
) {
self.appendEventOnMain = appendEvent
self.finishRunOnMain = finishRun
}

func appendEvent(_ event: BackendEvent) async {
await appendEventOnMain(event)
}

func finishRun(exitCode: Int32) async {
await finishRunOnMain(exitCode)
}
}
Loading
Loading