diff --git a/.mise.toml b/.mise.toml index 4867de4..db0d6b1 100644 --- a/.mise.toml +++ b/.mise.toml @@ -37,15 +37,33 @@ run = "Scripts/package.sh" [tasks.install] description = "Build and install the app to /Applications" -run = ["mise run build", "cp -R .build/Build/Products/Debug/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"] +run = ["mise run build", "mise run stop-remove", "mise run install-open"] + +[tasks.stop-remove] +description = "Stop CaptureThis and remove the current /Applications install" +run = ["osascript -e 'quit app \"CaptureThis\"' || true", "rm -rf /Applications/CaptureThis.app"] + +[tasks.wipe-local-state] +description = "Wipe local app settings, sandbox state, and granted permissions" +run = [ + "defaults delete com.capturethis.CaptureThis 2>/dev/null || true", + "rm -rf ~/Library/Containers/com.capturethis.CaptureThis/Data 2>/dev/null || true", + "tccutil reset ScreenCapture com.capturethis.CaptureThis || true", + "tccutil reset Camera com.capturethis.CaptureThis || true", + "tccutil reset Microphone com.capturethis.CaptureThis || true", +] + +[tasks.install-open] +description = "Install the built Debug app to /Applications and open it" +run = ["cp -R .build/Build/Products/Debug/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"] [tasks.install-release] description = "Build release and install the app to /Applications" run = ["mise run release-build", "cp -R .build/Build/Products/Release/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"] -[tasks.reinstall] -description = "Rebuild and reinstall the app to /Applications" -run = ["mise run build", "rm -rf /Applications/CaptureThis.app", "cp -R .build/Build/Products/Debug/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"] +[tasks.fresh-install] +description = "Build, wipe local app state and permissions, then install to /Applications" +run = ["mise run build", "mise run stop-remove", "mise run wipe-local-state", "mise run install-open"] [tasks.build-cli] description = "Build the CLI tool" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings new file mode 100644 index 0000000..badce7f --- /dev/null +++ b/Resources/Localizable.xcstrings @@ -0,0 +1,1158 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Allow" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow" + } + } + } + }, + "Allow Camera" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Camera" + } + } + } + }, + "Allow Microphone" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Microphone" + } + } + } + }, + "Application" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Application" + } + } + } + }, + "Applied to every new recording. Requires the matching permission above." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gilt für jede neue Aufnahme. Benötigt die passende Berechtigung oben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applied to every new recording. Requires the matching permission above." + } + } + } + }, + "Camera" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera" + } + } + } + }, + "Camera overlay" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera-Overlay" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera overlay" + } + } + } + }, + "Cancel" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "Capture Defaults" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme-Standardwerte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture Defaults" + } + } + } + }, + "CaptureThis needs these to record. Optional ones can stay off." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CaptureThis braucht diese Berechtigungen zum Aufnehmen. Optionale können aus bleiben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CaptureThis needs these to record. Optional ones can stay off." + } + } + } + }, + "Check Again" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut prüfen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check Again" + } + } + } + }, + "Check permissions again" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berechtigungen erneut prüfen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check permissions again" + } + } + } + }, + "Choose Folder" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner wählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Folder" + } + } + } + }, + "Countdown" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Countdown" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Countdown" + } + } + } + }, + "Counting down…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Countdown läuft..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Counting down…" + } + } + } + }, + "Delay before capture begins after you hit Record." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verzögerung nach dem Klick auf Aufnehmen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delay before capture begins after you hit Record." + } + } + } + }, + "Denied" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgelehnt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denied" + } + } + } + }, + "Developer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entwicklung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer" + } + } + } + }, + "Display" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display" + } + } + } + }, + "Enables verbose logging and lets the app's own windows (popover, HUD, settings) appear in screen captures. Leave off for normal use." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiviert ausführliche Logs und zeigt App-Fenster (Popover, HUD, Einstellungen) in Bildschirmaufnahmen. Für normale Nutzung deaktiviert lassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enables verbose logging and lets the app's own windows (popover, HUD, settings) appear in screen captures. Leave off for normal use." + } + } + } + }, + "error.screenRecordingDenied" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme ist erforderlich. In Systemeinstellungen > Datenschutz & Sicherheit > Bildschirmaufnahme aktivieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Screen recording permission is required. Enable it in System Settings > Privacy & Security > Screen Recording." + } + } + } + }, + "Format" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Format" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Format" + } + } + } + }, + "Grant Access" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant Access" + } + } + } + }, + "Granted" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erlaubt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Granted" + } + } + } + }, + "High (HEVC)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoch (HEVC)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High (HEVC)" + } + } + } + }, + "Microphone" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microphone" + } + } + } + }, + "MOV" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MOV" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "MOV" + } + } + } + }, + "MP4" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MP4" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "MP4" + } + } + } + }, + "No recordings yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch keine Aufnahmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No recordings yet" + } + } + } + }, + "Notifications" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitteilungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications" + } + } + } + }, + "Open" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open" + } + } + } + }, + "Open Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, + "Optional" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + } + } + }, + "Optional alert when a recording finishes." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optionale Meldung nach einer Aufnahme." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional alert when a recording finishes." + } + } + } + }, + "Output" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgabe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output" + } + } + } + }, + "Pause" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + } + } + }, + "Paused" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paused" + } + } + } + }, + "Permissions" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berechtigungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissions" + } + } + } + }, + "Picking source…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quelle wählen..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Picking source…" + } + } + } + }, + "Quality" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qualität" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quality" + } + } + } + }, + "Quit" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit" + } + } + } + }, + "Recent Recordings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Letzte Aufnahmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent Recordings" + } + } + } + }, + "Record" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnehmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record" + } + } + } + }, + "Recording" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording" + } + } + } + }, + "Recording actions" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahmeaktionen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording actions" + } + } + } + }, + "Recording complete" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme fertig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording complete" + } + } + } + }, + "Recording starts soon" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme startet gleich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording starts soon" + } + } + } + }, + "Recheck" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prüfen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recheck" + } + } + } + }, + "Required" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required" + } + } + } + }, + "Required permission was denied." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderliche Berechtigung wurde abgelehnt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required permission was denied." + } + } + } + }, + "Required to capture your screen." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderlich für Bildschirmaufnahmen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required to capture your screen." + } + } + } + }, + "Required to save recordings in Movies." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderlich zum Speichern in Filme." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required to save recordings in Movies." + } + } + } + }, + "Required when camera overlay is enabled." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderlich bei aktivem Kamera-Overlay." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required when camera overlay is enabled." + } + } + } + }, + "Required when microphone recording is enabled." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderlich bei aktiver Mikrofonaufnahme." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required when microphone recording is enabled." + } + } + } + }, + "Resume" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" + } + } + } + }, + "Reveal in Finder" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Finder zeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reveal in Finder" + } + } + } + }, + "Save Folder" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speicherordner" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save Folder" + } + } + } + }, + "Screen capture failed to start." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme konnte nicht starten." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Screen capture failed to start." + } + } + } + }, + "Screen Recording" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Screen Recording" + } + } + } + }, + "Screen recording permission is required. Enable it in System Settings → Privacy & Security → Screen Recording." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme-Berechtigung erforderlich. In Systemeinstellungen > Datenschutz & Sicherheit > Bildschirmaufnahme aktivieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Screen recording permission is required. Enable it in System Settings → Privacy & Security → Screen Recording." + } + } + } + }, + "Select a source…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quelle wählen..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a source…" + } + } + } + }, + "Select the Movies folder or the CaptureThis folder in Movies." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner Filme oder den CaptureThis-Ordner in Filme auswählen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select the Movies folder or the CaptureThis folder in Movies." + } + } + } + }, + "Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "settings.developer.footer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiviert ausführliche Logs und zeigt app-eigene Fenster (Popover, HUD, Einstellungen) in Bildschirmaufnahmen. Für normale Nutzung deaktiviert lassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enables verbose logging and lets the app's own windows (popover, HUD, settings) appear in screen captures. Leave off for normal use." + } + } + } + }, + "Standard (H.264)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard (H.264)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard (H.264)" + } + } + } + }, + "Stop" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stopp" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" + } + } + } + }, + "Stop Recording" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme stoppen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop Recording" + } + } + } + }, + "Stopping…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stoppt..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stopping…" + } + } + } + }, + "System audio" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systemaudio" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System audio" + } + } + } + }, + "Unable to write the recording file." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahmedatei konnte nicht geschrieben werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to write the recording file." + } + } + } + }, + "Window" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fenster" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Window" + } + } + } + }, + "Please select the Movies folder or ~/Movies/CaptureThis to save recordings." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zum Speichern der Aufnahmen den Ordner Filme oder ~/Movies/CaptureThis auswählen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please select the Movies folder or ~/Movies/CaptureThis to save recordings." + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Resources/de.lproj/InfoPlist.strings b/Resources/de.lproj/InfoPlist.strings new file mode 100644 index 0000000..125dbdb --- /dev/null +++ b/Resources/de.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "CaptureThis verwendet die Kamera für Bild-in-Bild-Aufnahmen."; +"NSMicrophoneUsageDescription" = "CaptureThis verwendet das Mikrofon, um Sprache aufzunehmen."; +"NSScreenCaptureUsageDescription" = "CaptureThis nimmt den Bildschirm auf, um Videos zu erstellen."; diff --git a/Resources/en.lproj/InfoPlist.strings b/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..4bfaa52 --- /dev/null +++ b/Resources/en.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "CaptureThis uses the camera for picture-in-picture recording."; +"NSMicrophoneUsageDescription" = "CaptureThis uses the microphone to record your voice."; +"NSScreenCaptureUsageDescription" = "CaptureThis records your screen to create videos."; diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 3f01f40..3cd1dcb 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -47,12 +47,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { _ = NotificationService.shared AppState.shared.runStartupPermissions() + DebugMode.shared.setEnabled(AppState.shared.settings.isDebugModeEnabled) + AppState.shared.$settings + .map(\.isDebugModeEnabled) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] enabled in + DebugMode.shared.setEnabled(enabled) + self?.applyCurrentSharingTypeToAllWindows() + } + .store(in: &cancellables) + windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main ) { [weak self] notification in - self?.applyCaptureExclusions(for: notification) + Task { @MainActor in + self?.applyCaptureExclusions(for: notification) + } } } @@ -66,10 +79,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func showPopover() { guard let button = statusItem?.button else { return } + // Accessory apps aren't activated by a status-item click, so the popover + // opens without a key window and the first click inside it is swallowed + // just to focus the app. Activate + make key so the first click acts. + NSApp.activate(ignoringOtherApps: true) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) DispatchQueue.main.async { [weak self] in - self?.popover.contentViewController?.view.window?.sharingType = .none + guard let window = self?.popover.contentViewController?.view.window else { return } + window.sharingType = self?.windowSharingType ?? .none + window.makeKeyAndOrderFront(nil) } + DebugMode.shared.log("Popover shown") clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in self?.closePopover() } @@ -103,6 +123,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if window.identifier == CameraOverlayWindowController.overlayIdentifier { return } - window.sharingType = .none + window.sharingType = windowSharingType + } + + /// `.none` hides the app's own windows from screen capture. In debug mode we + /// use `.readOnly` so the popover / settings can be screenshotted. + private var windowSharingType: NSWindow.SharingType { + DebugMode.shared.isEnabled ? .readOnly : .none + } + + @MainActor + private func applyCurrentSharingTypeToAllWindows() { + let type = windowSharingType + for window in NSApp.windows where window.identifier != CameraOverlayWindowController.overlayIdentifier { + window.sharingType = type + } } } diff --git a/Sources/App/AppState+Permissions.swift b/Sources/App/AppState+Permissions.swift new file mode 100644 index 0000000..2a2f9a9 --- /dev/null +++ b/Sources/App/AppState+Permissions.swift @@ -0,0 +1,116 @@ +import AppKit +import CaptureThisCore +import Foundation + +extension AppState { + func refreshPermissionSetupState() { + Task { @MainActor in + let notificationStatus = await notificationService.authorizationStatus() + permissionSetupState = PermissionSetupState(items: permissionSetupItems(notificationStatus: notificationStatus)) + } + } + + func performPermissionSetupAction(for kind: PermissionSetupKind) { + Task { @MainActor in + switch kind { + case .screenRecording: + // Requesting access is what registers CaptureThis in the Screen + // Recording list and shows the system prompt — merely opening Settings + // never adds the entry. Once the user has denied, the request is a + // no-op, so fall back to Settings where they can flip the toggle. + if !engine.permissionService.ensureScreenRecordingAccess() { + openScreenRecordingSettings() + } + case .saveFolder: + _ = try? await fileAccessService.ensureRecordingsDirectoryAccess() + case .camera: + if engine.permissionService.cameraStatus() == .denied { + openPrivacySettings(anchor: "Privacy_Camera") + } else { + _ = await engine.permissionService.requestCameraAccess() + } + case .microphone: + if engine.permissionService.microphoneStatus() == .denied { + openPrivacySettings(anchor: "Privacy_Microphone") + } else { + _ = await engine.permissionService.requestMicrophoneAccess() + } + case .notifications: + if await notificationService.authorizationStatus() == .denied { + openNotificationsSettings() + } else { + await notificationService.requestAuthorization() + } + } + refreshPermissionSetupState() + } + } + + func openSetupForMissingPermissions() { + refreshPermissionSetupState() + openMenuBarPopover() + } + + private func permissionSetupItems(notificationStatus: PermissionStatus) -> [PermissionSetupItem] { + var items = [ + PermissionSetupItem( + kind: .screenRecording, + status: engine.permissionService.screenRecordingStatus(), + isRequired: true + ), + PermissionSetupItem( + kind: .saveFolder, + status: fileAccessService.recordingsDirectoryAccessStatus(), + isRequired: true + ) + ] + + if settings.isCameraEnabled { + items.append( + PermissionSetupItem( + kind: .camera, + status: engine.permissionService.cameraStatus(), + isRequired: true + ) + ) + } + + if settings.isMicrophoneEnabled { + items.append( + PermissionSetupItem( + kind: .microphone, + status: engine.permissionService.microphoneStatus(), + isRequired: true + ) + ) + } + + items.append( + PermissionSetupItem( + kind: .notifications, + status: notificationStatus, + isRequired: false + ) + ) + + return items + } + + private func openScreenRecordingSettings() { + openPrivacySettings(anchor: "Privacy_ScreenCapture") + } + + private func openPrivacySettings(anchor: String) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { + return + } + NSWorkspace.shared.open(url) + } + + private func openNotificationsSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") else { + return + } + NSWorkspace.shared.open(url) + } +} diff --git a/Sources/App/AppState+RecordingFlow.swift b/Sources/App/AppState+RecordingFlow.swift index 4ce4b0f..5f18f3b 100644 --- a/Sources/App/AppState+RecordingFlow.swift +++ b/Sources/App/AppState+RecordingFlow.swift @@ -13,7 +13,7 @@ extension AppState: RecordingObserver { hudController.show() case .countdown: hudController.show() - if settings.isCameraEnabled { + if settings.isCameraEnabled, captureSource == .display { try? cameraService.startPreview() } case .recording: @@ -23,7 +23,7 @@ extension AppState: RecordingObserver { recordingStartDate = nil cameraService.stopPreview() case .stopping: - break + cameraService.stopPreview() case .error: hudController.hide() cameraService.stopPreview() diff --git a/Sources/App/AppState+Startup.swift b/Sources/App/AppState+Startup.swift index 5aeb163..f32e95c 100644 --- a/Sources/App/AppState+Startup.swift +++ b/Sources/App/AppState+Startup.swift @@ -3,17 +3,6 @@ import Foundation extension AppState { func runStartupPermissions() { - Task { @MainActor in - _ = engine.permissionService.ensureScreenRecordingAccess() - if settings.isCameraEnabled { - _ = await engine.permissionService.requestCameraAccess() - } - if settings.isMicrophoneEnabled { - _ = await engine.permissionService.requestMicrophoneAccess() - } - let fileAccess = FileAccessService() - _ = try? await fileAccess.ensureRecordingsDirectoryAccess() - await notificationService.requestAuthorization() - } + refreshPermissionSetupState() } } diff --git a/Sources/App/AppState.swift b/Sources/App/AppState.swift index 4faafee..53aaea0 100644 --- a/Sources/App/AppState.swift +++ b/Sources/App/AppState.swift @@ -23,6 +23,7 @@ final class AppState: ObservableObject { @Published var errorMessage: String? @Published var presenterOverlayEnabled = false @Published var recordingStartDate: Date? + @Published var permissionSetupState = PermissionSetupState() let engine: RecordingEngine let cameraService = CameraService() @@ -31,6 +32,7 @@ final class AppState: ObservableObject { private let contentSelector: GUIContentSelector private let directoryProvider: SandboxedDirectoryProvider + let fileAccessService: FileAccessService lazy var hudController = HUDWindowController(appState: self) @@ -39,9 +41,11 @@ final class AppState: ObservableObject { settings = loadedSettings let selector = GUIContentSelector() - let dirProvider = SandboxedDirectoryProvider() + let fileAccess = FileAccessService() + let dirProvider = SandboxedDirectoryProvider(fileAccessService: fileAccess) contentSelector = selector directoryProvider = dirProvider + fileAccessService = fileAccess // Engine init needs observer; use temporary, reassign after init engine = RecordingEngine( @@ -58,10 +62,18 @@ final class AppState: ObservableObject { } func startOrStopRecording() { + if recordingState == .idle, permissionSetupState.blocksRecording { + openSetupForMissingPermissions() + return + } engine.startOrStop() } func startRecording() { + if permissionSetupState.blocksRecording { + openSetupForMissingPermissions() + return + } engine.start() } @@ -82,16 +94,10 @@ final class AppState: ObservableObject { let previous = settings settings = newSettings engine.updateSettings(newSettings) + refreshPermissionSetupState() if previous.isCameraEnabled != newSettings.isCameraEnabled { - if newSettings.isCameraEnabled { - Task { @MainActor in - let granted = await engine.permissionService.requestCameraAccess() - if !granted { - errorMessage = AppError.permissionDenied.localizedDescription - } - } - } else { + if !newSettings.isCameraEnabled { cameraService.stopPreview() } } @@ -100,4 +106,41 @@ final class AppState: ObservableObject { func recordingDurationText(for date: Date) -> String { engine.recordingDuration(since: date) } + + func openRecording(_ recording: Recording) { + openRecordingURL(recording.url) + } + + func revealRecording(_ recording: Recording) { + revealRecordingURL(recording.url) + } + + func openRecordingURL(_ url: URL) { + accessRecordingURL { + NSWorkspace.shared.open(url) + } + } + + func revealRecordingURL(_ url: URL) { + accessRecordingURL { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + + private func accessRecordingURL(action: @escaping () -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + do { + _ = try await fileAccessService.ensureRecordingsDirectoryAccess() + action() + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + self?.fileAccessService.stopAccessingIfNeeded() + } + } catch { + errorMessage = error.localizedDescription + openSetupForMissingPermissions() + } + } + } } diff --git a/Sources/App/PermissionSetupState.swift b/Sources/App/PermissionSetupState.swift new file mode 100644 index 0000000..59272d1 --- /dev/null +++ b/Sources/App/PermissionSetupState.swift @@ -0,0 +1,98 @@ +import CaptureThisCore +import Foundation + +enum PermissionSetupKind: CaseIterable, Identifiable { + case screenRecording + case saveFolder + case camera + case microphone + case notifications + + var id: Self { + self + } + + var title: String { + switch self { + case .screenRecording: + String(localized: "Screen Recording") + case .saveFolder: + String(localized: "Save Folder") + case .camera: + String(localized: "Camera") + case .microphone: + String(localized: "Microphone") + case .notifications: + String(localized: "Notifications") + } + } + + var detail: String { + switch self { + case .screenRecording: + String(localized: "Required to capture your screen.") + case .saveFolder: + String(localized: "Required to save recordings in Movies.") + case .camera: + String(localized: "Required when camera overlay is enabled.") + case .microphone: + String(localized: "Required when microphone recording is enabled.") + case .notifications: + String(localized: "Optional alert when a recording finishes.") + } + } +} + +struct PermissionSetupItem: Identifiable, Equatable { + let kind: PermissionSetupKind + let status: PermissionStatus + let isRequired: Bool + + var id: PermissionSetupKind { + kind + } + + var actionTitle: String? { + guard !status.isGranted else { return nil } + if status == .denied { + return kind == .saveFolder ? String(localized: "Choose Folder") : String(localized: "Open Settings") + } + return switch kind { + case .screenRecording: + String(localized: "Open Settings") + case .saveFolder: + String(localized: "Choose Folder") + case .camera: + String(localized: "Allow Camera") + case .microphone: + String(localized: "Allow Microphone") + case .notifications: + String(localized: "Allow") + } + } + + var statusTitle: String { + switch status { + case .granted: + String(localized: "Granted") + case .notDetermined: + if isRequired { + String(localized: "Required") + } else { + String(localized: "Optional") + } + case .denied: + String(localized: "Denied") + case .unknown: + String(localized: "Check Again") + } + } +} + +struct PermissionSetupState: Equatable { + var items: [PermissionSetupItem] = [] + + var blocksRecording: Bool { + items.isEmpty || items.contains { $0.isRequired && !$0.status.isGranted } + } +} diff --git a/Sources/Core/Extensions/Bundle+Localization.swift b/Sources/Core/Extensions/Bundle+Localization.swift new file mode 100644 index 0000000..fdc7ec7 --- /dev/null +++ b/Sources/Core/Extensions/Bundle+Localization.swift @@ -0,0 +1,9 @@ +import Foundation + +private final class CoreBundleToken {} + +extension Bundle { + static var captureThisCore: Bundle { + Bundle(for: CoreBundleToken.self) + } +} diff --git a/Sources/Core/Extensions/RecordingSettings+Updating.swift b/Sources/Core/Extensions/RecordingSettings+Updating.swift index 1a54ddd..79a0cb8 100644 --- a/Sources/Core/Extensions/RecordingSettings+Updating.swift +++ b/Sources/Core/Extensions/RecordingSettings+Updating.swift @@ -7,7 +7,8 @@ public extension RecordingSettings { microphoneEnabled: Bool? = nil, systemAudioEnabled: Bool? = nil, outputFormat: RecordingFileFormat? = nil, - recordingQuality: RecordingQuality? = nil + recordingQuality: RecordingQuality? = nil, + debugModeEnabled: Bool? = nil ) -> RecordingSettings { var copy = self if let countdownSeconds { @@ -28,6 +29,9 @@ public extension RecordingSettings { if let recordingQuality { copy.recordingQuality = recordingQuality } + if let debugModeEnabled { + copy.isDebugModeEnabled = debugModeEnabled + } return copy } } diff --git a/Sources/Core/Models/AppError.swift b/Sources/Core/Models/AppError.swift index 79b2479..b5d8b4f 100644 --- a/Sources/Core/Models/AppError.swift +++ b/Sources/Core/Models/AppError.swift @@ -10,15 +10,18 @@ public enum AppError: LocalizedError { public var errorDescription: String? { switch self { case .permissionDenied: - "Required permission was denied." + String(localized: "Required permission was denied.", bundle: .captureThisCore) case .captureFailed: - "Screen capture failed to start." + String(localized: "Screen capture failed to start.", bundle: .captureThisCore) case .fileWriteFailed: - "Unable to write the recording file." + String(localized: "Unable to write the recording file.", bundle: .captureThisCore) case .invalidSaveLocation: - "Please select the Movies folder or ~/Movies/CaptureThis to save recordings." + String( + localized: "Please select the Movies folder or ~/Movies/CaptureThis to save recordings.", + bundle: .captureThisCore + ) case .screenRecordingDenied: - "Screen recording permission is required. Enable it in System Settings → Privacy & Security → Screen Recording." + String(localized: "error.screenRecordingDenied", bundle: .captureThisCore) } } } diff --git a/Sources/Core/Models/CaptureSource.swift b/Sources/Core/Models/CaptureSource.swift index 3976608..9e72976 100644 --- a/Sources/Core/Models/CaptureSource.swift +++ b/Sources/Core/Models/CaptureSource.swift @@ -12,11 +12,11 @@ public enum CaptureSource: String, CaseIterable, Identifiable, Sendable { public var displayName: String { switch self { case .display: - "Display" + String(localized: "Display", bundle: .captureThisCore) case .window: - "Window" + String(localized: "Window", bundle: .captureThisCore) case .application: - "Application" + String(localized: "Application", bundle: .captureThisCore) } } } diff --git a/Sources/Core/Models/PermissionStatus.swift b/Sources/Core/Models/PermissionStatus.swift new file mode 100644 index 0000000..be050a8 --- /dev/null +++ b/Sources/Core/Models/PermissionStatus.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum PermissionStatus: Equatable, Sendable { + case granted + case notDetermined + case denied + case unknown + + public var isGranted: Bool { + self == .granted + } +} diff --git a/Sources/Core/Models/RecordingSettings.swift b/Sources/Core/Models/RecordingSettings.swift index d90edba..159e650 100644 --- a/Sources/Core/Models/RecordingSettings.swift +++ b/Sources/Core/Models/RecordingSettings.swift @@ -11,9 +11,9 @@ public enum RecordingFileFormat: String, Codable, CaseIterable, Identifiable, Se public var displayName: String { switch self { case .mp4: - "MP4" + String(localized: "MP4", bundle: .captureThisCore) case .mov: - "MOV" + String(localized: "MOV", bundle: .captureThisCore) } } } @@ -29,9 +29,9 @@ public enum RecordingQuality: String, Codable, CaseIterable, Identifiable, Senda public var displayName: String { switch self { case .standard: - "Standard (H.264)" + String(localized: "Standard (H.264)", bundle: .captureThisCore) case .high: - "High (HEVC)" + String(localized: "High (HEVC)", bundle: .captureThisCore) } } } @@ -43,6 +43,7 @@ public struct RecordingSettings: Codable, Sendable { public var isSystemAudioEnabled: Bool public var outputFormat: RecordingFileFormat public var recordingQuality: RecordingQuality + public var isDebugModeEnabled: Bool public init( countdownSeconds: Int = 3, @@ -50,7 +51,8 @@ public struct RecordingSettings: Codable, Sendable { isMicrophoneEnabled: Bool = true, isSystemAudioEnabled: Bool = false, outputFormat: RecordingFileFormat = .mp4, - recordingQuality: RecordingQuality = .standard + recordingQuality: RecordingQuality = .standard, + isDebugModeEnabled: Bool = false ) { self.countdownSeconds = countdownSeconds self.isCameraEnabled = isCameraEnabled @@ -58,5 +60,22 @@ public struct RecordingSettings: Codable, Sendable { self.isSystemAudioEnabled = isSystemAudioEnabled self.outputFormat = outputFormat self.recordingQuality = recordingQuality + self.isDebugModeEnabled = isDebugModeEnabled + } + + /// Tolerant decoding so settings persisted before a field existed still load. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = RecordingSettings() + func value(_ key: CodingKeys, _ fallback: T) throws -> T { + try container.decodeIfPresent(T.self, forKey: key) ?? fallback + } + countdownSeconds = try value(.countdownSeconds, defaults.countdownSeconds) + isCameraEnabled = try value(.isCameraEnabled, defaults.isCameraEnabled) + isMicrophoneEnabled = try value(.isMicrophoneEnabled, defaults.isMicrophoneEnabled) + isSystemAudioEnabled = try value(.isSystemAudioEnabled, defaults.isSystemAudioEnabled) + outputFormat = try value(.outputFormat, defaults.outputFormat) + recordingQuality = try value(.recordingQuality, defaults.recordingQuality) + isDebugModeEnabled = try value(.isDebugModeEnabled, defaults.isDebugModeEnabled) } } diff --git a/Sources/Core/Protocols/PermissionServicing.swift b/Sources/Core/Protocols/PermissionServicing.swift index 814f502..d6e2981 100644 --- a/Sources/Core/Protocols/PermissionServicing.swift +++ b/Sources/Core/Protocols/PermissionServicing.swift @@ -1,4 +1,7 @@ public protocol PermissionServicing: AnyObject { + func screenRecordingStatus() -> PermissionStatus + func cameraStatus() -> PermissionStatus + func microphoneStatus() -> PermissionStatus func ensureScreenRecordingAccess() -> Bool func requestCameraAccess() async -> Bool func requestMicrophoneAccess() async -> Bool diff --git a/Sources/Core/Services/CaptureService+Internals.swift b/Sources/Core/Services/CaptureService+Internals.swift index 2e2542b..da948f3 100644 --- a/Sources/Core/Services/CaptureService+Internals.swift +++ b/Sources/Core/Services/CaptureService+Internals.swift @@ -66,6 +66,7 @@ extension CaptureService { } func recordingOutputDidFail(_ error: Error) { + logger.error("Recording output failed: \(error.localizedDescription, privacy: .public)") let (continuations, fallbackHandler) = withStateLock { () -> (Continuations, ((Error) -> Void)?) in let continuations = drainContinuationsLocked() let handler = continuations.hasActive ? nil : errorHandler @@ -82,11 +83,13 @@ extension CaptureService { } func handleOutputEffectDidStart(_ didStart: Bool) { + logger.notice("Screen capture output effect active: \(didStart)") let handler = withStateLock { presenterOverlayHandler } handler?(didStart) } func handleRecordingOutputDidFinish() { + logger.notice("Recording output finished") let action: CompletionAction = withStateLock { finalizeActiveSegmentLocked() @@ -122,6 +125,7 @@ extension CaptureService { } func handleStreamDidStop(with error: Error) { + logger.error("Screen capture stream stopped with error: \(error.localizedDescription, privacy: .public)") let (continuations, fallbackHandler) = withStateLock { () -> (Continuations, ((Error) -> Void)?) in let continuations = drainContinuationsLocked() let handler = continuations.hasActive ? nil : errorHandler @@ -156,13 +160,37 @@ extension CaptureService { } if let streamToStop { - try? streamToStop.removeStreamOutput(sampleOutput, type: .screen) - try? streamToStop.removeStreamOutput(sampleOutput, type: .audio) - try? streamToStop.removeStreamOutput(sampleOutput, type: .microphone) + logger.notice("Stopping screen capture stream") + do { + try streamToStop.removeStreamOutput(sampleOutput, type: .screen) + } catch { + logger.error("Failed to remove screen stream output: \(error.localizedDescription, privacy: .public)") + } + do { + try streamToStop.removeStreamOutput(sampleOutput, type: .audio) + } catch { + logger.error("Failed to remove audio stream output: \(error.localizedDescription, privacy: .public)") + } + do { + try streamToStop.removeStreamOutput(sampleOutput, type: .microphone) + } catch { + logger.error("Failed to remove microphone stream output: \(error.localizedDescription, privacy: .public)") + } if let outputToRemove { - try? streamToStop.removeRecordingOutput(outputToRemove) + do { + try streamToStop.removeRecordingOutput(outputToRemove) + } catch { + logger.error("Failed to remove recording output: \(error.localizedDescription, privacy: .public)") + } } - try? await streamToStop.stopCapture() + do { + try await streamToStop.stopCapture() + logger.notice("Screen capture stream stopped") + } catch { + logger.error("Failed to stop screen capture stream: \(error.localizedDescription, privacy: .public)") + } + } else { + logger.debug("Screen capture stop requested with no active stream") } } diff --git a/Sources/Core/Services/CaptureService+Recording.swift b/Sources/Core/Services/CaptureService+Recording.swift index f7808e3..9a47bef 100644 --- a/Sources/Core/Services/CaptureService+Recording.swift +++ b/Sources/Core/Services/CaptureService+Recording.swift @@ -10,6 +10,7 @@ extension CaptureService: CaptureServicing { options: CaptureRecordingOptions, handlers: CaptureRecordingHandlers ) async throws { + logger.notice("Starting screen capture stream") if withStateLock({ stream != nil }) { try await stopAndReset() } @@ -42,6 +43,7 @@ extension CaptureService: CaptureServicing { try newStream.addRecordingOutput(newOutput) try await newStream.startCapture() + logger.notice("Screen capture stream started") withStateLock { stream = newStream @@ -51,6 +53,7 @@ extension CaptureService: CaptureServicing { phase = .recording } } catch { + logger.error("Failed to start screen capture stream: \(error.localizedDescription, privacy: .public)") try? await stopAndReset() throw error } @@ -121,6 +124,7 @@ extension CaptureService: CaptureServicing { } public func stopRecording() async throws -> URL { + logger.notice("Stopping recording") guard await waitForOutputTransitionToSettle() else { try? await stopAndReset(clearSegmentState: false) throw AppError.captureFailed @@ -137,8 +141,10 @@ extension CaptureService: CaptureServicing { outputFileType: stitchInput.outputFileType ) try await stopAndReset() + logger.notice("Recording stopped") return stitchedURL } catch { + logger.error("Failed to stop recording cleanly: \(error.localizedDescription, privacy: .public)") try? await stopAndReset(clearSegmentState: false) throw error } diff --git a/Sources/Core/Services/CaptureService.swift b/Sources/Core/Services/CaptureService.swift index ae3d37e..fb2647a 100644 --- a/Sources/Core/Services/CaptureService.swift +++ b/Sources/Core/Services/CaptureService.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import os import ScreenCaptureKit public final class CaptureService: NSObject { @@ -73,6 +74,7 @@ public final class CaptureService: NSObject { let streamBuilder: any CaptureStreamBuilding let recordingOutputBuilder: any CaptureRecordingOutputBuilding let stateLock = NSLock() + let logger = Logger(subsystem: "com.capturethis.CaptureThis", category: "Capture") override public convenience init() { self.init( diff --git a/Sources/Core/Services/DebugMode.swift b/Sources/Core/Services/DebugMode.swift new file mode 100644 index 0000000..ebccff8 --- /dev/null +++ b/Sources/Core/Services/DebugMode.swift @@ -0,0 +1,35 @@ +import Foundation +import os + +/// Runtime debug switch. When enabled it turns on verbose logging and lets the +/// app's own windows be captured (so the popover / HUD can be screenshotted). +/// Thread-safe so services can log from any queue. +public final class DebugMode: @unchecked Sendable { + public static let shared = DebugMode() + + private let lock = NSLock() + private var _isEnabled = false + private let logger = Logger(subsystem: "com.capturethis.CaptureThis", category: "Debug") + + private init() {} + + public var isEnabled: Bool { + lock.lock() + defer { lock.unlock() } + return _isEnabled + } + + public func setEnabled(_ enabled: Bool) { + lock.lock() + _isEnabled = enabled + lock.unlock() + logger.log("Debug mode \(enabled ? "enabled" : "disabled", privacy: .public)") + } + + /// Logs only when debug mode is on. Message is autoclosed so it costs nothing when off. + public func log(_ message: @autoclosure () -> String) { + guard isEnabled else { return } + let text = message() + logger.debug("\(text, privacy: .public)") + } +} diff --git a/Sources/Core/Services/PermissionService.swift b/Sources/Core/Services/PermissionService.swift index 06e6d79..af23a5c 100644 --- a/Sources/Core/Services/PermissionService.swift +++ b/Sources/Core/Services/PermissionService.swift @@ -3,12 +3,41 @@ import CoreGraphics import Foundation public final class PermissionService: PermissionServicing { - public init() {} + /// `CGPreflightScreenCaptureAccess()` returns `false` for both "never asked" + /// and "denied", so we remember whether we've ever requested to tell them + /// apart — otherwise a fresh install looks denied before the first prompt. + private static let hasRequestedScreenRecordingKey = "hasRequestedScreenRecording" + + private let defaults: UserDefaults + + public convenience init() { + self.init(defaults: .standard) + } + + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + public func screenRecordingStatus() -> PermissionStatus { + if CGPreflightScreenCaptureAccess() { + return .granted + } + return defaults.bool(forKey: Self.hasRequestedScreenRecordingKey) ? .denied : .notDetermined + } + + public func cameraStatus() -> PermissionStatus { + status(for: AVCaptureDevice.authorizationStatus(for: .video)) + } + + public func microphoneStatus() -> PermissionStatus { + status(for: AVCaptureDevice.authorizationStatus(for: .audio)) + } public func ensureScreenRecordingAccess() -> Bool { if CGPreflightScreenCaptureAccess() { return true } + defaults.set(true, forKey: Self.hasRequestedScreenRecordingKey) return CGRequestScreenCaptureAccess() } @@ -27,4 +56,17 @@ public final class PermissionService: PermissionServicing { } } } + + private func status(for authorizationStatus: AVAuthorizationStatus) -> PermissionStatus { + switch authorizationStatus { + case .authorized: + .granted + case .notDetermined: + .notDetermined + case .denied, .restricted: + .denied + @unknown default: + .unknown + } + } } diff --git a/Sources/Features/DesignSystem/Theme.swift b/Sources/Features/DesignSystem/Theme.swift new file mode 100644 index 0000000..e5d792a --- /dev/null +++ b/Sources/Features/DesignSystem/Theme.swift @@ -0,0 +1,193 @@ +import SwiftUI + +/// Shared design tokens so every surface uses the same spacing rhythm, +/// corner radii, and semantic colors instead of ad-hoc values. +enum Theme { + enum Spacing { + static let xxs: CGFloat = 2 + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 24 + } + + /// Single type ramp. macOS semantic fonts are smaller than they read + /// on iOS, so we name them by role to keep every surface consistent. + enum Typography { + static let sectionTitle = Font.headline // 13 semibold — group/section titles + static let rowTitle = Font.body // 13 — primary row text + static let rowDetail = Font.callout // 12 — supporting text + static let meta = Font.caption // 10 — timestamps / metadata + static let badge = Font.caption2.weight(.bold) // 10 bold — status badges + } + + enum Radius { + static let sm: CGFloat = 6 + static let md: CGFloat = 10 + static let lg: CGFloat = 16 + } + + enum Palette { + /// Primary brand / record accent. + static let record = Color(red: 0.90, green: 0.22, blue: 0.24) + static let paused = Color.orange + static let success = Color.green + static let warning = Color.orange + static let danger = Color.red + static let accent = Color.accentColor + + /// Subtle fill for contained rows / banners. + static let subtleFill = Color.primary.opacity(0.06) + static let hoverFill = Color.primary.opacity(0.09) + } +} + +// MARK: - Button styles + +/// Full-width, filled, focal call-to-action. Used for the primary Record button. +struct ProminentActionButtonStyle: ButtonStyle { + var tint: Color = Theme.Palette.accent + var isEnabled: Bool = true + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.xs + 2) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.md, style: .continuous) + .fill(tint.opacity(isEnabled ? 1 : 0.4)) + ) + .opacity(configuration.isPressed ? 0.82 : 1) + .contentShape(Rectangle()) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +/// Compact capsule button for secondary actions (permission prompts, etc.). +struct CapsuleActionButtonStyle: ButtonStyle { + var tint: Color = Theme.Palette.accent + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.xs + 1) + .foregroundStyle(tint) + .background( + Capsule(style: .continuous) + .fill(tint.opacity(configuration.isPressed ? 0.22 : 0.14)) + ) + .contentShape(Capsule()) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +/// Circular icon button used inside the floating recording HUD. +struct HUDIconButtonStyle: ButtonStyle { + var tint = Color.primary + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 34, height: 34) + .background( + Circle().fill(Theme.Palette.subtleFill.opacity(configuration.isPressed ? 1.6 : 1)) + ) + .contentShape(Circle()) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +/// Full-width menu row: leading icon, label, trailing shortcut hint, with a +/// hover/pressed highlight — matches the native macOS menu idiom used for the +/// popover footer (Settings / Quit). +struct MenuRowButtonStyle: ButtonStyle { + @State private var isHovering = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.xs + 1) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.sm, style: .continuous) + .fill(configuration.isPressed || isHovering ? Theme.Palette.hoverFill : .clear) + ) + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + .animation(.easeOut(duration: 0.1), value: isHovering) + } +} + +/// Row content for `MenuRowButtonStyle`: leading icon, title, trailing shortcut. +struct MenuRowLabel: View { + let title: String + let systemImage: String + var shortcut: String? + + var body: some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: systemImage) + .font(.system(size: 12)) + .frame(width: 16) + Text(title) + .font(Theme.Typography.rowDetail) + Spacer(minLength: Theme.Spacing.sm) + if let shortcut { + Text(shortcut) + .font(Theme.Typography.rowDetail) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.secondary) + } +} + +// MARK: - Reusable views + +/// Contained, icon-led banner for inline errors / notices. +struct InlineBanner: View { + enum Kind { + case error + case info + + var symbol: String { + switch self { + case .error: "exclamationmark.triangle.fill" + case .info: "info.circle.fill" + } + } + + var tint: Color { + switch self { + case .error: Theme.Palette.danger + case .info: Theme.Palette.accent + } + } + } + + let kind: Kind + let message: String + + var body: some View { + HStack(alignment: .top, spacing: Theme.Spacing.sm) { + Image(systemName: kind.symbol) + .foregroundStyle(kind.tint) + .font(.system(size: 12)) + Text(message) + .font(.caption) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(Theme.Spacing.sm) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.sm, style: .continuous) + .fill(kind.tint.opacity(0.12)) + ) + } +} diff --git a/Sources/Features/MenuBar/MenuBarView.swift b/Sources/Features/MenuBar/MenuBarView.swift index 4bf50f6..93adc0f 100644 --- a/Sources/Features/MenuBar/MenuBarView.swift +++ b/Sources/Features/MenuBar/MenuBarView.swift @@ -6,79 +6,277 @@ struct MenuBarView: View { @EnvironmentObject private var appState: AppState var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("CaptureThis") - .font(.headline) + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + header - Picker("Source", selection: $appState.captureSource) { - ForEach(CaptureSource.allCases) { source in - Text(source.displayName).tag(source) - } - } - .pickerStyle(.segmented) + SourceSegmentedControl(selection: $appState.captureSource) - Button(Self.recordButtonTitle(for: appState.recordingState)) { - appState.startOrStopRecording() - } - .keyboardShortcut(.defaultAction) - .disabled(Self.isRecordButtonDisabled(for: appState.recordingState)) + if appState.permissionSetupState.blocksRecording, appState.recordingState == .idle { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Text("Permissions") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Button { + appState.refreshPermissionSetupState() + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Check permissions again") + } + PermissionsSetupView( + state: appState.permissionSetupState, + action: appState.performPermissionSetupAction(for:) + ) + } + } else { + recordButton - if let pauseResumeTitle = Self.pauseResumeButtonTitle(for: appState.recordingState) { - Button(pauseResumeTitle) { - appState.togglePauseResume() + if let pauseResumeTitle = Self.pauseResumeButtonTitle(for: appState.recordingState) { + Button(pauseResumeTitle) { + appState.togglePauseResume() + } + .buttonStyle(.bordered) + .controlSize(.large) + .frame(maxWidth: .infinity) } } if let errorMessage = appState.errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) + InlineBanner(kind: .error, message: errorMessage) } Divider() - HStack { - Text("Recent Recordings") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() + recentRecordingsSection + + Divider() + + VStack(spacing: 0) { SettingsLink { - Text("Settings") + MenuRowLabel(title: String(localized: "Settings"), systemImage: "gearshape") } + .buttonStyle(MenuRowButtonStyle()) + + Button { + NSApp.terminate(nil) + } label: { + MenuRowLabel(title: String(localized: "Quit"), systemImage: "power") + } + .buttonStyle(MenuRowButtonStyle()) } + } + .padding(Theme.Spacing.md) + .frame(width: 320) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "record.circle") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.Palette.record) + Text("CaptureThis") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + statusPill + } + } + + @ViewBuilder + private var statusPill: some View { + if let (label, color) = Self.statusPillContent(for: appState.recordingState) { + HStack(spacing: Theme.Spacing.xs) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(label) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(color) + } + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.xxs + 1) + .background(Capsule().fill(color.opacity(0.14))) + } + } + + // MARK: - Record button + + private var recordButton: some View { + let state = appState.recordingState + let disabled = Self.isRecordButtonDisabled(for: state) + return Button { + appState.startOrStopRecording() + } label: { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: Self.recordButtonSymbol(for: state)) + Text(Self.recordButtonTitle(for: state)) + } + } + .buttonStyle(ProminentActionButtonStyle(tint: Theme.Palette.record, isEnabled: !disabled)) + .keyboardShortcut(.defaultAction) + .disabled(disabled) + } + + // MARK: - Recent recordings + + private var recentRecordingsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Recent Recordings") + .font(Theme.Typography.meta.weight(.semibold)) + .foregroundStyle(.secondary) if appState.recentRecordings.isEmpty { - Text("No recordings yet") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "tray") + .foregroundStyle(.tertiary) + Text("No recordings yet") + .font(Theme.Typography.rowDetail) + .foregroundStyle(.secondary) + } + .padding(.vertical, Theme.Spacing.sm) } else { - ForEach(appState.recentRecordings) { recording in - HStack { - Text(recording.url.lastPathComponent) - .font(.caption) - .lineLimit(1) - Spacer() - } - .contextMenu { - Button("Open") { - NSWorkspace.shared.open(recording.url) - } - Button("Reveal in Finder") { - NSWorkspace.shared.activateFileViewerSelecting([recording.url]) - } + VStack(spacing: 0) { + ForEach(appState.recentRecordings) { recording in + RecordingRow(recording: recording) } } } + } + } +} - Divider() +// MARK: - Source segmented control - Button("Quit") { - NSApp.terminate(nil) +/// Custom equal-width segmented control; the native `.segmented` Picker keeps +/// its intrinsic width and won't fill the popover, so we roll our own. +private struct SourceSegmentedControl: View { + @Binding var selection: CaptureSource + + var body: some View { + HStack(spacing: 0) { + ForEach(CaptureSource.allCases) { source in + let isSelected = source == selection + Button { + selection = source + } label: { + Text(source.displayName) + .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? Color.primary : .secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.xs + 1) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.sm - 1, style: .continuous) + .fill(isSelected ? Color(nsColor: .controlBackgroundColor) : .clear) + .shadow(color: .black.opacity(isSelected ? 0.12 : 0), radius: 1, y: 0.5) + .padding(1) + ) } - .keyboardShortcut("q") } - .padding(12) - .frame(width: 320) + .padding(2) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.sm, style: .continuous) + .fill(Theme.Palette.subtleFill) + ) + } +} + +// MARK: - Recording row + +private struct RecordingRow: View { + @EnvironmentObject private var appState: AppState + let recording: Recording + @State private var isHovering = false + + var body: some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: Self.symbol(for: recording.captureType)) + .font(.system(size: 15)) + .foregroundStyle(Theme.Palette.accent) + .frame(width: 20) + + VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { + Text(recording.url.lastPathComponent) + .font(Theme.Typography.rowDetail) + .lineLimit(1) + .truncationMode(.middle) + Text(subtitle) + .font(Theme.Typography.meta) + .foregroundStyle(.secondary) + } + + Spacer(minLength: Theme.Spacing.xs) + + Menu { + Button("Open") { appState.openRecording(recording) } + Button("Reveal in Finder") { appState.revealRecording(recording) } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 12)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .foregroundStyle(.secondary) + .help("Recording actions") + .opacity(isHovering ? 1 : 0) + } + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.sm) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.sm, style: .continuous) + .fill(isHovering ? Theme.Palette.hoverFill : .clear) + ) + .contentShape(Rectangle()) + .onTapGesture { + appState.openRecording(recording) + } + .onHover { isHovering = $0 } + .contextMenu { + Button("Open") { appState.openRecording(recording) } + Button("Reveal in Finder") { + appState.revealRecording(recording) + } + } + .help("Open \(recording.url.lastPathComponent)") + } + + private var subtitle: String { + var parts = [Self.relativeDate(recording.createdAt)] + if let duration = recording.duration { + parts.append(Self.durationText(duration)) + } + return parts.joined(separator: " · ") + } + + static func symbol(for type: Recording.CaptureType) -> String { + switch type { + case .display: "display" + case .window: "macwindow" + case .application: "app.badge" + } + } + + static func relativeDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + + static func durationText(_ duration: TimeInterval) -> String { + let total = Int(duration.rounded()) + let minutes = total / 60 + let seconds = total % 60 + return String(format: "%d:%02d", minutes, seconds) } } @@ -86,17 +284,28 @@ extension MenuBarView { static func recordButtonTitle(for state: RecordingState) -> String { switch state { case .recording(true): - "Paused" + String(localized: "Paused") case .recording(false): - "Stop" + String(localized: "Stop Recording") case .countdown: - "Counting down…" + String(localized: "Counting down…") case .pickingSource: - "Picking source…" + String(localized: "Picking source…") case .stopping: - "Stopping…" + String(localized: "Stopping…") default: - "Record" + String(localized: "Record") + } + } + + static func recordButtonSymbol(for state: RecordingState) -> String { + switch state { + case .recording: + "stop.fill" + case .countdown, .pickingSource, .stopping: + "hourglass" + default: + "record.circle.fill" } } @@ -111,10 +320,25 @@ extension MenuBarView { static func pauseResumeButtonTitle(for state: RecordingState) -> String? { if case let .recording(isPaused) = state { - return isPaused ? "Resume" : "Pause" + return isPaused ? String(localized: "Resume") : String(localized: "Pause") } return nil } + + static func statusPillContent(for state: RecordingState) -> (String, Color)? { + switch state { + case .recording(false): + ("REC", Theme.Palette.record) + case .recording(true): + ("PAUSED", Theme.Palette.paused) + case .countdown: + ("STARTING", Theme.Palette.accent) + case .stopping: + ("STOPPING", .secondary) + default: + nil + } + } } #Preview { diff --git a/Sources/Features/Recording/RecordingHUDView.swift b/Sources/Features/Recording/RecordingHUDView.swift index 6c798fe..7a1cf2f 100644 --- a/Sources/Features/Recording/RecordingHUDView.swift +++ b/Sources/Features/Recording/RecordingHUDView.swift @@ -42,29 +42,34 @@ struct RecordingHUDView: View { .frame(maxWidth: .infinity) } - HStack(spacing: 8) { + HStack(spacing: Theme.Spacing.md) { if case let .recording(isPaused) = recordingState { Button { appState.togglePauseResume() } label: { Image(systemName: Self.pauseResumeSymbolName(for: recordingState) ?? "pause.fill") - .frame(width: 18) } - .help(isPaused ? "Resume" : "Pause") + .buttonStyle(HUDIconButtonStyle()) + .help(isPaused ? String(localized: "Resume") : String(localized: "Pause")) - Button("Stop") { + Button { appState.stopRecording() + } label: { + Image(systemName: "stop.fill") } + .buttonStyle(HUDIconButtonStyle(tint: Theme.Palette.record)) .keyboardShortcut(.cancelAction) + .help(String(localized: "Stop")) } else if case .countdown = appState.recordingState { Button("Cancel") { appState.cancelRecording() } + .buttonStyle(.bordered) .keyboardShortcut(.cancelAction) } } } - .padding(16) + .padding(Theme.Spacing.lg) .frame(width: 220) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .onExitCommand { @@ -80,11 +85,11 @@ struct RecordingHUDView: View { private func color(named colorName: String) -> Color { switch colorName { case "orange": - .orange + Theme.Palette.paused case "red": - .red + Theme.Palette.record default: - .red + Theme.Palette.record } } } diff --git a/Sources/Features/Settings/PermissionsSetupView.swift b/Sources/Features/Settings/PermissionsSetupView.swift new file mode 100644 index 0000000..9351383 --- /dev/null +++ b/Sources/Features/Settings/PermissionsSetupView.swift @@ -0,0 +1,151 @@ +import CaptureThisCore +import SwiftUI + +/// The permissions list adapts to where it lives: `.settings` sits inside a +/// Form section that already draws a card, so it renders as a plain divided +/// table; `.popover` is standalone, so it wraps each group in its own card and +/// reveals reasons on hover to stay compact. +enum PermissionsSetupStyle { + case settings + case popover +} + +struct PermissionsSetupView: View { + let state: PermissionSetupState + var style: PermissionsSetupStyle = .popover + let action: (PermissionSetupKind) -> Void + + private var requiredItems: [PermissionSetupItem] { + state.items.filter(\.isRequired) + } + + private var optionalItems: [PermissionSetupItem] { + state.items.filter { !$0.isRequired } + } + + private var isPlain: Bool { + style == .settings + } + + var body: some View { + VStack(alignment: .leading, spacing: isPlain ? Theme.Spacing.sm : Theme.Spacing.md) { + group(String(localized: "Required"), items: requiredItems) + group(String(localized: "Optional"), items: optionalItems) + } + } + + @ViewBuilder + private func group(_ title: String, items: [PermissionSetupItem]) -> some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(title.uppercased()) + .font(Theme.Typography.badge) + .foregroundStyle(.tertiary) + .padding(.horizontal, Theme.Spacing.xxs) + + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if index > 0 { + Divider() + } + PermissionSetupRow(item: item, style: style) { + action(item.kind) + } + } + } + .background(groupBackground) + } + } + } + + /// The popover has no surrounding card, so each group gets its own; Settings + /// leans on the Form section's card and stays flat to avoid box-in-box. + @ViewBuilder + private var groupBackground: some View { + if isPlain { + Color.clear + } else { + RoundedRectangle(cornerRadius: Theme.Radius.sm, style: .continuous) + .fill(Theme.Palette.subtleFill) + } + } +} + +private struct PermissionSetupRow: View { + let item: PermissionSetupItem + let style: PermissionsSetupStyle + let action: () -> Void + + @State private var isHovering = false + + private var alwaysShowsDetail: Bool { + style == .settings + } + + private var showsDetail: Bool { + alwaysShowsDetail || isHovering + } + + var body: some View { + HStack(alignment: .center, spacing: Theme.Spacing.sm) { + Image(systemName: symbolName) + .foregroundStyle(symbolColor) + .font(.system(size: 15)) + .frame(width: 18) + + VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { + Text(item.kind.title) + .font(Theme.Typography.rowTitle) + .foregroundStyle(item.status.isGranted ? .secondary : .primary) + + // Settings keeps every reason on screen; the popover surfaces them on + // hover so its resting list stays a calm column of names. + if showsDetail { + Text(item.kind.detail) + .font(Theme.Typography.rowDetail) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + } + } + + Spacer(minLength: Theme.Spacing.sm) + + if let actionTitle = item.actionTitle { + Button(actionTitle, action: action) + .buttonStyle(CapsuleActionButtonStyle()) + } + } + .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, alwaysShowsDetail ? Theme.Spacing.xxs : Theme.Spacing.sm) + .contentShape(Rectangle()) + .onHover { hovering in + guard !alwaysShowsDetail else { return } + withAnimation(.easeOut(duration: 0.12)) { isHovering = hovering } + } + } + + private var symbolName: String { + switch item.status { + case .granted: + "checkmark.circle.fill" + case .denied: + "exclamationmark.triangle.fill" + case .notDetermined, .unknown: + "circle" + } + } + + /// Only a genuine denial earns an alarm color. A not-yet-granted permission + /// is the expected first-run state, so it stays neutral instead of screaming. + private var symbolColor: Color { + switch item.status { + case .granted: + Theme.Palette.success + case .denied: + Theme.Palette.danger + case .notDetermined, .unknown: + Color.secondary + } + } +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index c93c0d5..c325304 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -6,31 +6,73 @@ struct SettingsView: View { var body: some View { Form { - Section("Recording") { - Stepper( - value: Binding( - get: { appState.settings.countdownSeconds }, - set: { appState.updateSettings(appState.settings.updating(countdownSeconds: $0)) } - ), - in: 0 ... 10 - ) { - Text("Countdown: \(appState.settings.countdownSeconds)s") + Section { + PermissionsSetupView( + state: appState.permissionSetupState, + style: .settings, + action: appState.performPermissionSetupAction(for:) + ) + } header: { + HStack { + Text("Permissions") + Spacer() + Button { + appState.refreshPermissionSetupState() + } label: { + Label("Recheck", systemImage: "arrow.clockwise") + } + .buttonStyle(.borderless) + .font(Theme.Typography.rowDetail) + .help("Check permissions again") + } + } footer: { + Text("CaptureThis needs these to record. Optional ones can stay off.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Text("Countdown") + Spacer() + Text("\(appState.settings.countdownSeconds)s") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { Double(appState.settings.countdownSeconds) }, + set: { appState.updateSettings(appState.settings.updating(countdownSeconds: Int($0.rounded()))) } + ), + in: 0 ... 10, + step: 1 + ) } + } header: { + Text("Recording") + } footer: { + Text("Delay before capture begins after you hit Record.") + .font(.caption) + .foregroundStyle(.secondary) } - Section("Defaults") { - Toggle("Camera enabled", isOn: Binding( - get: { appState.settings.isCameraEnabled }, - set: { appState.updateSettings(appState.settings.updating(cameraEnabled: $0)) } - )) - Toggle("Microphone enabled", isOn: Binding( - get: { appState.settings.isMicrophoneEnabled }, - set: { appState.updateSettings(appState.settings.updating(microphoneEnabled: $0)) } - )) - Toggle("System audio enabled", isOn: Binding( - get: { appState.settings.isSystemAudioEnabled }, - set: { appState.updateSettings(appState.settings.updating(systemAudioEnabled: $0)) } - )) + Section { + Toggle(isOn: cameraBinding) { + Label("Camera overlay", systemImage: "camera") + } + Toggle(isOn: microphoneBinding) { + Label("Microphone", systemImage: "mic") + } + Toggle(isOn: systemAudioBinding) { + Label("System audio", systemImage: "speaker.wave.2") + } + } header: { + Text("Capture Defaults") + } footer: { + Text("Applied to every new recording. Requires the matching permission above.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Output") { @@ -52,9 +94,52 @@ struct SettingsView: View { } } } + + Section { + Toggle(isOn: debugModeBinding) { + Label("Debug mode", systemImage: "ladybug") + } + } header: { + Text("Developer") + } footer: { + Text(String(localized: "settings.developer.footer")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .frame(minWidth: 440, idealWidth: 480, maxWidth: 720, minHeight: 480, idealHeight: 600, maxHeight: .infinity) + .onAppear { + appState.refreshPermissionSetupState() } - .padding(20) - .frame(width: 420) + } + + private var cameraBinding: Binding { + Binding( + get: { appState.settings.isCameraEnabled }, + set: { appState.updateSettings(appState.settings.updating(cameraEnabled: $0)) } + ) + } + + private var microphoneBinding: Binding { + Binding( + get: { appState.settings.isMicrophoneEnabled }, + set: { appState.updateSettings(appState.settings.updating(microphoneEnabled: $0)) } + ) + } + + private var systemAudioBinding: Binding { + Binding( + get: { appState.settings.isSystemAudioEnabled }, + set: { appState.updateSettings(appState.settings.updating(systemAudioEnabled: $0)) } + ) + } + + private var debugModeBinding: Binding { + Binding( + get: { appState.settings.isDebugModeEnabled }, + set: { appState.updateSettings(appState.settings.updating(debugModeEnabled: $0)) } + ) } } diff --git a/Sources/Services/CameraService.swift b/Sources/Services/CameraService.swift index 4590e0c..1ca4997 100644 --- a/Sources/Services/CameraService.swift +++ b/Sources/Services/CameraService.swift @@ -1,16 +1,22 @@ import AppKit @preconcurrency import AVFoundation import CaptureThisCore +import os @MainActor final class CameraService { private var session: AVCaptureSession? private var overlayController: CameraOverlayWindowController? private let sessionQueue = DispatchQueue(label: "CaptureThis.CameraService") + private let logger = Logger(subsystem: "com.capturethis.CaptureThis", category: "Camera") func startPreview() throws { - guard session == nil else { return } + guard session == nil else { + logger.debug("Camera preview already running") + return + } guard let camera = AVCaptureDevice.default(for: .video) else { + logger.error("Unable to find default camera") throw AppError.permissionDenied } @@ -27,8 +33,11 @@ final class CameraService { overlayController = overlay // AVCaptureSession start/stop can block and should not run on the main actor. + let logger = logger sessionQueue.async { + logger.notice("Starting camera preview session") session.startRunning() + logger.notice("Camera preview session started") } } @@ -37,14 +46,29 @@ final class CameraService { self.session = nil overlayController?.detach() overlayController?.hide() + overlayController = nil - guard let session else { return } + guard let session else { + logger.debug("Camera preview stop requested with no active session") + return + } // Keep session teardown serialized with startup to avoid camera shutdown races. + let logger = logger sessionQueue.async { + logger.notice("Stopping camera preview session") if session.isRunning { session.stopRunning() } + session.beginConfiguration() + for input in session.inputs { + session.removeInput(input) + } + for output in session.outputs { + session.removeOutput(output) + } + session.commitConfiguration() + logger.notice("Camera preview session stopped and detached") } } } diff --git a/Sources/Services/FileAccessService.swift b/Sources/Services/FileAccessService.swift index 33fa636..7062d7a 100644 --- a/Sources/Services/FileAccessService.swift +++ b/Sources/Services/FileAccessService.swift @@ -1,70 +1,104 @@ import AppKit import CaptureThisCore import Foundation +import os @MainActor final class FileAccessService { private let bookmarkKey = "CaptureThis.SaveFolderBookmark" + private let logger = Logger(subsystem: "com.capturethis.CaptureThis", category: "FileAccess") private var securityScopedURL: URL? - private var isAccessing = false + private var accessCount = 0 + + func recordingsDirectoryAccessStatus() -> PermissionStatus { + resolveBookmark(startAccessing: false) == nil ? .notDetermined : .granted + } func ensureRecordingsDirectoryAccess() async throws -> URL { - if let url = resolveBookmark() { + if let url = resolveBookmark(startAccessing: true) { + logger.debug("Using saved folder bookmark for recordings directory: \(url.path, privacy: .private)") return url } guard let moviesURL = moviesDirectoryURL() else { + logger.error("Unable to resolve Movies directory") throw AppError.fileWriteFailed } let panel = NSOpenPanel() - panel.message = "Select the Movies folder or the CaptureThis folder in Movies." + panel.message = String(localized: "Select the Movies folder or the CaptureThis folder in Movies.") panel.directoryURL = moviesURL panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.canCreateDirectories = true - panel.prompt = "Grant Access" + panel.prompt = String(localized: "Grant Access") let response = panel.runModal() guard response == .OK, let selectedURL = panel.url else { + logger.notice("User cancelled folder access prompt") throw AppError.permissionDenied } let normalizedSelected = normalizeURL(selectedURL) guard let recordingsURL = recordingsDirectory(for: normalizedSelected) else { + logger.error("Rejected selected save location: \(normalizedSelected.path, privacy: .private)") throw AppError.invalidSaveLocation } - let bookmark = try normalizedSelected.bookmarkData( - options: .withSecurityScope, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) + let bookmark = try createBookmark(for: normalizedSelected) UserDefaults.standard.set(bookmark, forKey: bookmarkKey) securityScopedURL = normalizedSelected guard normalizedSelected.startAccessingSecurityScopedResource() else { + logger.error( + "Failed to start security-scoped access for selected URL: \(normalizedSelected.path, privacy: .private)" + ) throw AppError.permissionDenied } - isAccessing = true + accessCount += 1 try ensureDirectoryExists(at: recordingsURL) + logger.notice( + """ + Saved folder bookmark. selected=\(normalizedSelected.path, privacy: .private), \ + recordings=\(recordingsURL.path, privacy: .private), bytes=\(bookmark.count) + """ + ) return recordingsURL } func stopAccessingIfNeeded() { - guard isAccessing, let url = securityScopedURL else { return } + guard accessCount > 0, let url = securityScopedURL else { return } url.stopAccessingSecurityScopedResource() - isAccessing = false + accessCount -= 1 + let remainingAccessCount = accessCount + logger.debug( + "Stopped security-scoped access for URL: \(url.path, privacy: .private), remaining=\(remainingAccessCount)" + ) } private func moviesDirectoryURL() -> URL? { FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first } + private func createBookmark(for url: URL) throws -> Data { + do { + return try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } catch { + logger.error( + "Failed to create folder bookmark: \(url.path, privacy: .private), error=\(error.localizedDescription)" + ) + throw error + } + } + private func recordingsDirectory(for selectedURL: URL) -> URL? { guard let moviesURL = moviesDirectoryURL().map(normalizeURL) else { return nil } let normalizedSelected = normalizeURL(selectedURL) @@ -81,8 +115,13 @@ final class FileAccessService { return nil } - private func resolveBookmark() -> URL? { - guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil } + private func resolveBookmark(startAccessing: Bool) -> URL? { + guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { + logger.debug("No saved folder bookmark found") + return nil + } + + logger.debug("Resolving saved folder bookmark. bytes=\(data.count)") var isStale = false do { let url = try URL( @@ -93,21 +132,43 @@ final class FileAccessService { ) if isStale { + logger.notice("Saved folder bookmark is stale for URL: \(url.path, privacy: .private)") return nil } guard let recordingsURL = recordingsDirectory(for: normalizeURL(url)) else { + logger.error( + "Saved folder bookmark resolved outside allowed save locations: \(url.path, privacy: .private)" + ) return nil } securityScopedURL = url + guard startAccessing else { + return recordingsURL + } + if url.startAccessingSecurityScopedResource() { - isAccessing = true - try? ensureDirectoryExists(at: recordingsURL) + accessCount += 1 + do { + try ensureDirectoryExists(at: recordingsURL) + } catch { + logger.error( + """ + Failed to create recordings directory from saved bookmark: \ + \(recordingsURL.path, privacy: .private): \(error.localizedDescription) + """ + ) + return nil + } return recordingsURL } + logger.error( + "Saved folder bookmark resolved, but security-scoped access failed for URL: \(url.path, privacy: .private)" + ) return nil } catch { + logger.error("Failed to resolve saved folder bookmark: \(error.localizedDescription)") return nil } } diff --git a/Sources/Services/NotificationService.swift b/Sources/Services/NotificationService.swift index b1093bf..14ee1ee 100644 --- a/Sources/Services/NotificationService.swift +++ b/Sources/Services/NotificationService.swift @@ -14,16 +14,30 @@ final class NotificationService: NSObject { registerCategories() } + func authorizationStatus() async -> PermissionStatus { + let settings = await center.notificationSettings() + return switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + PermissionStatus.granted + case .notDetermined: + PermissionStatus.notDetermined + case .denied: + PermissionStatus.denied + @unknown default: + PermissionStatus.unknown + } + } + func requestAuthorization() async { _ = try? await center.requestAuthorization(options: [.alert, .sound]) } func sendRecordingCompleteNotification(for recording: Recording) { Task { - await requestAuthorization() + guard await authorizationStatus().isGranted else { return } let content = UNMutableNotificationContent() - content.title = "Recording complete" + content.title = String(localized: "Recording complete") content.body = recording.url.lastPathComponent content.categoryIdentifier = "recording.complete" content.userInfo = ["fileURL": recording.url.absoluteString] @@ -41,13 +55,13 @@ final class NotificationService: NSObject { private func registerCategories() { let openAction = UNNotificationAction( identifier: "recording.open", - title: "Open", + title: String(localized: "Open"), options: [.foreground] ) let revealAction = UNNotificationAction( identifier: "recording.reveal", - title: "Reveal in Finder", + title: String(localized: "Reveal in Finder"), options: [.foreground] ) @@ -72,9 +86,13 @@ extension NotificationService: UNUserNotificationCenterDelegate { switch response.actionIdentifier { case "recording.open": - NSWorkspace.shared.open(url) + await MainActor.run { + AppState.shared.openRecordingURL(url) + } case "recording.reveal": - NSWorkspace.shared.activateFileViewerSelecting([url]) + await MainActor.run { + AppState.shared.revealRecordingURL(url) + } default: break } diff --git a/Sources/Services/PickerService.swift b/Sources/Services/PickerService.swift index 59b0a64..dfcc3dd 100644 --- a/Sources/Services/PickerService.swift +++ b/Sources/Services/PickerService.swift @@ -1,9 +1,11 @@ import CaptureThisCore +import os import ScreenCaptureKit final class PickerService: NSObject { private var continuation: CheckedContinuation? private let picker = SCContentSharingPicker.shared + private let logger = Logger(subsystem: "com.capturethis.CaptureThis", category: "Picker") @MainActor func pickContent(allowedSource: CaptureSource) async throws -> SCContentFilter? { @@ -26,6 +28,7 @@ final class PickerService: NSObject { return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation + logger.notice("Activating content sharing picker") picker.isActive = true picker.present() } @@ -33,6 +36,7 @@ final class PickerService: NSObject { @MainActor func cancel() { + logger.notice("Canceling content sharing picker") picker.isActive = false continuation?.resume(returning: nil) continuation = nil @@ -41,6 +45,8 @@ final class PickerService: NSObject { @MainActor func finishPicking(with filter: SCContentFilter?) { + logger.notice("Finishing content sharing picker selection") + picker.isActive = false continuation?.resume(returning: filter) continuation = nil picker.remove(self) @@ -48,6 +54,8 @@ final class PickerService: NSObject { @MainActor func failPicking(with error: Error) { + logger.error("Content sharing picker failed: \(error.localizedDescription, privacy: .public)") + picker.isActive = false continuation?.resume(throwing: error) continuation = nil picker.remove(self) diff --git a/Sources/Services/SandboxedDirectoryProvider.swift b/Sources/Services/SandboxedDirectoryProvider.swift index 7f45353..bb973fc 100644 --- a/Sources/Services/SandboxedDirectoryProvider.swift +++ b/Sources/Services/SandboxedDirectoryProvider.swift @@ -5,8 +5,8 @@ final class SandboxedDirectoryProvider: OutputDirectoryProvider, @unchecked Send private let fileAccessService: FileAccessService @MainActor - init() { - fileAccessService = FileAccessService() + init(fileAccessService: FileAccessService) { + self.fileAccessService = fileAccessService } func recordingsDirectory() async throws -> URL { diff --git a/Tests/CaptureThisTests.swift b/Tests/CaptureThisTests.swift index 728ea9c..4666088 100644 --- a/Tests/CaptureThisTests.swift +++ b/Tests/CaptureThisTests.swift @@ -4,17 +4,17 @@ import XCTest final class CaptureThisTests: XCTestCase { func testMenuBarRecordButtonTitles() { - XCTAssertEqual(MenuBarView.recordButtonTitle(for: .idle), "Record") - XCTAssertEqual(MenuBarView.recordButtonTitle(for: .recording(isPaused: false)), "Stop") - XCTAssertEqual(MenuBarView.recordButtonTitle(for: .recording(isPaused: true)), "Paused") - XCTAssertEqual(MenuBarView.recordButtonTitle(for: .countdown(2)), "Counting down…") - XCTAssertEqual(MenuBarView.recordButtonTitle(for: .pickingSource), "Picking source…") - XCTAssertEqual(MenuBarView.recordButtonTitle(for: .stopping), "Stopping…") + XCTAssertEqual(MenuBarView.recordButtonTitle(for: .idle), String(localized: "Record")) + XCTAssertEqual(MenuBarView.recordButtonTitle(for: .recording(isPaused: false)), String(localized: "Stop Recording")) + XCTAssertEqual(MenuBarView.recordButtonTitle(for: .recording(isPaused: true)), String(localized: "Paused")) + XCTAssertEqual(MenuBarView.recordButtonTitle(for: .countdown(2)), String(localized: "Counting down…")) + XCTAssertEqual(MenuBarView.recordButtonTitle(for: .pickingSource), String(localized: "Picking source…")) + XCTAssertEqual(MenuBarView.recordButtonTitle(for: .stopping), String(localized: "Stopping…")) } func testMenuBarPauseResumeButtonTitle() { - XCTAssertEqual(MenuBarView.pauseResumeButtonTitle(for: .recording(isPaused: false)), "Pause") - XCTAssertEqual(MenuBarView.pauseResumeButtonTitle(for: .recording(isPaused: true)), "Resume") + XCTAssertEqual(MenuBarView.pauseResumeButtonTitle(for: .recording(isPaused: false)), String(localized: "Pause")) + XCTAssertEqual(MenuBarView.pauseResumeButtonTitle(for: .recording(isPaused: true)), String(localized: "Resume")) XCTAssertNil(MenuBarView.pauseResumeButtonTitle(for: .idle)) } @@ -25,6 +25,35 @@ final class CaptureThisTests: XCTestCase { XCTAssertTrue(MenuBarView.isRecordButtonDisabled(for: .stopping)) } + func testPermissionSetupStateBlocksRecordingUntilStatusesLoad() { + XCTAssertTrue(PermissionSetupState().blocksRecording) + } + + func testPermissionSetupStateBlocksRecordingForMissingRequiredItem() { + let state = PermissionSetupState(items: [ + PermissionSetupItem(kind: .screenRecording, status: .granted, isRequired: true), + PermissionSetupItem(kind: .saveFolder, status: .notDetermined, isRequired: true), + PermissionSetupItem(kind: .notifications, status: .notDetermined, isRequired: false) + ]) + + XCTAssertTrue(state.blocksRecording) + } + + func testPermissionSetupStateAllowsRecordingWhenRequiredItemsAreGranted() { + let state = PermissionSetupState(items: [ + PermissionSetupItem(kind: .screenRecording, status: .granted, isRequired: true), + PermissionSetupItem(kind: .saveFolder, status: .granted, isRequired: true), + PermissionSetupItem(kind: .notifications, status: .notDetermined, isRequired: false) + ]) + + XCTAssertFalse(state.blocksRecording) + } + + func testDeniedPermissionActionOpensSettings() { + let item = PermissionSetupItem(kind: .camera, status: .denied, isRequired: true) + XCTAssertEqual(item.actionTitle, String(localized: "Open Settings")) + } + func testRecordingHUDHelpers() { XCTAssertEqual(RecordingHUDView.pauseResumeSymbolName(for: .recording(isPaused: false)), "pause.fill") XCTAssertEqual(RecordingHUDView.pauseResumeSymbolName(for: .recording(isPaused: true)), "play.fill") diff --git a/Tests/CoreTests/RecordingEnginePauseResumeTests.swift b/Tests/CoreTests/RecordingEnginePauseResumeTests.swift index 400e6c9..5ff42c3 100644 --- a/Tests/CoreTests/RecordingEnginePauseResumeTests.swift +++ b/Tests/CoreTests/RecordingEnginePauseResumeTests.swift @@ -277,6 +277,18 @@ private final class MockCaptureService: CaptureServicing { } private final class MockPermissionService: PermissionServicing { + func screenRecordingStatus() -> PermissionStatus { + .granted + } + + func cameraStatus() -> PermissionStatus { + .granted + } + + func microphoneStatus() -> PermissionStatus { + .granted + } + func ensureScreenRecordingAccess() -> Bool { true } diff --git a/project.yml b/project.yml index 2d55ace..345d652 100644 --- a/project.yml +++ b/project.yml @@ -26,6 +26,8 @@ targets: deploymentTarget: "15.2" sources: - path: Sources/Core + resources: + - path: Resources/Localizable.xcstrings settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.capturethis.CaptureThisCore