From c09b820e0b21b01bd7090329e2a0bf42f4a9f6b5 Mon Sep 17 00:00:00 2001 From: Jared Henderson Date: Tue, 5 Sep 2023 11:53:25 -0400 Subject: [PATCH 1/2] macapp: fix hostnames resolved from outbound bytes not passed to app --- macapp/App/Sources/Filter/Decision+Flow.swift | 5 +- .../CompletedFlowDecisionTests.swift | 121 +++++++++++------- .../FilterDataProvider.swift | 6 +- 3 files changed, 79 insertions(+), 53 deletions(-) diff --git a/macapp/App/Sources/Filter/Decision+Flow.swift b/macapp/App/Sources/Filter/Decision+Flow.swift index a31fecc4..acb11773 100644 --- a/macapp/App/Sources/Filter/Decision+Flow.swift +++ b/macapp/App/Sources/Filter/Decision+Flow.swift @@ -1,7 +1,7 @@ import Core import Foundation import Gertie -import os.log // temp +import os.log public extension NetworkFilter { @@ -14,11 +14,10 @@ public extension NetworkFilter { } func completedFlowDecision( - _ flow: FilterFlow, + _ flow: inout FilterFlow, readBytes: Data, auditToken: Data? = nil ) -> FilterDecision.FromFlow { - var flow = flow if flow.url == nil { flow.parseOutboundData(byteString: bytesToString(readBytes)) } diff --git a/macapp/App/Tests/FilterTests/CompletedFlowDecisionTests.swift b/macapp/App/Tests/FilterTests/CompletedFlowDecisionTests.swift index c1456289..39a3495f 100644 --- a/macapp/App/Tests/FilterTests/CompletedFlowDecisionTests.swift +++ b/macapp/App/Tests/FilterTests/CompletedFlowDecisionTests.swift @@ -54,83 +54,87 @@ final class CompletedFlowDecisionTests: XCTestCase { // // user 1 var filter = TestFilter.scenario(userKeys: [502: [key1], 503: [key2]]) - let flow1 = FilterFlow.test(hostname: "one.com", userId: 502) - expect(filter.completedDecision(flow1)).toEqual(.allow(.permittedByKey(key1.id))) - let flow2 = FilterFlow.test(hostname: "two.com", userId: 502) - expect(filter.completedDecision(flow2)).toEqual(.block(.defaultNotAllowed)) + var flow1 = FilterFlow.test(hostname: "one.com", userId: 502) + expect(filter.completedDecision(&flow1)).toEqual(.allow(.permittedByKey(key1.id))) + var flow2 = FilterFlow.test(hostname: "two.com", userId: 502) + expect(filter.completedDecision(&flow2)).toEqual(.block(.defaultNotAllowed)) // user 2 filter = TestFilter.scenario(userKeys: [502: [key1], 503: [key2]]) - let flow3 = FilterFlow.test(hostname: "one.com", userId: 503) - expect(filter.completedDecision(flow3)).toEqual(.block(.defaultNotAllowed)) - let flow4 = FilterFlow.test(hostname: "two.com", userId: 503) - expect(filter.completedDecision(flow4)).toEqual(.allow(.permittedByKey(key2.id))) + var flow3 = FilterFlow.test(hostname: "one.com", userId: 503) + expect(filter.completedDecision(&flow3)).toEqual(.block(.defaultNotAllowed)) + var flow4 = FilterFlow.test(hostname: "two.com", userId: 503) + expect(filter.completedDecision(&flow4)).toEqual(.allow(.permittedByKey(key2.id))) } func testWeNolongerAllowIpAddressesAuthedByPriorHostnameAllowance() { let key = FilterKey(key: .domain(domain: "safe.com", scope: .unrestricted)) - let flow = FilterFlow.test(ipAddress: "1.2.3.4", hostname: "safe.com") + var flow = FilterFlow.test(ipAddress: "1.2.3.4", hostname: "safe.com") let filter = TestFilter.scenario(userKeys: [502: [key]]) - let decision1 = filter.completedDecision(flow) + let decision1 = filter.completedDecision(&flow) expect(decision1).toEqual(.allow(.permittedByKey(key.id))) // same ip address, unknown hostname - let flow2 = FilterFlow.test(ipAddress: "1.2.3.4", hostname: nil) - let decision2 = filter.completedDecision(flow2) + var flow2 = FilterFlow.test(ipAddress: "1.2.3.4", hostname: nil) + let decision2 = filter.completedDecision(&flow2) expect(decision2).toEqual(.block(.defaultNotAllowed)) // same ip address, different hostname - let flow3 = FilterFlow.test(ipAddress: "1.2.3.4", hostname: "bad.com") - let decision3 = filter.completedDecision(flow3) + var flow3 = FilterFlow.test(ipAddress: "1.2.3.4", hostname: "bad.com") + let decision3 = filter.completedDecision(&flow3) expect(decision3).toEqual(.block(.defaultNotAllowed)) } func testUdpRequestFromUnrestrictedAppAllowed() { let key = FilterKey(key: .skeleton(scope: .bundleId("com.skype"))) let filter = TestFilter.scenario(userKeys: [502: [key]]) - let unrestrictedAppFlow = FilterFlow.test( + var unrestrictedAppFlow = FilterFlow.test( hostname: "foo.com", bundleId: "com.skype", port: .other(333), ipProtocol: .udp(Int32(IPPROTO_UDP)) ) - let decision = filter.completedDecision(unrestrictedAppFlow) + let decision = filter.completedDecision(&unrestrictedAppFlow) expect(decision).toEqual(.allow(.permittedByKey(key.id))) // but some other app is still blocked from making same request - let otherAppFlow = FilterFlow.test( + var otherAppFlow = FilterFlow.test( hostname: "foo.com", bundleId: "com.acme.widget", port: .other(333), ipProtocol: .udp(Int32(IPPROTO_UDP)) ) - let otherAppDecision = filter.completedDecision(otherAppFlow) + let otherAppDecision = filter.completedDecision(&otherAppFlow) expect(otherAppDecision).toEqual(.block(.defaultNotAllowed)) } func testFlowAllowedImmediatelyWhenFilterCompletelySuspended() { let filter = TestFilter .scenario(suspensions: [502: .init(scope: .unrestricted, duration: 1000)]) - let decision = filter.completedDecision(.test(hostname: "radsite.com")) + var flow = FilterFlow.test(hostname: "radsite.com") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.allow(.filterSuspended)) } func testWebBrowsersOnlySuspensionAllowsBrowserRequest() { let filter = TestFilter .scenario(suspensions: [502: .init(scope: .webBrowsers, duration: 1000)]) - let decision = filter.completedDecision(.test(hostname: "radsite.com")) + var flow = FilterFlow.test(hostname: "radsite.com") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.allow(.filterSuspended)) } func testWebBrowsersOnlySuspensionDoesNotAllowWrongUser() { let filter = TestFilter.scenario(suspensions: [504: .init(scope: .webBrowsers, duration: 1000)]) - let decision = filter.completedDecision(.test(hostname: "radsite.com", userId: 502)) + var flow = FilterFlow.test(hostname: "radsite.com", userId: 502) + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.block(.defaultNotAllowed)) } func testWebBrowsersSuspensionDoesNotAllowNonWebBrowserRequest() { let filter = TestFilter.scenario(suspensions: [502: .init(scope: .webBrowsers, duration: 1000)]) - let decision = filter.completedDecision(.test(hostname: "radsite.com", bundleId: "com.xcode")) + var flow = FilterFlow.test(hostname: "radsite.com", bundleId: "com.xcode") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.block(.defaultNotAllowed)) } @@ -139,7 +143,8 @@ final class CompletedFlowDecisionTests: XCTestCase { scope: .single(.identifiedAppSlug("chrome")), duration: 1000 )]) - let decision = filter.completedDecision(.test(hostname: "radsite.com", bundleId: "com.chrome")) + var flow = FilterFlow.test(hostname: "radsite.com", bundleId: "com.chrome") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.allow(.filterSuspended)) } @@ -148,7 +153,8 @@ final class CompletedFlowDecisionTests: XCTestCase { scope: .single(.identifiedAppSlug("chrome")), duration: 1000 )]) - let decision = filter.completedDecision(.test(hostname: "radsite.com", bundleId: "com.xcode")) + var flow = FilterFlow.test(hostname: "radsite.com", bundleId: "com.xcode") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.block(.defaultNotAllowed)) } @@ -157,7 +163,8 @@ final class CompletedFlowDecisionTests: XCTestCase { scope: .single(.bundleId("com.chrome")), duration: 1000 )]) - let decision = filter.completedDecision(.test(hostname: "radsite.com", bundleId: "com.chrome")) + var flow = FilterFlow.test(hostname: "radsite.com", bundleId: "com.chrome") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.allow(.filterSuspended)) } @@ -166,7 +173,8 @@ final class CompletedFlowDecisionTests: XCTestCase { scope: .single(.bundleId("com.chrome")), duration: 1000 )]) - let decision = filter.completedDecision(.test(hostname: "radsite.com", bundleId: "com.xcode")) + var flow = FilterFlow.test(hostname: "radsite.com", bundleId: "com.xcode") + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.block(.defaultNotAllowed)) } @@ -185,33 +193,33 @@ final class CompletedFlowDecisionTests: XCTestCase { let pattern = Key.DomainRegexPattern(patternStr)! // ALLOWS any matching hostname when scope = .unrestricted var key = FilterKey(key: .domainRegex(pattern: pattern, scope: .unrestricted)) - let flow = FilterFlow.test(hostname: hostname, bundleId: "com.\(UUID())") + var flow = FilterFlow.test(hostname: hostname, bundleId: "com.\(UUID())") var filter = TestFilter.scenario(userKeys: [502: [key]]) - expect(filter.completedDecision(flow)).toEqual(.allow(.permittedByKey(key.id))) + expect(filter.completedDecision(&flow)).toEqual(.allow(.permittedByKey(key.id))) // when scope = .webBrowsers, only allows web browsers key = FilterKey(key: .domainRegex(pattern: pattern, scope: .webBrowsers)) filter = TestFilter.scenario(userKeys: [502: [key]]) - let browserFlow = FilterFlow.test(hostname: hostname, bundleId: "com.chrome") - expect(filter.completedDecision(browserFlow)).toEqual(.allow(.permittedByKey(key.id))) - let xcodeFlow = FilterFlow.test(hostname: hostname, bundleId: "com.xcode") - expect(filter.completedDecision(xcodeFlow)).toEqual(.block(.defaultNotAllowed)) + var browserFlow = FilterFlow.test(hostname: hostname, bundleId: "com.chrome") + expect(filter.completedDecision(&browserFlow)).toEqual(.allow(.permittedByKey(key.id))) + var xcodeFlow = FilterFlow.test(hostname: hostname, bundleId: "com.xcode") + expect(filter.completedDecision(&xcodeFlow)).toEqual(.block(.defaultNotAllowed)) // when scope = .single(.identifiedAppSlug), only allows matching app key = .init(key: .domainRegex(pattern: pattern, scope: .single(.identifiedAppSlug("chrome")))) filter = TestFilter.scenario(userKeys: [502: [key]]) - let appSlugFlow = FilterFlow.test(hostname: hostname, bundleId: "com.chrome") - expect(filter.completedDecision(appSlugFlow)).toEqual(.allow(.permittedByKey(key.id))) - let slackFlow = FilterFlow.test(hostname: hostname, bundleId: "com.slack") - expect(filter.completedDecision(slackFlow)).toEqual(.block(.defaultNotAllowed)) + var appSlugFlow = FilterFlow.test(hostname: hostname, bundleId: "com.chrome") + expect(filter.completedDecision(&appSlugFlow)).toEqual(.allow(.permittedByKey(key.id))) + var slackFlow = FilterFlow.test(hostname: hostname, bundleId: "com.slack") + expect(filter.completedDecision(&slackFlow)).toEqual(.block(.defaultNotAllowed)) // when scope = .single(.bundleId), only allows matching app key = .init(key: .domainRegex(pattern: pattern, scope: .single(.bundleId("com.chrome")))) filter = TestFilter.scenario(userKeys: [502: [key]]) - let bundleFlow = FilterFlow.test(hostname: hostname, bundleId: "com.chrome") - expect(filter.completedDecision(bundleFlow)).toEqual(.allow(.permittedByKey(key.id))) - let skypeFlow = FilterFlow.test(hostname: hostname, bundleId: "com.skype") - expect(filter.completedDecision(skypeFlow)).toEqual(.block(.defaultNotAllowed)) + var bundleFlow = FilterFlow.test(hostname: hostname, bundleId: "com.chrome") + expect(filter.completedDecision(&bundleFlow)).toEqual(.allow(.permittedByKey(key.id))) + var skypeFlow = FilterFlow.test(hostname: hostname, bundleId: "com.skype") + expect(filter.completedDecision(&skypeFlow)).toEqual(.block(.defaultNotAllowed)) } } @@ -229,22 +237,41 @@ final class CompletedFlowDecisionTests: XCTestCase { for (pattern, hostname) in cases { let key = FilterKey(key: .domainRegex(pattern: .init(pattern)!, scope: .unrestricted)) let filter = TestFilter.scenario(userKeys: [502: [key]]) - let decision = filter.completedDecision(.test(hostname: hostname)) + var flow = FilterFlow.test(hostname: hostname) + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.block(.defaultNotAllowed)) } } func testUnknownIpAddressAndHostNamedBlocked() { - let flow = FilterFlow.test(ipAddress: "5.5.5.5", hostname: "unknown.com") + var flow = FilterFlow.test(ipAddress: "5.5.5.5", hostname: "unknown.com") let filter = TestFilter.scenario() - let decision = filter.completedDecision(flow) + let decision = filter.completedDecision(&flow) expect(decision).toEqual(.block(.defaultNotAllowed)) } + + func testHostnameResolvedFromBytesUpdatesInoutFlowForTransmittal() { + var flow = FilterFlow.test(ipAddress: "5.5.5.5", hostname: nil) + let filter = TestFilter.scenario() + expect(flow.hostname).toBeNil() + + _ = filter.completedDecision(&flow, bytes: "••••••••••parents.gertrude.app•••••") + + // the flow is inout because looking at the outbound bytes sets more + // data on the flow, which is used to make the decision. + // in v2.0.0 -- v2.0.4, we were making the correct decision, but the + // blocked requests transmitted to the app via xpc were missing + // the resolved hostname, making that window full of bare ip addresses. + expect(flow.hostname).toEqual("parents.gertrude.app") + } } extension NetworkFilter { - func completedDecision(_ flow: FilterFlow) -> FilterDecision.FromFlow { - completedFlowDecision(flow, readBytes: .init()) + func completedDecision( + _ flow: inout FilterFlow, + bytes: String? = nil + ) -> FilterDecision.FromFlow { + completedFlowDecision(&flow, readBytes: bytes?.data(using: .utf8) ?? .init()) } } @@ -266,7 +293,7 @@ extension CompletedFlowDecisionTests { func assertDecisions(_ cases: [(TestCase.Input, TestCase.Decision)]) { for (input, decision) in cases { let key: FilterKey - let flow: FilterFlow + var flow: FilterFlow switch input { case .domain(let keyDomain, let flowHostname): key = FilterKey(key: .domain(domain: .init(keyDomain)!, scope: .unrestricted)) @@ -284,7 +311,7 @@ extension CompletedFlowDecisionTests { let filter = TestFilter.scenario(userKeys: [502: [key]]) let flowDecision = decision == .allow ? FilterDecision.FromFlow .allow(.permittedByKey(key.id)) : .block(.defaultNotAllowed) - expect(filter.completedFlowDecision(flow, readBytes: .init())).toEqual(flowDecision) + expect(filter.completedFlowDecision(&flow, readBytes: .init())).toEqual(flowDecision) } } } diff --git a/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift b/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift index a01ab1bb..57bd3b21 100644 --- a/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift +++ b/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift @@ -95,7 +95,7 @@ class FilterDataProvider: NEFilterDataProvider { withFilterInbound: false, peekInboundBytes: Int.max, filterOutbound: true, - peekOutboundBytes: 250 + peekOutboundBytes: 1024 ) } } @@ -112,9 +112,9 @@ class FilterDataProvider: NEFilterDataProvider { flowUserIds = [:] } - let filterFlow = FilterFlow(flow, userId: userId) + var filterFlow = FilterFlow(flow, userId: userId) let decision = store.completedFlowDecision( - filterFlow, + &filterFlow, readBytes: readBytes, auditToken: flow.sourceAppAuditToken ) From 40fa527d3a23a6a48472644b87fb54c5f4a15e42 Mon Sep 17 00:00:00 2001 From: Jared Henderson Date: Wed, 6 Sep 2023 12:14:14 -0400 Subject: [PATCH 2/2] macapp: fix bug where computer sleep can cause double browser quit --- macapp/App/Sources/Filter/Filter.swift | 39 +++++++++--- .../App/Sources/TestSupport/TestSupport.swift | 9 +++ .../FilterTests/FilterReducerTests.swift | 61 +++++++++++-------- macapp/Xcode/Gertrude/Info.plist | 4 +- .../Xcode/GertrudeFilterExtension/Info.plist | 4 +- macapp/readme.md | 3 + 6 files changed, 82 insertions(+), 38 deletions(-) diff --git a/macapp/App/Sources/Filter/Filter.swift b/macapp/App/Sources/Filter/Filter.swift index 97d0e0b3..3a1c6cbf 100644 --- a/macapp/App/Sources/Filter/Filter.swift +++ b/macapp/App/Sources/Filter/Filter.swift @@ -21,7 +21,8 @@ public struct Filter: Reducer, Sendable { case flowBlocked(FilterFlow, AppDescriptor) case cacheAppDescriptor(String, AppDescriptor) case loadedPersistentState(Persistent.State?) - case suspensionExpired(uid_t) + case suspensionTimerEnded(uid_t) + case staleSuspensionFound(uid_t) case heartbeat } @@ -33,8 +34,8 @@ public struct Filter: Reducer, Sendable { @Dependency(\.uuid) var uuid private enum CancelId: Hashable { - case suspension(for: uid_t) case heartbeat + case suspensionTimer(for: uid_t) } public func reduce(into state: inout State, action: Action) -> Effect { @@ -111,14 +112,20 @@ public struct Filter: Reducer, Sendable { } return expiredSuspensionUserIds.isEmpty ? .none : .run { [expiredSuspensionUserIds] send in for userId in expiredSuspensionUserIds { - try await xpc.notifyFilterSuspensionEnded(userId) - await send(.suspensionExpired(userId)) + await send(.staleSuspensionFound(userId)) } } - case .suspensionExpired(let userId): + case .suspensionTimerEnded(let userId): state.suspensions[userId] = nil - return .none + return .run { _ in try await xpc.notifyFilterSuspensionEnded(userId) } + + case .staleSuspensionFound(let userId): + state.suspensions[userId] = nil + return .merge( + .cancel(id: CancelId.suspensionTimer(for: userId)), + .run { _ in try await xpc.notifyFilterSuspensionEnded(userId) } + ) case .cacheAppDescriptor("", _): return .none // don't cache empty bundle id @@ -143,7 +150,7 @@ public struct Filter: Reducer, Sendable { case .xpc(.receivedAppMessage(.endFilterSuspension(let userId))): state.suspensions[userId] = nil - return .cancel(id: CancelId.suspension(for: userId)) + return .cancel(id: CancelId.suspensionTimer(for: userId)) case .xpc(.receivedAppMessage(.suspendFilter(let userId, let duration))): state.suspensions[userId] = .init( @@ -152,10 +159,12 @@ public struct Filter: Reducer, Sendable { now: now ) return .run { send in + // NB: this sleep pauses (and thus becomes incorrect) when the computer is asleep + // ideally we should use ContinuousClock instead, but it's not available for our targets + // so we check for stale suspensions in the heartbeat, cancelling the timer try await mainQueue.sleep(for: .seconds(duration.rawValue)) - try await xpc.notifyFilterSuspensionEnded(userId) - await send(.suspensionExpired(userId)) - }.cancellable(id: CancelId.suspension(for: userId), cancelInFlight: true) + await send(.suspensionTimerEnded(userId)) + }.cancellable(id: CancelId.suspensionTimer(for: userId), cancelInFlight: true) case .xpc(.receivedAppMessage(.userRules(let userId, let keys, let manifest))): state.userKeys[userId] = keys @@ -190,3 +199,13 @@ public struct Filter: Reducer, Sendable { } let FIVE_MINUTES_IN_SECONDS = 60.0 * 5.0 + +#if DEBUG + import Darwin + + func eprint(_ items: Any...) { + let s = items.map { "\($0)" }.joined(separator: " ") + fputs(s + "\n", stderr) + fflush(stderr) + } +#endif diff --git a/macapp/App/Sources/TestSupport/TestSupport.swift b/macapp/App/Sources/TestSupport/TestSupport.swift index 2b393786..59dc5817 100644 --- a/macapp/App/Sources/TestSupport/TestSupport.swift +++ b/macapp/App/Sources/TestSupport/TestSupport.swift @@ -52,6 +52,15 @@ public struct ControllingNow { ) } + /// advance the time, but not the scheduler. + /// this simulates when the computer is asleep, when timers spun up + /// by mainQueue.sleep(for:) are suspended (because i can't use ContinuousClock) + /// but real wall-clock time is advancing + public func simulateComputerSleep(seconds advance: Int) { + let current = elapsed.value + elapsed.setValue(current + advance) + } + public func advance(seconds advance: Int) async { let current = elapsed.value elapsed.setValue(current + advance) diff --git a/macapp/App/Tests/FilterTests/FilterReducerTests.swift b/macapp/App/Tests/FilterTests/FilterReducerTests.swift index ba3d230b..d77ca816 100644 --- a/macapp/App/Tests/FilterTests/FilterReducerTests.swift +++ b/macapp/App/Tests/FilterTests/FilterReducerTests.swift @@ -217,7 +217,7 @@ import XExpect await mainQueue.advance(by: .seconds(1)) await expect(notifyExpired.invocations).toEqual([502]) - await store.receive(.suspensionExpired(502)) { + await store.receive(.suspensionTimerEnded(502)) { $0.suspensions[502] = nil } } @@ -283,7 +283,7 @@ import XExpect await mainQueue.advance(by: .seconds(100)) await expect(notifyExpired.invocations).toEqual([504]) - await store.receive(.suspensionExpired(504)) { + await store.receive(.suspensionTimerEnded(504)) { $0.suspensions = [ 502: .init(scope: .unrestricted, duration: 600, now: store.deps.date.now), 503: .init(scope: .unrestricted, duration: 400, now: store.deps.date.now), @@ -301,29 +301,13 @@ import XExpect // last remaining suspension expires await mainQueue.advance(by: .seconds(200)) await expect(notifyExpired.invocations).toEqual([504, 502]) - await store.receive(.suspensionExpired(502)) { + await store.receive(.suspensionTimerEnded(502)) { $0.suspensions = [:] } } - // set up so we have an expired suspension in state - // this can happen when the computer SLEEPS for some of the suspension - // during which time, the timer is not running, so when it wakes - // the timer shows time remaining, but the suspension has expired - // in this case, we want to notify the app asap - // NB: filter always checks suspension absolute time, so there's no danger - // of a wrongly prolonged suspension, just that we need to kill the browsers - func testHeartbeatCleansUpDanglingSuspension() async { - let (store, mainQueue) = Filter.testStore { - // by setting the suspension into state during setup, - // we bypass the expiration timer being set, which allows - // to get into the state where the heartbeat will clean up - $0.suspensions[502] = .init( - scope: .unrestricted, - duration: 60 * 10 + 30, // <-- expires 10.5 minutes after 1970 - now: Date(timeIntervalSince1970: 0) - ) - } + func testHeartbeatCleansUpDanglingSuspensionFromSleepConfusingTimer() async { + let (store, mainQueue) = Filter.testStore() let time = ControllingNow(starting: Date(timeIntervalSince1970: 0), with: mainQueue) store.deps.date = time.generator @@ -333,13 +317,42 @@ import XExpect await store.send(.extensionStarted) // start hearbeat - await time.advance(seconds: 60 * 10) + await store.send(.xpc(.receivedAppMessage(.suspendFilter(userId: 502, duration: 630)))) { + $0.suspensions = [ + 502: .init( + scope: .unrestricted, + duration: 60 * 10 + 30, // <-- expires 10.5 minutes after 1970 + now: Date(timeIntervalSince1970: 0) + ), + ] + } + + await time.advance(seconds: 60 * 1) // advance 1 of 10 minutes and then.. + + // ... the user puts the computer to sleep for an hour + // upon wakeup, suspension is over, but the timer still has 9 minutes left. + // the filter should notify the app immediately, so it can quit browsers + // and the dangling timer should be cancelled so we don't quit twice + time.simulateComputerSleep(seconds: 60 * 60) + + // on waking up, the suspension is still in memory, but it is now expired expect(store.state.suspensions[502]).not.toBeNil() - await time.advance(seconds: 60) - await store.receive(.suspensionExpired(502)) { + // advance to next heartbeat, which should trigger cleanup logic + await time.advance(seconds: 60 * 1) + + // assert that action is emitted, and suspension is removed from state... + await store.receive(.staleSuspensionFound(502)) { $0.suspensions = [:] } + + // ... and we have notified the app so browsers can be quit + await expect(notifyExpired.invocations).toEqual([502]) + + // now advance long enough that the confused timer would expire... + await time.advance(seconds: 60 * 10) + + // and assert that we haven't notified the app again await expect(notifyExpired.invocations).toEqual([502]) } } diff --git a/macapp/Xcode/Gertrude/Info.plist b/macapp/Xcode/Gertrude/Info.plist index f96a073f..b2d26efd 100644 --- a/macapp/Xcode/Gertrude/Info.plist +++ b/macapp/Xcode/Gertrude/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.0.4 + 2.0.5 CFBundleVersion - 2.0.4 + 2.0.5 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/macapp/Xcode/GertrudeFilterExtension/Info.plist b/macapp/Xcode/GertrudeFilterExtension/Info.plist index 30b788f0..d6a42898 100644 --- a/macapp/Xcode/GertrudeFilterExtension/Info.plist +++ b/macapp/Xcode/GertrudeFilterExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.0.4 + 2.0.5 CFBundleVersion - 2.0.4 + 2.0.5 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/macapp/readme.md b/macapp/readme.md index 7a05fbb3..2e0cc6b3 100644 --- a/macapp/readme.md +++ b/macapp/readme.md @@ -30,6 +30,9 @@ - fix flash of light theme when loading app windows in dark mode - fixed long blocked urls in catalina - fixed app window scrollbars in dark mode +- `2.0.5` (9/6/23) + - fixed domains resolved from outbound bytes not showing up in blocked requests window + - fixed double browser quit from computer sleep during filter suspension ## Sparkle Releases