Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ xcuserdata/
# End of https://www.toptal.com/developers/gitignore/api/xcode

.vscode/tasks.json
.DS_Store
6 changes: 4 additions & 2 deletions Overview.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "";
Expand All @@ -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;
Expand All @@ -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 = "";
Expand Down
131 changes: 129 additions & 2 deletions Overview/Capture/CaptureCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class CaptureCoordinator: ObservableObject {
// Private State
private var hasPermission: Bool = false
private var activeFrameProcessingTask: Task<Void, Never>?
private var systemStopRecoveryTask: Task<Void, Never>?
private var subscriptions = Set<AnyCancellable>()

init(
Expand Down Expand Up @@ -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()
}
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion Overview/Capture/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
4 changes: 4 additions & 0 deletions Overview/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
<string>https://williampierce.io/overview/releases/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>fm2kUnJuAO4xV8wvCntVYueLcMgbsrp/U9lhN8UXOJs=</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>
4 changes: 2 additions & 2 deletions Overview/Logging/AppLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -85,7 +85,7 @@ extension AppLogger {
message += " - Context: \(context)"
}

loggers[category]?.error("\(message)")
loggers[category]?.error("\(message, privacy: .public)")
}
}

Expand Down
25 changes: 23 additions & 2 deletions Overview/OverviewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))")
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down