diff --git a/macapp/App/Sources/App/AppReducer.swift b/macapp/App/Sources/App/AppReducer.swift index 6e0798bc..1ecee092 100644 --- a/macapp/App/Sources/App/AppReducer.swift +++ b/macapp/App/Sources/App/AppReducer.swift @@ -133,7 +133,6 @@ struct AppReducer: Reducer, Sendable { case .startProtecting(let user): let onboardingWindowOpen = state.onboarding.windowOpen return .merge( - .exec { [filterVersion = state.filter.version] send in await self.api.setUserToken(user.token) guard self.network.isConnected() else { return } @@ -173,9 +172,18 @@ struct AppReducer: Reducer, Sendable { .exec { _ in try await self.app.startRelaunchWatcher() + }, + + .exec { _ in + await self.preventScreenCaptureNag() } ) + case .heartbeat(.everySixHours): + return .exec { _ in + await self.preventScreenCaptureNag() + } + case .focusedNotification(let notification): // dismiss windows/dropdowns so notification is visible, i.e. "focused" state.adminWindow.windowOpen = false @@ -272,4 +280,13 @@ struct AppReducer: Reducer, Sendable { OnboardingFeature.Reducer() } } + + func preventScreenCaptureNag() async { + switch await self.device.preventScreenCaptureNag() { + case .success: + break + case .failure(let error): + unexpectedError(id: "3d2a5573", detail: error.message) + } + } } diff --git a/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+Os.swift b/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+Os.swift index a802c711..9ba2abdf 100644 --- a/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+Os.swift +++ b/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+Os.swift @@ -1,4 +1,5 @@ import Foundation +import Gertie struct MacOSVersion: Sendable { enum Name: String, Codable { @@ -14,8 +15,8 @@ struct MacOSVersion: Sendable { let minor: Int let patch: Int - var semver: String { - "\(self.major).\(self.minor).\(self.patch)" + var semver: Semver { + .init(major: self.major, minor: self.minor, patch: self.patch) } var name: Name { @@ -31,7 +32,7 @@ struct MacOSVersion: Sendable { } var description: String { - "\(self.name.rawValue)@\(self.semver)" + "\(self.name.rawValue)@\(self.semver.string)" } } diff --git a/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+ScreenCaptureNag.swift b/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+ScreenCaptureNag.swift new file mode 100644 index 00000000..d9d397e8 --- /dev/null +++ b/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient+ScreenCaptureNag.swift @@ -0,0 +1,76 @@ +import Foundation + +@Sendable func _preventScreenCaptureNag() async -> Result { + let macosVersion = macOSVersion().semver + guard macosVersion.major >= 15 else { + return .success(()) + } + + let path = approvalFilepath() + switch loadPlist(at: path) { + case .failure(let error): + return .failure(error) + case .success(var plist): + // NB: fileformat changed between 15.0 and 15.1, see: + // https://github.com/gertrude-app/project/issues/334#issuecomment-2568295348 + if macosVersion < .init("15.1.0")! { + plist["/Applications/Gertrude.app/Contents/MacOS/Gertrude"] = Date() + .days(90) + } else { + let value: [String: Any] = [ + "kScreenCaptureAlertableUsageCount": Int(1), + "kScreenCaptureApprovalLastAlerted": Date() + .days(90), + "kScreenCaptureApprovalLastUsed": Date() + .days(90), + "kScreenCapturePrivacyHintDate": Date() + .days(90), + ] + plist["com.netrivet.gertrude.app"] = value + } + return write(plist, to: path) + } +} + +private func write(_ plist: [String: Any], to url: URL) -> Result { + do { + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: url) + return .success(()) + } catch { + return .failure(.init( + oslogging: "error writing plist to file: \(error)", + context: "DeviceClient.preventScreenCaptureNag" + )) + } +} + +private func loadPlist(at url: URL) -> Result<[String: Any], AppError> { + do { + let data = try Data(contentsOf: url) + do { + guard let plist = try PropertyListSerialization + .propertyList(from: data, options: [], format: nil) as? [String: Any] else { + return .failure(.init( + oslogging: "got nil casting Data to [String: Any]", + context: "DeviceClient.preventScreenCaptureNag" + )) + } + return .success(plist) + } catch { + return .failure(.init( + oslogging: "error casting Data to [String: Any]: \(error)", + context: "DeviceClient.preventScreenCaptureNag" + )) + } + } catch { + return .failure(.init( + oslogging: "error reading Data from plist file: \(error)", + context: "DeviceClient.preventScreenCaptureNag" + )) + } +} + +private func approvalFilepath() -> URL { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let path = "\(home)/Library/Group Cont" + + "ainers/gro" + "up.com.apple.repl" + + "ayd/Screen" + "CaptureApp" + "rovals.plist" + return URL(fileURLWithPath: path) +} diff --git a/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient.swift b/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient.swift index 43541d70..caf34c2e 100644 --- a/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient.swift +++ b/macapp/App/Sources/App/Dependencies/DeviceClient/DeviceClient.swift @@ -18,6 +18,7 @@ struct DeviceClient: Sendable { var openSystemPrefs: @Sendable (SystemPrefsLocation) async -> Void var openWebUrl: @Sendable (URL) async -> Void var osVersion: @Sendable () -> MacOSVersion + var preventScreenCaptureNag: @Sendable () async -> Result var quitBrowsers: @Sendable ([BrowserMatch]) async -> Void var requestNotificationAuthorization: @Sendable () async -> Void var runningAppFromPid: @Sendable (pid_t) -> RunningApp? @@ -50,6 +51,7 @@ extension DeviceClient: DependencyKey { openSystemPrefs: openSystemPrefs(at:), openWebUrl: { NSWorkspace.shared.open($0) }, osVersion: { macOSVersion() }, + preventScreenCaptureNag: _preventScreenCaptureNag, quitBrowsers: quitAllBrowsers, requestNotificationAuthorization: requestNotificationAuth, runningAppFromPid: { .init(pid: $0) }, @@ -92,6 +94,10 @@ extension DeviceClient: TestDependencyKey { "DeviceClient.osVersion", placeholder: .init(major: 15, minor: 0, patch: 0) ), + preventScreenCaptureNag: unimplemented( + "DeviceClient.preventScreenCaptureNag", + placeholder: .success(()) + ), quitBrowsers: unimplemented("DeviceClient.quitBrowsers"), requestNotificationAuthorization: unimplemented( "DeviceClient.requestNotificationAuthorization" @@ -125,6 +131,7 @@ extension DeviceClient: TestDependencyKey { openSystemPrefs: { _ in }, openWebUrl: { _ in }, osVersion: { .init(major: 14, minor: 0, patch: 0) }, + preventScreenCaptureNag: { .success(()) }, quitBrowsers: { _ in }, requestNotificationAuthorization: {}, runningAppFromPid: { _ in nil }, diff --git a/macapp/App/Sources/App/Types.swift b/macapp/App/Sources/App/Types.swift index b5b29d93..2b41d910 100644 --- a/macapp/App/Sources/App/Types.swift +++ b/macapp/App/Sources/App/Types.swift @@ -2,6 +2,7 @@ import ClientInterfaces import ComposableArchitecture import Foundation import MacAppRoute +import os.log typealias FeatureReducer = Reducer @@ -71,7 +72,7 @@ public extension ApiClient { appVersion: appClient.installedVersion() ?? "unknown", filterVersion: filterVersion, userIsAdmin: device.currentMacOsUserType() == .admin, - osVersion: device.osVersion().semver, + osVersion: device.osVersion().semver.string, pendingFilterSuspension: pendingFilterSuspension, pendingUnlockRequests: pendingUnlockRequests, namedApps: sendNamedApps ? device.listRunningApps().filter(\.hasName) : nil @@ -83,3 +84,26 @@ public extension ApiClient { extension URL { static let contact = URL(string: "https://gertrude.app/contact")! } + +struct AppError: Error, Equatable, Sendable { + var message: String + + init(_ message: String) { + self.message = message + } + + init(oslogging message: String, context: String? = nil) { + if let context { + os_log("[G•] AppError context: %{public}s, message: %{public}s", context, message) + } else { + os_log("[G•] AppError message: %{public}s", message) + } + self.message = message + } +} + +extension AppError: ExpressibleByStringLiteral { + init(stringLiteral value: String) { + self.message = value + } +} diff --git a/macapp/App/Sources/Relauncher/RelauncherClient.swift b/macapp/App/Sources/Relauncher/RelauncherClient.swift index 7e1fe6b2..4b56539f 100644 --- a/macapp/App/Sources/Relauncher/RelauncherClient.swift +++ b/macapp/App/Sources/Relauncher/RelauncherClient.swift @@ -2,7 +2,6 @@ import AppKit import Darwin import Dependencies import Foundation -import os.log public struct RelauncherClient: Sendable { public var commandLineArgs: @Sendable () -> [String] diff --git a/macapp/App/Tests/AppTests/AppReducerTests.swift b/macapp/App/Tests/AppTests/AppReducerTests.swift index 77f6924f..bbec2006 100644 --- a/macapp/App/Tests/AppTests/AppReducerTests.swift +++ b/macapp/App/Tests/AppTests/AppReducerTests.swift @@ -28,6 +28,8 @@ final class AppReducerTests: XCTestCase { store.deps.app.startRelaunchWatcher = startRelaunchWatcher.fn store.deps.device.boottime = { .reference - 60 } store.deps.date = .constant(.reference) + let preventScreenCaptureNag = mock(always: Result.success(())) + store.deps.device.preventScreenCaptureNag = preventScreenCaptureNag.fn await store.send(.application(.didFinishLaunching)) @@ -49,6 +51,7 @@ final class AppReducerTests: XCTestCase { await expect(setUserToken.calls).toEqual([UserData.mock.token]) await expect(enableLaunchAtLogin.calls.count).toEqual(1) await expect(startRelaunchWatcher.calls.count).toEqual(1) + await expect(preventScreenCaptureNag.calls.count).toEqual(1) let prevUser = store.state.user.data @@ -115,6 +118,17 @@ final class AppReducerTests: XCTestCase { await expect(connectionEstablished).toEqual(true) } + @MainActor + func testPreventsScreenCaptureNagEverySixHours() async { + let (store, _) = AppReducer.testStore() + let preventScreenCaptureNag = mock(always: Result.success(())) + store.deps.device.preventScreenCaptureNag = preventScreenCaptureNag.fn + await store.send(.heartbeat(.everySixHours)) + await expect(preventScreenCaptureNag.calls.count).toEqual(1) + await store.send(.heartbeat(.everySixHours)) + await expect(preventScreenCaptureNag.calls.count).toEqual(2) + } + @MainActor func testDidFinishLaunching_NoPersistentUser() async { let (store, _) = AppReducer.testStore() diff --git a/macapp/Xcode/Gertrude/Info.plist b/macapp/Xcode/Gertrude/Info.plist index 12acf044..13d626d8 100644 --- a/macapp/Xcode/Gertrude/Info.plist +++ b/macapp/Xcode/Gertrude/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.6.1 + 2.6.2 CFBundleVersion - 2.6.1 + 2.6.2 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/macapp/Xcode/GertrudeFilterExtension/Info.plist b/macapp/Xcode/GertrudeFilterExtension/Info.plist index dcb1ced6..60162bdd 100644 --- a/macapp/Xcode/GertrudeFilterExtension/Info.plist +++ b/macapp/Xcode/GertrudeFilterExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.6.1 + 2.6.2 CFBundleVersion - 2.6.1 + 2.6.2 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/macapp/readme.md b/macapp/readme.md index 20f76d33..3e988a50 100644 --- a/macapp/readme.md +++ b/macapp/readme.md @@ -78,6 +78,8 @@ - app blocking - `2.6.1` (canary as of 12/27/24) - filter polices app, blocks if awol +- `2.6.1` (canary as of 1/3/25) + - bypasses sequioa screen capture nag ## Sparkle Releases