diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4caf96a..dbfdfae 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -17,14 +17,14 @@ jobs: # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -43,12 +43,11 @@ jobs: - Performance considerations - Security concerns - Test coverage - + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index ae36c00..3cf327b 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,7 +35,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - + # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' - diff --git a/.gitignore b/.gitignore index e0b75d2..58f1647 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,8 @@ TextGrabber2/Secrets.plist *.sqlite *.sqlite3 + + +# Agent +.agent/ +_antigravity/ diff --git a/Assets/.png b/Assets/Settings_Panel_Old.png similarity index 100% rename from Assets/.png rename to Assets/Settings_Panel_Old.png diff --git a/ScreenScribe.xcodeproj/project.pbxproj b/ScreenScribe.xcodeproj/project.pbxproj index b7a76d0..076c398 100644 --- a/ScreenScribe.xcodeproj/project.pbxproj +++ b/ScreenScribe.xcodeproj/project.pbxproj @@ -36,6 +36,10 @@ B9583CC52DBD8C8300F43550 /* NSMenu+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9583C812DBD8C8300F43550 /* NSMenu+Extension.swift */; }; B9583CC62DBD8C8300F43550 /* SMAppService+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9583C872DBD8C8300F43550 /* SMAppService+Extension.swift */; }; B9F54FE92F10866C0070D1C2 /* ScreenCapturePermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F54FE82F10866C0070D1C2 /* ScreenCapturePermissionManager.swift */; }; + C1000001000000000000000A /* Prompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000001000000000000000B /* Prompt.swift */; }; + C1000002000000000000000A /* PromptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000002000000000000000B /* PromptManager.swift */; }; + C1000003000000000000000A /* PromptEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000003000000000000000B /* PromptEditorView.swift */; }; + C1000004000000000000000A /* PromptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000004000000000000000B /* PromptListView.swift */; }; C2000001000000000000000A /* OnboardingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000001000000000000000B /* OnboardingManager.swift */; }; C2000002000000000000000A /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000002000000000000000B /* OnboardingWindowController.swift */; }; C2000003000000000000000A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000003000000000000000B /* OnboardingView.swift */; }; @@ -43,10 +47,7 @@ C2000005000000000000000A /* PermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000005000000000000000B /* PermissionStepView.swift */; }; C2000006000000000000000A /* APIKeyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000006000000000000000B /* APIKeyStepView.swift */; }; C2000007000000000000000A /* CompletionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000007000000000000000B /* CompletionStepView.swift */; }; - C1000001000000000000000A /* Prompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000001000000000000000B /* Prompt.swift */; }; - C1000002000000000000000A /* PromptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000002000000000000000B /* PromptManager.swift */; }; - C1000003000000000000000A /* PromptEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000003000000000000000B /* PromptEditorView.swift */; }; - C1000004000000000000000A /* PromptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1000004000000000000000B /* PromptListView.swift */; }; + F5B0C7647A784F30BB1B71D9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C128894D5D084D2FBAADA65C /* Assets.xcassets */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -83,6 +84,11 @@ B9583C9F2DBD8C8300F43550 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; B9583CCA2DBD935300F43550 /* ScreenScribe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ScreenScribe.app; sourceTree = BUILT_PRODUCTS_DIR; }; B9F54FE82F10866C0070D1C2 /* ScreenCapturePermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCapturePermissionManager.swift; sourceTree = ""; }; + C1000001000000000000000B /* Prompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Prompt.swift; sourceTree = ""; }; + C1000002000000000000000B /* PromptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptManager.swift; sourceTree = ""; }; + C1000003000000000000000B /* PromptEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptEditorView.swift; sourceTree = ""; }; + C1000004000000000000000B /* PromptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptListView.swift; sourceTree = ""; }; + C128894D5D084D2FBAADA65C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C2000001000000000000000B /* OnboardingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManager.swift; sourceTree = ""; }; C2000002000000000000000B /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = ""; }; C2000003000000000000000B /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; @@ -90,10 +96,6 @@ C2000005000000000000000B /* PermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionStepView.swift; sourceTree = ""; }; C2000006000000000000000B /* APIKeyStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeyStepView.swift; sourceTree = ""; }; C2000007000000000000000B /* CompletionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionStepView.swift; sourceTree = ""; }; - C1000001000000000000000B /* Prompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Prompt.swift; sourceTree = ""; }; - C1000002000000000000000B /* PromptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptManager.swift; sourceTree = ""; }; - C1000003000000000000000B /* PromptEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptEditorView.swift; sourceTree = ""; }; - C1000004000000000000000B /* PromptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -181,6 +183,7 @@ B9583C9D2DBD8C8300F43550 /* ScreenScribe */ = { isa = PBXGroup; children = ( + C128894D5D084D2FBAADA65C /* Assets.xcassets */, B9583C972DBD8C8300F43550 /* Sources */, B9583C982DBD8C8300F43550 /* Info.entitlements */, B9583C992DBD8C8300F43550 /* Info.plist */, @@ -284,6 +287,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + F5B0C7647A784F30BB1B71D9 /* Assets.xcassets in Resources */, B9583CA72DBD8C8300F43550 /* Secrets.plist in Resources */, B9583CA82DBD8C8300F43550 /* Build.xcconfig in Resources */, B9583CAC2DBD8C8300F43550 /* Screen Capture.aif in Resources */, @@ -310,7 +314,6 @@ B9583CB82DBD8C8300F43550 /* SettingsManager.swift in Sources */, B9583CB92DBD8C8300F43550 /* ShortcutMonitor.swift in Sources */, B9583CBA2DBD8C8300F43550 /* NSImage+Extension.swift in Sources */, - B9F54FED2F1089BD0070D1C2 /* APIKeyStepView.swift in Sources */, B9F54FE92F10866C0070D1C2 /* ScreenCapturePermissionManager.swift in Sources */, B9583CBB2DBD8C8300F43550 /* main.swift in Sources */, B9583CBC2DBD8C8300F43550 /* App.swift in Sources */, diff --git a/ScreenScribe/Assets.xcassets/AppIcon.appiconset/Contents.json b/ScreenScribe/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..33957f8 --- /dev/null +++ b/ScreenScribe/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,20 @@ +{ + "images": [ + { + "filename": "Icon_512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "Icon_512.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ScreenScribe/Assets.xcassets/AppIcon.appiconset/Icon_512.png b/ScreenScribe/Assets.xcassets/AppIcon.appiconset/Icon_512.png new file mode 100644 index 0000000..fb9ab32 Binary files /dev/null and b/ScreenScribe/Assets.xcassets/AppIcon.appiconset/Icon_512.png differ diff --git a/ScreenScribe/Assets.xcassets/Contents.json b/ScreenScribe/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ScreenScribe/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ScreenScribe/Sources/Onboarding/PermissionStepView.swift b/ScreenScribe/Sources/Onboarding/PermissionStepView.swift index e09159c..e2f66e5 100644 --- a/ScreenScribe/Sources/Onboarding/PermissionStepView.swift +++ b/ScreenScribe/Sources/Onboarding/PermissionStepView.swift @@ -4,6 +4,7 @@ import Combine struct PermissionStepView: View { @StateObject private var manager = OnboardingManager.shared @StateObject private var permissionManager = ScreenCapturePermissionManager.shared + @State private var isCheckingPermission = false var body: some View { VStack(spacing: 24) { @@ -39,6 +40,82 @@ struct PermissionStepView: View { .font(.subheadline) .padding(.top, 8) + // Troubleshooting section + VStack(alignment: .leading, spacing: 8) { + Text("Troubleshooting:") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + Text("If permission is not working:") + .font(.caption) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 4) { + Text("1.") + .font(.caption) + .foregroundColor(.secondary) + Text("Open System Settings → Privacy & Security → Screen Recording") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(alignment: .top, spacing: 4) { + Text("2.") + .font(.caption) + .foregroundColor(.secondary) + Text("Remove ScreenScribe from the list") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(alignment: .top, spacing: 4) { + Text("3.") + .font(.caption) + .foregroundColor(.secondary) + Text("Quit ScreenScribe completely") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(alignment: .top, spacing: 4) { + Text("4.") + .font(.caption) + .foregroundColor(.secondary) + Text("Restart ScreenScribe") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(alignment: .top, spacing: 4) { + Text("5.") + .font(.caption) + .foregroundColor(.secondary) + Text("Accept the permission when prompted") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(alignment: .top, spacing: 4) { + Text("6.") + .font(.caption) + .foregroundColor(.secondary) + Text("Restart ScreenScribe again") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.leading, 8) + } + .frame(maxWidth: 360) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.1)) + ) + Spacer() VStack(spacing: 12) { @@ -51,6 +128,20 @@ struct PermissionStepView: View { .buttonStyle(.bordered) .controlSize(.large) + Button(action: { + Task { + isCheckingPermission = true + await permissionManager.requestPermissionAndStartMonitoring() + isCheckingPermission = false + } + }) { + Text("Recheck Permission") + .frame(width: 200) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(permissionManager.hasPermission || isCheckingPermission) + Button(action: { manager.nextStep() }) { diff --git a/ScreenScribe/Sources/Services/ScreenCapturePermissionManager.swift b/ScreenScribe/Sources/Services/ScreenCapturePermissionManager.swift index 96d0e7b..762fab4 100644 --- a/ScreenScribe/Sources/Services/ScreenCapturePermissionManager.swift +++ b/ScreenScribe/Sources/Services/ScreenCapturePermissionManager.swift @@ -17,6 +17,12 @@ final class ScreenCapturePermissionManager: ObservableObject { /// Polling interval in seconds private let pollingInterval: TimeInterval = 2.0 + /// Minimum time interval (in seconds) between showing the system permission dialog + private let popupCooldownInterval: TimeInterval = 30.0 + + /// Timestamp of the last time we showed the system permission dialog + private var lastPopupTime: Date? + private init() { // Only use safe, read-only check on init // CGPreflightScreenCaptureAccess does NOT trigger any dialog @@ -29,7 +35,8 @@ final class ScreenCapturePermissionManager: ObservableObject { /// Verify permission using ScreenCaptureKit with retry logic /// Returns true if permission is confirmed, false if all attempts fail /// On macOS Sequoia, ScreenCaptureKit can throw errors at app launch even when permission is granted - private func verifyPermissionViaScreenCaptureKit(maxAttempts: Int = 3, delaySeconds: Double = 0.5) async -> Bool { + /// This is a known issue where the system needs time to initialize screen capture subsystems + private func verifyPermissionViaScreenCaptureKit(maxAttempts: Int = 5, delaySeconds: Double = 1.0) async -> Bool { for attempt in 1...maxAttempts { do { _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) @@ -37,10 +44,25 @@ final class ScreenCapturePermissionManager: ObservableObject { stopPolling() Logger.log(.info, "ScreenCaptureKit permission verified on attempt \(attempt)") return true + } catch let error as NSError { + // On macOS Sequoia, ScreenCaptureKit can fail temporarily during system initialization + // even when permission is granted. We retry to handle these transient failures. + // Note: Some error codes like -3801 (userDeclined) may indicate actual denials, + // but Sequoia exhibits flaky behavior where retrying can succeed after system warmup. + + Logger.log(.info, "ScreenCaptureKit attempt \(attempt)/\(maxAttempts) failed: \(error.localizedDescription) (domain: \(error.domain), code: \(error.code))") + + if attempt < maxAttempts { + // Use linear backoff for better handling of slow system initialization + let delay = delaySeconds * Double(attempt) + Logger.log(.info, "Waiting \(delay)s before retry...") + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } } catch { - Logger.log(.info, "ScreenCaptureKit attempt \(attempt) failed: \(error.localizedDescription)") + Logger.log(.info, "ScreenCaptureKit attempt \(attempt)/\(maxAttempts) failed with unexpected error: \(error.localizedDescription)") if attempt < maxAttempts { - try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) + let delay = delaySeconds * Double(attempt) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } } @@ -49,10 +71,13 @@ final class ScreenCapturePermissionManager: ObservableObject { /// Request permission and start monitoring for changes func requestPermissionAndStartMonitoring() async { + Logger.log(.info, "Starting permission request and monitoring...") + // First, check via ScreenCaptureKit with retries (more reliable on macOS Sequoia) // CGPreflightScreenCaptureAccess can return false even when permission is granted - // ScreenCaptureKit can also throw errors at app launch, so we retry a few times - let hasPermissionNow = await verifyPermissionViaScreenCaptureKit(maxAttempts: 3, delaySeconds: 0.5) + // ScreenCaptureKit can also throw errors after restart/cold boot, so we retry with linear backoff + // Use more attempts (5) and longer base delay (1.0s) for initial check since we might be starting from cold boot + let hasPermissionNow = await verifyPermissionViaScreenCaptureKit(maxAttempts: 5, delaySeconds: 1.0) // If already granted, no need to show dialog if hasPermissionNow { @@ -60,10 +85,37 @@ final class ScreenCapturePermissionManager: ObservableObject { return } - // Only trigger the system permission dialog if truly not granted after retries - Logger.log(.info, "Permission not detected after retries, showing system dialog") - let result = CGRequestScreenCaptureAccess() - hasPermission = result + // Also try CGPreflightScreenCaptureAccess as a final check before showing dialog + // Sometimes it works even when ScreenCaptureKit fails + if CGPreflightScreenCaptureAccess() { + Logger.log(.info, "Permission detected via CGPreflightScreenCaptureAccess after ScreenCaptureKit failed") + hasPermission = true + return + } + + // Only trigger the system permission dialog if truly not granted after all retry methods + // AND if enough time has passed since the last popup (cooldown period) + let now = Date() + let shouldShowPopup: Bool + + if let lastTime = lastPopupTime { + let timeSinceLastPopup = now.timeIntervalSince(lastTime) + shouldShowPopup = timeSinceLastPopup >= popupCooldownInterval + + if !shouldShowPopup { + let remainingCooldown = Int(popupCooldownInterval - timeSinceLastPopup) + Logger.log(.info, "Skipping system dialog (cooldown active, \(remainingCooldown)s remaining)") + } + } else { + shouldShowPopup = true + } + + if shouldShowPopup { + Logger.log(.info, "Permission not detected after retries, showing system dialog") + let result = CGRequestScreenCaptureAccess() + hasPermission = result + lastPopupTime = now + } // If not granted, start polling if !hasPermission { @@ -109,15 +161,16 @@ final class ScreenCapturePermissionManager: ObservableObject { if !hasPermission { hasPermission = true stopPolling() - Logger.log(.info, "Screen capture permission granted") + Logger.log(.info, "Screen capture permission granted (via CGPreflightScreenCaptureAccess)") } return } - // Fallback: check via ScreenCaptureKit (single attempt since we're polling) - let verified = await verifyPermissionViaScreenCaptureKit(maxAttempts: 1, delaySeconds: 0) + // Fallback: check via ScreenCaptureKit with a couple of attempts + // On macOS Sequoia, permission detection can be flaky even during polling + let verified = await verifyPermissionViaScreenCaptureKit(maxAttempts: 2, delaySeconds: 0.5) if verified { - Logger.log(.info, "Screen capture permission granted (via ScreenCaptureKit)") + Logger.log(.info, "Screen capture permission granted (via ScreenCaptureKit polling)") } }