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
11 changes: 6 additions & 5 deletions Semper/Modules/UtilityRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -907,12 +907,13 @@ final class UtilityRuntime {
},
stop: { [weak self] reason in
guard let self, let shelf = self.shelf else { return }
if reason == .pause {
await shelf.pause()
} else {
await shelf.shutdown()
self.shelf = nil
let result = reason == .pause ? await shelf.pause() : await shelf.shutdown()
if case .failure(let failure) = result {
throw UtilityCleanupDeferral(
reason: shelf.imageCopy.message ?? shelf.message ?? failure.localizedDescription,
retaining: [.shelf])
}
if reason != .pause { self.shelf = nil }
}))
try lifecycle.register(
.storage,
Expand Down
130 changes: 128 additions & 2 deletions Semper/Modules/UtilityShellView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import KeyboardShortcuts
import SwiftUI

Expand Down Expand Up @@ -104,7 +105,7 @@ struct UtilityShellView: View {
registry: runtime.registry, lifecycle: runtime.lifecycle,
pause: runtime.pause, remove: runtime.remove, mutationDisabledReason: runtime.mutationDisabledReason)
case .module(let id):
module(id).disabled(id != .away && runtime.mutationDisabledReason != nil)
module(id).disabled(moduleInteractionDisabled(for: id))
}
}

Expand All @@ -128,7 +129,10 @@ struct UtilityShellView: View {
UtilityActionList(commands: runtime.commands, actions: runtime.registry.favoriteActions)
}
ForEach(runtime.registry.addedModules) { module in
if compact, module.id == .shelf,
if compact, module.id == .shelf, shelfStopRecoveryRoute != nil {
ShelfStopRecoveryView(runtime: runtime)
.disabled(moduleInteractionDisabled(for: .shelf))
} else if compact, module.id == .shelf,
!runtime.registry.pausedModuleIDs.contains(.shelf),
!runtime.lifecycle.stopping.contains(.shelf), !runtime.lifecycle.isShuttingDown,
let shelf = runtime.shelf, shelf.isRunning
Expand Down Expand Up @@ -230,6 +234,15 @@ struct UtilityShellView: View {
return .module(.scenes)
}

var shelfStopRecoveryRoute: ShelfStopRecoveryRoute? {
ShelfStopRecoveryRoute.current(in: runtime)
}

func moduleInteractionDisabled(for id: UtilityModuleID) -> Bool {
if id == .shelf, shelfStopRecoveryRoute != nil { return false }
return id != .away && runtime.mutationDisabledReason != nil
}

@ViewBuilder
private func module(_ id: UtilityModuleID) -> some View {
if id == .away, runtime.awayCleanupResult != nil, runtime.awayCleanupResult != .complete {
Expand All @@ -241,6 +254,8 @@ struct UtilityShellView: View {
runtime.sceneShortcuts == nil || runtime.lifecycle.isShuttingDown
{
SceneRecoveryView(manager: scenes)
} else if id == .shelf, shelfStopRecoveryRoute != nil {
ShelfStopRecoveryView(runtime: runtime)
} else if runtime.registry.pausedModuleIDs.contains(id) {
ContentUnavailableView {
Label("Module paused", systemImage: "pause.circle")
Expand Down Expand Up @@ -522,6 +537,117 @@ struct UtilitySettingsView: View {
}
}

nonisolated enum ShelfStopRecoveryRoute: Equatable, Sendable {
case pause, shutdown

@MainActor
static func current(in runtime: UtilityRuntime) -> Self? {
guard runtime.shelf?.stopFailure != nil else { return nil }
return runtime.lifecycle.isShuttingDown ? .shutdown : .pause
}

@MainActor
static func retry(in runtime: UtilityRuntime) async throws {
switch current(in: runtime) {
case .pause: try await runtime.pause(.shelf)
case .shutdown: await runtime.shutdown()
case nil: return
}
}

@MainActor
static func acknowledgeRecoveredCopy(in runtime: UtilityRuntime, requestID: UUID) async throws {
guard current(in: runtime) != nil,
runtime.shelf?.acknowledgeImageCopyReceipt(requestID: requestID) == true
else { return }
try await retry(in: runtime)
}

@MainActor
static func acknowledgeUnverifiedCopy(in runtime: UtilityRuntime, requestID: UUID) async throws {
guard current(in: runtime) != nil, let service = runtime.shelf,
service.imageCopy.request?.id == requestID, service.imageCopy.hasUnverifiedPublishedCopy
else { return }
try await service.acknowledgeUnverifiedImageCopy(requestID: requestID).get()
guard runtime.shelf === service, service.imageCopy.request == nil else { return }
try await retry(in: runtime)
}
}

private struct ShelfStopRecoveryView: View {
@Bindable var runtime: UtilityRuntime
@State private var retrying = false

var body: some View {
let acknowledgementRequest =
runtime.shelf?.imageCopy.needsReceiptAcknowledgement == true
? runtime.shelf?.imageCopy.request : nil
VStack(alignment: .leading, spacing: 14) {
Label("File Shelf cleanup needs attention", systemImage: "exclamationmark.triangle")
.font(.headline)
Text(
runtime.shelf?.imageCopy.message ?? runtime.shelf?.stopFailure?.localizedDescription
?? "Retry cleanup to finish stopping File Shelf."
)
.foregroundStyle(.secondary)
if let session = runtime.shelf?.imageCopy {
if session.needsReceiptAcknowledgement, let receipt = session.receipt {
Label("Copy recovered", systemImage: "checkmark.circle").foregroundStyle(.green)
Text(receipt.url.path).font(.callout).textSelection(.enabled)
} else {
ForEach(session.recoveryLocations, id: \.self) { location in
Text(location.path).font(.callout).textSelection(.enabled)
}
}
}
Text("Shelf items are retained until cleanup finishes.").font(.callout)
Button(acknowledgementRequest == nil ? "Retry Cleanup" : "Done") {
guard !retrying else { return }
retrying = true
Task {
do {
if let acknowledgementRequest {
try await ShelfStopRecoveryRoute.acknowledgeRecoveredCopy(
in: runtime, requestID: acknowledgementRequest.id)
} else {
try await ShelfStopRecoveryRoute.retry(in: runtime)
}
} catch {
runtime.message = error.localizedDescription
}
retrying = false
}
}
.buttonStyle(.borderedProminent)
.disabled(retrying || runtime.shelf?.isStopping == true || runtime.lifecycle.stopping.contains(.shelf))
if let session = runtime.shelf?.imageCopy, session.hasUnverifiedPublishedCopy,
let request = session.request
{
ShelfUnverifiedCopyExplanation()
Button("Finish Without Verification") {
guard !retrying else { return }
retrying = true
Task {
do {
try await ShelfStopRecoveryRoute.acknowledgeUnverifiedCopy(
in: runtime, requestID: request.id)
} catch {
runtime.message = error.localizedDescription
}
retrying = false
}
}
.buttonStyle(.borderless)
.disabled(
retrying || session.isWorking || runtime.shelf?.isStopping == true
|| runtime.lifecycle.stopping.contains(.shelf))
}
if retrying { ProgressView().controlSize(.small) }
}
.padding(24)
}
}

private struct AwayCleanupView: View {
@Bindable var runtime: UtilityRuntime
@State private var retrying = false
Expand Down
Loading