From 38cbcdddd868b4cca7e6871af7b7b78c063baf38 Mon Sep 17 00:00:00 2001 From: Jakub Boguslaw Date: Sun, 11 Jan 2026 16:00:12 +0100 Subject: [PATCH] Fix capture bug workaround --- .gitignore | 1 + Overview.xcodeproj/project.pbxproj | 6 +- Overview/Capture/CaptureCoordinator.swift | 131 +++++++++++++++++++++- Overview/Capture/CaptureEngine.swift | 9 +- Overview/Info.plist | 4 + Overview/Logging/AppLogger.swift | 4 +- Overview/OverviewApp.swift | 25 ++++- 7 files changed, 171 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index edd0e241..f037941b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ xcuserdata/ # End of https://www.toptal.com/developers/gitignore/api/xcode .vscode/tasks.json +.DS_Store diff --git a/Overview.xcodeproj/project.pbxproj b/Overview.xcodeproj/project.pbxproj index 250c916a..9772274a 100644 --- a/Overview.xcodeproj/project.pbxproj +++ b/Overview.xcodeproj/project.pbxproj @@ -483,6 +483,7 @@ CURRENT_PROJECT_VERSION = 1.2.2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Overview/Preview Content\""; + DEVELOPMENT_TEAM = VL7JAGSH8G; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -495,7 +496,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "v1.2.2-beta"; + MARKETING_VERSION = "v1.2.2-beta-debug"; PRODUCT_BUNDLE_IDENTIFIER = io.williampierce.Overview; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -516,6 +517,7 @@ CURRENT_PROJECT_VERSION = 1.2.2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Overview/Preview Content\""; + DEVELOPMENT_TEAM = VL7JAGSH8G; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -528,7 +530,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "v1.2.2-beta"; + MARKETING_VERSION = "v1.2.2-beta-debug"; PRODUCT_BUNDLE_IDENTIFIER = io.williampierce.Overview; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Overview/Capture/CaptureCoordinator.swift b/Overview/Capture/CaptureCoordinator.swift index 30af170c..4a6af15e 100644 --- a/Overview/Capture/CaptureCoordinator.swift +++ b/Overview/Capture/CaptureCoordinator.swift @@ -40,6 +40,7 @@ final class CaptureCoordinator: ObservableObject { // Private State private var hasPermission: Bool = false private var activeFrameProcessingTask: Task? + private var systemStopRecoveryTask: Task? private var subscriptions = Set() init( @@ -153,12 +154,31 @@ final class CaptureCoordinator: ObservableObject { private func handleStreamError(_ error: SCStreamError) async { let errorDescription = error.localizedDescription + let errorDetails = "code=\(error.code) fatal=\(error.code.isFatal)" + + if isSystemStoppedStream(error.code) { + if systemStopRecoveryTask != nil { + logger.debug("System stop recovery already in progress") + return + } + + systemStopRecoveryTask = Task { @MainActor in + await logSourceDiagnostics(reason: "systemStoppedStream") + await stopCapture() + await attemptSystemStopRecovery() + systemStopRecoveryTask = nil + } + return + } if error.code.isFatal { - logger.logError(error, context: "Fatal stream error: \(errorDescription)") + logger.logError( + error, + context: "Fatal stream error: \(errorDescription) (\(errorDetails))" + ) await stopCapture() } else { - logger.warning("Recoverable stream error: \(errorDescription)") + logger.warning("Recoverable stream error: \(errorDescription) (\(errorDetails))") await recoverFromError() } } @@ -179,6 +199,105 @@ final class CaptureCoordinator: ObservableObject { } } + private func attemptSystemStopRecovery() async { + guard let selectedSource = selectedSource else { + logger.warning("System stop recovery skipped: no selected source") + return + } + + let snapshot = makeSourceSnapshot(from: selectedSource) + let retryDelays: [UInt64] = [1_000_000_000, 2_000_000_000, 5_000_000_000] + + for (index, delay) in retryDelays.enumerated() { + try? await Task.sleep(nanoseconds: delay) + + do { + let sources = try await sourceManager.getAvailableSources() + if let match = findMatchingSource(snapshot: snapshot, in: sources) { + self.selectedSource = match + try await startCapture() + logger.info("System stop recovery succeeded after attempt \(index + 1)") + return + } else { + logger.warning( + "System stop recovery attempt \(index + 1): source not found" + ) + } + } catch { + logger.logError(error, context: "System stop recovery attempt failed") + } + } + + logger.warning("System stop recovery failed: no matching source found") + } + + private func logSourceDiagnostics(reason: String) async { + guard let selectedSource = selectedSource else { + logger.warning("Source diagnostics skipped: no selected source") + return + } + + let snapshot = makeSourceSnapshot(from: selectedSource) + let availability: String + + do { + let sources = try await sourceManager.getAvailableSources() + let match = findMatchingSource(snapshot: snapshot, in: sources) + availability = match == nil ? "missing" : "available" + } catch { + logger.logError(error, context: "Failed to query available sources") + availability = "unknown" + } + + let activeStatus: String + if #available(macOS 13.1, *) { + activeStatus = selectedSource.isActive ? "active" : "inactive" + } else { + activeStatus = "unknown" + } + + logger.info( + "Source diagnostics (\(reason)): windowID=\(snapshot.windowID) title='\(snapshot.title)' app='\(snapshot.appName)' bundleId=\(snapshot.bundleId) processID=\(snapshot.processID) onScreen=\(selectedSource.isOnScreen) active=\(activeStatus) availability=\(availability)" + ) + } + + private func findMatchingSource(snapshot: SourceSnapshot, in sources: [SCWindow]) -> SCWindow? { + if let exact = sources.first(where: { $0.windowID == snapshot.windowID }) { + return exact + } + + let title = snapshot.title + let bundleId = snapshot.bundleId + if let match = sources.first(where: { + $0.owningApplication?.bundleIdentifier == bundleId && $0.title == title + }) { + return match + } + + let appName = snapshot.appName + if let match = sources.first(where: { + $0.owningApplication?.applicationName == appName && $0.title == title + }) { + return match + } + + return nil + } + + private func makeSourceSnapshot(from source: SCWindow) -> SourceSnapshot { + SourceSnapshot( + windowID: source.windowID, + title: source.title ?? "Untitled", + appName: source.owningApplication?.applicationName ?? "Unknown", + bundleId: source.owningApplication?.bundleIdentifier ?? "unknown", + processID: source.owningApplication?.processID ?? -1 + ) + } + + private func isSystemStoppedStream(_ code: SCStreamError.Code) -> Bool { + code.rawValue == -3821 + } + // MARK: - State Synchronization private func setupSubscriptions() { @@ -231,6 +350,14 @@ enum CaptureError: LocalizedError { } } +private struct SourceSnapshot { + let windowID: CGWindowID + let title: String + let appName: String + let bundleId: String + let processID: pid_t +} + extension SCStreamError.Code { var isFatal: Bool { switch self { diff --git a/Overview/Capture/CaptureEngine.swift b/Overview/Capture/CaptureEngine.swift index 783d6763..e9f54c5f 100644 --- a/Overview/Capture/CaptureEngine.swift +++ b/Overview/Capture/CaptureEngine.swift @@ -179,7 +179,14 @@ private class CaptureEngineStreamOutput: NSObject, SCStreamOutput, SCStreamDeleg // MARK: - Error Handling func stream(_ stream: SCStream, didStopWithError error: Error) { - logger.logError(error, context: "Stream stopped with error") + if let streamError = error as? SCStreamError { + logger.logError( + streamError, + context: "Stream stopped with error: code=\(streamError.code) fatal=\(streamError.code.isFatal)" + ) + } else { + logger.logError(error, context: "Stream stopped with error") + } continuation?.finish(throwing: error) } } diff --git a/Overview/Info.plist b/Overview/Info.plist index 2d9ce76a..ce0a4755 100644 --- a/Overview/Info.plist +++ b/Overview/Info.plist @@ -6,5 +6,9 @@ https://williampierce.io/overview/releases/appcast.xml SUPublicEDKey fm2kUnJuAO4xV8wvCntVYueLcMgbsrp/U9lhN8UXOJs= + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) diff --git a/Overview/Logging/AppLogger.swift b/Overview/Logging/AppLogger.swift index 57b93c85..ce7d518d 100644 --- a/Overview/Logging/AppLogger.swift +++ b/Overview/Logging/AppLogger.swift @@ -71,7 +71,7 @@ extension AppLogger { location: SourceLocation ) { let formattedMessage: String = "\(location.description) \(message)" - loggers[category]?.log(level: level.osLogType, "\(formattedMessage)") + loggers[category]?.log(level: level.osLogType, "\(formattedMessage, privacy: .public)") } static func logError( @@ -85,7 +85,7 @@ extension AppLogger { message += " - Context: \(context)" } - loggers[category]?.error("\(message)") + loggers[category]?.error("\(message, privacy: .public)") } } diff --git a/Overview/OverviewApp.swift b/Overview/OverviewApp.swift index cccd7a0e..be5a2634 100644 --- a/Overview/OverviewApp.swift +++ b/Overview/OverviewApp.swift @@ -180,8 +180,11 @@ struct OverviewApp: App { private var versionText: some View { Group { - if let version: String = getAppVersion() { - Text("Version \(version)") + if let version: String = getAppVersion(), + let build: String = getAppBuild(), + let buildDate: String = getAppBuildDate() + { + Text("Version \(version) (\(build), \(buildDate))") } } } @@ -263,6 +266,24 @@ struct OverviewApp: App { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String } + private func getAppBuild() -> String? { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + } + + private func getAppBuildDate() -> String? { + guard let executableURL = Bundle.main.executableURL else { return nil } + + let values = try? executableURL.resourceValues(forKeys: [.contentModificationDateKey]) + guard let date = values?.contentModificationDate else { return nil } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + + return formatter.string(from: date) + } + // MARK: - Menu Bar Icon struct MenuBarIcon: View {