Fix Control Center presentation and time-dependent tests (#28)
## Summary / 摘要
- Make one coordinator the sole owner of Control Center presentation,
activation, exact-window registration, and key-window state across cold
launch, reopen, menu, overlay, and update paths.
- Remove the startup alpha/input gate; appearance hydration now applies
only `NSAppearance` while the window stays visible and interactive.
- Keep overlay panels passive by default and grant the session bubble
keyboard focus only through an explicit, revocable lease.
- Add bounded lifecycle diagnostics and a fail-closed cold-launch
release gate that rebuilds the exact worktree App and proves singleton
identity, layer, alpha, nonblank pixels, overlay ordering, and retained
focus.
- Replace every retention-sensitive fixed Rust event timestamp with one
current UTC fixture base plus relative offsets; the audit removed 25
calendar-dependent timestamps, including the failing 2026-07-17 cases,
imminent 2026-07-20 cases, and 2099 escape fixtures.
- Add a CI-enforced Rust source validator that follows same-file fixture
helpers, rejects fixed RFC 3339 timestamps crossing real event-retention
APIs, and permits isolated deterministic schema/serialization/migration
samples.
## Validation / 验证
- `cargo test --workspace --locked` — complete Rust workspace passed
(petcore unit 279; integration A/B/C/D 96/40/66/160; CLI and type suites
passed).
- `cargo clippy -p petcore --all-targets --locked -- -D warnings`
passed.
- `python3 script/validate_time_independent_tests.py --root .` passed;
running the same validator against the unfixed main tree reports all 25
risky timestamps.
- `python3 script/tests/test_validation_tooling.py` — 56 tests passed.
- `./script/validate_swift_tests.sh --scope all` — 749 tests passed.
- `./script/validate_app_lifecycle_contract.sh` — 16/16 passed.
- `APC_VALIDATE_HOST_UI=1 APC_MAIN_UI_COLD_LAUNCH_ITERATIONS=20
./script/validate_main_window_ui.sh` — 20/20 cold launches passed.
- Computer Use acceptance — five destinations, three bundled pets,
action preview, pet hide/show, Control Center close/reopen, and two
consecutive quit/relaunch cycles passed.
- `./script/validate_pre_push.sh` passed after the Rust and source-gate
update.
- `./script/validate_test_isolation.sh` and
`./script/validate_build_scripts_safety.sh --static-only` passed.
This PR updates the release-control-plane inventory, so it must complete
PR CI and then receive a trusted manual squash merge.
## Development claim / 开发声明
- Domain: `release-control-plane`
- Claim: `control-plane`
- Base commit: `e216a4fd13af88902cf2bd77d23939fe606c9383`
- Release preparation: `no`
- Focused validation:
- `python3 script/tests/test_validation_tooling.py`
- `./script/validate_build_scripts_safety.sh --static-only`
<!-- apc-engineering-coordination-v1
{"claim_overlap_rejections":1,"merge_tree_conflicts":0,"schema_version":"apc.engineering-coordination.v1","task_created_at":"2026-08-16T12:09:52Z","train_conflict_resolutions":0}
-->
---------
Co-authored-by: gd-ops <gd-ops@xmfunny.com>
This commit is contained in:
1 parent
e216a4fd13
commit
80f7912026
25 files changed
+1359
-652
No files matched your search
@@ -61,11 +61,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
guard AppSingleInstanceCoordinator.shared.claim == .primary else { return }
|
||||
AppDiagnostics.shared.log(.notice, category: "lifecycle", event: "app_did_finish_launching")
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// Defer one turn so SwiftUI installs the primary activation handler
|
||||
// and AppKit completes its launch-time scene ordering before the
|
||||
// shared presenter fronts the registered Control Center window.
|
||||
DispatchQueue.main.async {
|
||||
// The presentation coordinator owns activation and key-window state.
|
||||
// A bounded host-validation-only delay exercises both SwiftUI setup
|
||||
// orderings without changing production launch timing.
|
||||
ControlCenterColdLaunchTiming.schedule {
|
||||
AppSingleInstanceCoordinator.shared.activatePrimaryInstance()
|
||||
}
|
||||
}
|
||||
@@ -208,7 +207,7 @@ struct AgentPetCompanionApp: App {
|
||||
.apcInterfaceLanguage(store)
|
||||
.background {
|
||||
if AppLaunchMode.manualInstallationRequest == nil {
|
||||
InitialAppearanceWindowGateView(
|
||||
InitialAppearanceWindowView(
|
||||
readiness: store.initialAppearanceReadiness,
|
||||
theme: store.behavior.appearanceTheme
|
||||
)
|
||||
@@ -412,7 +411,6 @@ private struct AppStatusItemLabel: View {
|
||||
.onAppear {
|
||||
store.setMainWindowPresenter {
|
||||
openWindow(id: "main")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,7 +506,7 @@ private struct MainWindowContent: View {
|
||||
.background {
|
||||
ZStack {
|
||||
if AppLaunchMode.manualInstallationRequest == nil {
|
||||
InitialAppearanceWindowGateView(
|
||||
InitialAppearanceWindowView(
|
||||
readiness: store.initialAppearanceReadiness,
|
||||
theme: store.behavior.appearanceTheme
|
||||
)
|
||||
|
||||
@@ -9,20 +9,6 @@ enum OverlayBubbleDisclosureDirection: Equatable {
|
||||
case collapsing
|
||||
}
|
||||
|
||||
enum InitialOverlayFocusPolicy {
|
||||
static func shouldRestoreControlCenter(
|
||||
controlCenterWasKeyBeforePresentation: Bool,
|
||||
appIsActive: Bool,
|
||||
controlCenterIsOpen: Bool,
|
||||
windowIsVisible: Bool
|
||||
) -> Bool {
|
||||
controlCenterWasKeyBeforePresentation
|
||||
&& appIsActive
|
||||
&& controlCenterIsOpen
|
||||
&& windowIsVisible
|
||||
}
|
||||
}
|
||||
|
||||
enum OverlayBubbleDisclosureAction: Equatable {
|
||||
case revealBubble
|
||||
case revealCollapsedStandaloneStack
|
||||
@@ -790,6 +776,10 @@ final class AppStore: ObservableObject {
|
||||
private let overlayPlacementJournalStore: OverlayPlacementJournalStore
|
||||
private let agentSessionRouteOpener: AgentSessionRouteOpener
|
||||
private let runtimeHandoffIfNeeded: RuntimeHandoffCheck
|
||||
private let controlCenterWindowProvider:
|
||||
ControlCenterPresentationCoordinator.WindowProvider
|
||||
private let controlCenterApplicationActivator:
|
||||
ControlCenterPresentationCoordinator.ApplicationActivator
|
||||
private let applicationAppearanceApplier: ApplicationAppearanceApplier
|
||||
private let applicationLanguageApplier: ApplicationLanguageApplier
|
||||
private let overlayPresenter: OverlayPresenter
|
||||
@@ -827,14 +817,34 @@ final class AppStore: ObservableObject {
|
||||
private var behaviorMutationTask: Task<Void, Never>?
|
||||
private var behaviorMutationSequence: UInt64 = 0
|
||||
private var pendingBehaviorMutationCount = 0
|
||||
private var mainWindowPresenter: (() -> Void)?
|
||||
private weak var controlCenterWindow: NSWindow?
|
||||
private var controlCenterCloseObserver: AnyCancellable?
|
||||
private var connectionsModelObservation: AnyCancellable?
|
||||
private(set) var controlCenterIsOpen = false
|
||||
private var pendingMainWindowPresentation = false
|
||||
private var pendingMainWindowPresentationChecksRuntimeHandoff = true
|
||||
private var pendingControlCenterFronting = false
|
||||
private lazy var controlCenterPresentationCoordinator =
|
||||
ControlCenterPresentationCoordinator(
|
||||
identifier: Self.controlCenterWindowIdentifier,
|
||||
windowProvider: controlCenterWindowProvider,
|
||||
activateApplication: controlCenterApplicationActivator,
|
||||
runtimeHandoffIfNeeded: { [weak self] in
|
||||
self?.runtimeHandoffIfNeeded() ?? false
|
||||
},
|
||||
onWindowOpened: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.overlayController.controlCenterDidOpen()
|
||||
self.diagnostics.log(
|
||||
.debug,
|
||||
category: "lifecycle",
|
||||
event: "control_center_opened"
|
||||
)
|
||||
},
|
||||
onWindowClosed: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.overlayController.controlCenterDidClose()
|
||||
self.diagnostics.log(
|
||||
.debug,
|
||||
category: "lifecycle",
|
||||
event: "control_center_closed"
|
||||
)
|
||||
}
|
||||
)
|
||||
private var generationMessagesTask: Task<Void, Never>?
|
||||
private var latestGenerationRestoreAttemptState = LatestGenerationRestoreAttemptState.notAttempted
|
||||
private var latestGenerationRestoreAttemptSequence: UInt64 = 0
|
||||
@@ -885,6 +895,14 @@ final class AppStore: ObservableObject {
|
||||
static let controlCenterWindowIdentifier = NSUserInterfaceItemIdentifier(
|
||||
"dev.agentpet.companion.control-center"
|
||||
)
|
||||
|
||||
var controlCenterIsOpen: Bool {
|
||||
controlCenterPresentationCoordinator.isOpen
|
||||
}
|
||||
|
||||
var controlCenterPresentationPhase: ControlCenterPresentationCoordinator.Phase {
|
||||
controlCenterPresentationCoordinator.phase
|
||||
}
|
||||
private static let defaultOverlayKeyboardFocusHandler: OverlayKeyboardFocusHandler = { controller, action in
|
||||
switch action {
|
||||
case .bubbleSessions:
|
||||
@@ -915,6 +933,10 @@ final class AppStore: ObservableObject {
|
||||
runtimeHandoffIfNeeded = {
|
||||
AppUpdateHandoffCoordinator.shared.restartIfInstalledBuildChanged()
|
||||
}
|
||||
controlCenterWindowProvider = { NSApp?.windows ?? [] }
|
||||
controlCenterApplicationActivator = {
|
||||
NSApp?.activate(ignoringOtherApps: true)
|
||||
}
|
||||
applicationAppearanceApplier = { theme in
|
||||
APCApplicationAppearance.apply(theme)
|
||||
}
|
||||
@@ -969,6 +991,10 @@ final class AppStore: ObservableObject {
|
||||
.failed(.applicationUnavailable)
|
||||
},
|
||||
runtimeHandoffIfNeeded: @escaping RuntimeHandoffCheck = { false },
|
||||
controlCenterWindowProvider: @escaping
|
||||
ControlCenterPresentationCoordinator.WindowProvider = { [] },
|
||||
controlCenterApplicationActivator: @escaping
|
||||
ControlCenterPresentationCoordinator.ApplicationActivator = {},
|
||||
applicationAppearanceApplier: @escaping ApplicationAppearanceApplier = { theme in
|
||||
APCApplicationAppearance.apply(theme)
|
||||
},
|
||||
@@ -1007,6 +1033,8 @@ final class AppStore: ObservableObject {
|
||||
self.overlayPlacementJournalStore = overlayPlacementJournalStore
|
||||
self.agentSessionRouteOpener = agentSessionRouteOpener
|
||||
self.runtimeHandoffIfNeeded = runtimeHandoffIfNeeded
|
||||
self.controlCenterWindowProvider = controlCenterWindowProvider
|
||||
self.controlCenterApplicationActivator = controlCenterApplicationActivator
|
||||
self.applicationAppearanceApplier = applicationAppearanceApplier
|
||||
self.applicationLanguageApplier = applicationLanguageApplier
|
||||
self.overlayPresenter = overlayPresenter
|
||||
@@ -1511,44 +1539,11 @@ final class AppStore: ObservableObject {
|
||||
}
|
||||
|
||||
func setMainWindowPresenter(_ presenter: @escaping () -> Void) {
|
||||
mainWindowPresenter = presenter
|
||||
guard pendingMainWindowPresentation else { return }
|
||||
let checksRuntimeHandoff = pendingMainWindowPresentationChecksRuntimeHandoff
|
||||
pendingMainWindowPresentation = false
|
||||
pendingMainWindowPresentationChecksRuntimeHandoff = true
|
||||
guard !checksRuntimeHandoff || !runtimeHandoffIfNeeded() else {
|
||||
pendingControlCenterFronting = false
|
||||
return
|
||||
}
|
||||
presenter()
|
||||
NSApp?.activate(ignoringOtherApps: true)
|
||||
controlCenterPresentationCoordinator.installPresenter(presenter)
|
||||
}
|
||||
|
||||
func registerControlCenterWindow(_ window: NSWindow) {
|
||||
guard controlCenterWindow !== window else { return }
|
||||
controlCenterCloseObserver?.cancel()
|
||||
window.identifier = Self.controlCenterWindowIdentifier
|
||||
controlCenterWindow = window
|
||||
controlCenterIsOpen = true
|
||||
overlayController.controlCenterDidOpen(window: window)
|
||||
controlCenterCloseObserver = NotificationCenter.default.publisher(
|
||||
for: NSWindow.willCloseNotification,
|
||||
object: window
|
||||
)
|
||||
.sink { [weak self, weak window] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, self.controlCenterWindow === window else {
|
||||
return
|
||||
}
|
||||
self.controlCenterIsOpen = false
|
||||
self.overlayController.controlCenterDidClose()
|
||||
}
|
||||
}
|
||||
if pendingControlCenterFronting {
|
||||
pendingControlCenterFronting = false
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp?.activate(ignoringOtherApps: true)
|
||||
}
|
||||
controlCenterPresentationCoordinator.register(window)
|
||||
}
|
||||
|
||||
func presentManualAppInstallation(_ request: AppManualInstallationRequest) {
|
||||
@@ -1636,28 +1631,20 @@ final class AppStore: ObservableObject {
|
||||
}
|
||||
|
||||
private func presentMainWindow(checkRuntimeHandoff: Bool) {
|
||||
guard !checkRuntimeHandoff || !runtimeHandoffIfNeeded() else { return }
|
||||
pendingControlCenterFronting = true
|
||||
if frontExistingMainWindow() {
|
||||
pendingControlCenterFronting = false
|
||||
pendingMainWindowPresentation = false
|
||||
pendingMainWindowPresentationChecksRuntimeHandoff = true
|
||||
NSApp?.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
if let mainWindowPresenter {
|
||||
pendingMainWindowPresentation = false
|
||||
pendingMainWindowPresentationChecksRuntimeHandoff = true
|
||||
mainWindowPresenter()
|
||||
} else {
|
||||
// A secondary launch or Dock reopen can arrive before either scene
|
||||
// has installed its openWindow presenter. Replay exactly once when
|
||||
// the first presenter becomes available.
|
||||
pendingMainWindowPresentation = true
|
||||
pendingMainWindowPresentationChecksRuntimeHandoff = checkRuntimeHandoff
|
||||
}
|
||||
NSApp?.activate(ignoringOtherApps: true)
|
||||
controlCenterPresentationCoordinator.requestPresentation(
|
||||
checkRuntimeHandoff: checkRuntimeHandoff
|
||||
)
|
||||
diagnostics.log(
|
||||
.debug,
|
||||
category: "lifecycle",
|
||||
event: "control_center_presentation_requested",
|
||||
metadata: [
|
||||
"phase": .string(
|
||||
controlCenterPresentationCoordinator.phase.diagnosticValue
|
||||
),
|
||||
"runtime_handoff_checked": .bool(checkRuntimeHandoff),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func presentAgentSession(
|
||||
@@ -1898,31 +1885,11 @@ final class AppStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func frontExistingMainWindow() -> Bool {
|
||||
guard let application = NSApp else {
|
||||
return false
|
||||
}
|
||||
let window = if let controlCenterWindow,
|
||||
Self.isMainWindowCandidate(controlCenterWindow) {
|
||||
controlCenterWindow
|
||||
} else {
|
||||
application.windows.first(where: Self.isMainWindowCandidate)
|
||||
}
|
||||
guard let window else { return false }
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
controlCenterIsOpen = true
|
||||
overlayController.controlCenterDidOpen(window: window)
|
||||
return true
|
||||
}
|
||||
|
||||
static func isMainWindowCandidate(_ window: NSWindow) -> Bool {
|
||||
window.identifier == controlCenterWindowIdentifier
|
||||
&& !(window is NSPanel)
|
||||
&& window.level == .normal
|
||||
&& window.styleMask.contains(.titled)
|
||||
ControlCenterPresentationCoordinator.isCandidate(
|
||||
window,
|
||||
identifier: controlCenterWindowIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
private enum RuntimeBootstrapStartMode {
|
||||
@@ -1969,9 +1936,9 @@ final class AppStore: ObservableObject {
|
||||
appUpdateConvergenceState = .updating
|
||||
}
|
||||
setServiceChecking()
|
||||
// Bound the invisible window gate from bootstrap start. Runtime
|
||||
// replacement and rollback can take much longer than appearance
|
||||
// hydration, and must not make the App look dead while it is checking.
|
||||
// Bound appearance hydration from bootstrap start. Runtime replacement
|
||||
// and rollback can take much longer, so the already-visible system
|
||||
// appearance must remain the explicit fallback while they run.
|
||||
scheduleInitialAppearanceFallback()
|
||||
let startResult = switch startMode {
|
||||
case .ensureRunning:
|
||||
@@ -2105,9 +2072,9 @@ final class AppStore: ObservableObject {
|
||||
// focused behavior request is suspended. Once that snapshot is
|
||||
// authoritative, never publish this older focused result.
|
||||
guard initialAppearanceReadiness != .authoritative else { return }
|
||||
// The publication order is deliberate: every window gate observing
|
||||
// readiness must see behavior, revision, and AppKit appearance as
|
||||
// one fully prepared initial presentation state.
|
||||
// The publication order is deliberate: every window appearance
|
||||
// observer sees behavior, revision, and AppKit appearance as one
|
||||
// fully prepared initial presentation state.
|
||||
authoritativeBehavior = versioned.behavior
|
||||
behavior = versioned.behavior
|
||||
behaviorRevision = versioned.revision
|
||||
@@ -3017,33 +2984,8 @@ final class AppStore: ObservableObject {
|
||||
hasLoadedStateSnapshot,
|
||||
!hasPresentedOverlay
|
||||
else { return }
|
||||
let keyControlCenterWindow: NSWindow? = if controlCenterIsOpen,
|
||||
controlCenterWindow?.isKeyWindow == true {
|
||||
controlCenterWindow
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
hasPresentedOverlay = true
|
||||
overlayPresenter(overlayController, self)
|
||||
guard let keyControlCenterWindow else { return }
|
||||
|
||||
// The resident panels can become key for explicit keyboard navigation,
|
||||
// but their first ordering must not take the cold-launch focus away
|
||||
// from an already-key Control Center. Defer until AppKit has completed
|
||||
// the overlay ordering turn, and never reactivate an App the user has
|
||||
// already left.
|
||||
DispatchQueue.main.async { [weak self, weak keyControlCenterWindow] in
|
||||
guard let self, let keyControlCenterWindow,
|
||||
self.controlCenterWindow === keyControlCenterWindow,
|
||||
InitialOverlayFocusPolicy.shouldRestoreControlCenter(
|
||||
controlCenterWasKeyBeforePresentation: true,
|
||||
appIsActive: NSApp?.isActive == true,
|
||||
controlCenterIsOpen: self.controlCenterIsOpen,
|
||||
windowIsVisible: keyControlCenterWindow.isVisible
|
||||
)
|
||||
else { return }
|
||||
keyControlCenterWindow.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcileActiveGeneration(_ snapshot: ActiveGenerationSnapshot) {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import AppKit
|
||||
|
||||
enum ControlCenterColdLaunchTiming {
|
||||
static let maximumValidationJitterMilliseconds = 1_500
|
||||
|
||||
static func validationJitterMilliseconds(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Int {
|
||||
guard environment["APC_VALIDATE_HOST_UI"] == "1",
|
||||
let rawValue = environment["APC_CONTROL_CENTER_LAUNCH_JITTER_MS"],
|
||||
let value = Int(rawValue),
|
||||
(0...maximumValidationJitterMilliseconds).contains(value)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func schedule(_ action: @escaping @MainActor () -> Void) {
|
||||
let delay = validationJitterMilliseconds()
|
||||
if delay == 0 {
|
||||
DispatchQueue.main.async(execute: action)
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + .milliseconds(delay),
|
||||
execute: action
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single presentation owner for the product's Control Center window.
|
||||
///
|
||||
/// SwiftUI may install its `openWindow` presenter and attach the concrete
|
||||
/// `NSWindow` in either order. Every launch, reopen, menu, overlay, and update
|
||||
/// request is reduced to one desired presentation here, then fulfilled only
|
||||
/// when the exact singleton window can be made visible and key.
|
||||
@MainActor
|
||||
final class ControlCenterPresentationCoordinator: NSObject {
|
||||
enum Phase: Equatable {
|
||||
case closed
|
||||
case waitingForPresenter
|
||||
case waitingForWindow
|
||||
case presented
|
||||
|
||||
var diagnosticValue: String {
|
||||
switch self {
|
||||
case .closed: "closed"
|
||||
case .waitingForPresenter: "waiting_for_presenter"
|
||||
case .waitingForWindow: "waiting_for_window"
|
||||
case .presented: "presented"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias WindowProvider = @MainActor () -> [NSWindow]
|
||||
typealias ApplicationActivator = @MainActor () -> Void
|
||||
typealias RuntimeHandoffCheck = @MainActor () -> Bool
|
||||
typealias WindowHandler = @MainActor (NSWindow) -> Void
|
||||
typealias CloseHandler = @MainActor () -> Void
|
||||
|
||||
private let identifier: NSUserInterfaceItemIdentifier
|
||||
private let windowProvider: WindowProvider
|
||||
private let activateApplication: ApplicationActivator
|
||||
private let runtimeHandoffIfNeeded: RuntimeHandoffCheck
|
||||
private let onWindowOpened: WindowHandler
|
||||
private let onWindowClosed: CloseHandler
|
||||
|
||||
private var presenter: (() -> Void)?
|
||||
private weak var window: NSWindow?
|
||||
private weak var observedWindow: NSWindow?
|
||||
private var wantsPresentation = false
|
||||
private var presenterInvokedForOutstandingRequest = false
|
||||
private var runtimeHandoffCheckRequired = true
|
||||
|
||||
private(set) var phase = Phase.closed
|
||||
private(set) var isOpen = false
|
||||
|
||||
var registeredWindow: NSWindow? {
|
||||
guard let window, Self.isCandidate(window, identifier: identifier) else {
|
||||
return nil
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
init(
|
||||
identifier: NSUserInterfaceItemIdentifier,
|
||||
windowProvider: @escaping WindowProvider,
|
||||
activateApplication: @escaping ApplicationActivator,
|
||||
runtimeHandoffIfNeeded: @escaping RuntimeHandoffCheck,
|
||||
onWindowOpened: @escaping WindowHandler,
|
||||
onWindowClosed: @escaping CloseHandler
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.windowProvider = windowProvider
|
||||
self.activateApplication = activateApplication
|
||||
self.runtimeHandoffIfNeeded = runtimeHandoffIfNeeded
|
||||
self.onWindowOpened = onWindowOpened
|
||||
self.onWindowClosed = onWindowClosed
|
||||
super.init()
|
||||
}
|
||||
|
||||
func installPresenter(_ presenter: @escaping () -> Void) {
|
||||
self.presenter = presenter
|
||||
fulfillOutstandingRequestIfPossible()
|
||||
}
|
||||
|
||||
func register(_ window: NSWindow) {
|
||||
window.identifier = identifier
|
||||
guard Self.isCandidate(window, identifier: identifier) else { return }
|
||||
|
||||
let publishesOpenTransition = self.window !== window || !isOpen
|
||||
if self.window !== window {
|
||||
if let observedWindow {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.willCloseNotification,
|
||||
object: observedWindow
|
||||
)
|
||||
}
|
||||
self.window = window
|
||||
observedWindow = window
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(controlCenterWindowWillClose(_:)),
|
||||
name: NSWindow.willCloseNotification,
|
||||
object: window
|
||||
)
|
||||
}
|
||||
|
||||
isOpen = true
|
||||
phase = .presented
|
||||
if publishesOpenTransition {
|
||||
onWindowOpened(window)
|
||||
}
|
||||
|
||||
if wantsPresentation {
|
||||
front(window)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func controlCenterWindowWillClose(_ notification: Notification) {
|
||||
guard let closingWindow = notification.object as? NSWindow,
|
||||
window === closingWindow
|
||||
else {
|
||||
return
|
||||
}
|
||||
window = nil
|
||||
observedWindow = nil
|
||||
wantsPresentation = false
|
||||
presenterInvokedForOutstandingRequest = false
|
||||
runtimeHandoffCheckRequired = true
|
||||
isOpen = false
|
||||
phase = .closed
|
||||
onWindowClosed()
|
||||
}
|
||||
|
||||
func requestPresentation(checkRuntimeHandoff: Bool) {
|
||||
if wantsPresentation {
|
||||
// A forced recovery or installation guide must not be weakened by
|
||||
// an earlier ordinary request that was still waiting on SwiftUI.
|
||||
runtimeHandoffCheckRequired =
|
||||
runtimeHandoffCheckRequired && checkRuntimeHandoff
|
||||
} else {
|
||||
runtimeHandoffCheckRequired = checkRuntimeHandoff
|
||||
}
|
||||
wantsPresentation = true
|
||||
fulfillOutstandingRequestIfPossible()
|
||||
}
|
||||
|
||||
static func isCandidate(
|
||||
_ window: NSWindow,
|
||||
identifier: NSUserInterfaceItemIdentifier
|
||||
) -> Bool {
|
||||
window.identifier == identifier
|
||||
&& !(window is NSPanel)
|
||||
&& window.level == .normal
|
||||
&& window.styleMask.contains(.titled)
|
||||
}
|
||||
|
||||
private func fulfillOutstandingRequestIfPossible() {
|
||||
guard wantsPresentation else { return }
|
||||
|
||||
if let window = resolveWindow() {
|
||||
if wantsPresentation {
|
||||
front(window)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let presenter else {
|
||||
phase = .waitingForPresenter
|
||||
return
|
||||
}
|
||||
guard !presenterInvokedForOutstandingRequest else {
|
||||
phase = .waitingForWindow
|
||||
return
|
||||
}
|
||||
guard authorizePresentationIfNeeded() else { return }
|
||||
|
||||
presenterInvokedForOutstandingRequest = true
|
||||
phase = .waitingForWindow
|
||||
presenter()
|
||||
|
||||
// `openWindow(id:)` normally attaches the AppKit window later, but a
|
||||
// test presenter or future scene implementation may do so inline.
|
||||
if let window = resolveWindow(), wantsPresentation {
|
||||
front(window)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveWindow() -> NSWindow? {
|
||||
if let registeredWindow {
|
||||
return registeredWindow
|
||||
}
|
||||
guard let candidate = windowProvider().first(where: {
|
||||
Self.isCandidate($0, identifier: identifier)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
register(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func authorizePresentationIfNeeded() -> Bool {
|
||||
guard runtimeHandoffCheckRequired else { return true }
|
||||
guard !runtimeHandoffIfNeeded() else {
|
||||
wantsPresentation = false
|
||||
presenterInvokedForOutstandingRequest = false
|
||||
runtimeHandoffCheckRequired = true
|
||||
phase = isOpen ? .presented : .closed
|
||||
return false
|
||||
}
|
||||
runtimeHandoffCheckRequired = false
|
||||
return true
|
||||
}
|
||||
|
||||
private func front(_ window: NSWindow) {
|
||||
guard authorizePresentationIfNeeded() else { return }
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
|
||||
// Presentation invariants belong here. No appearance hydration or
|
||||
// overlay update may leave the Control Center transparent, unable to
|
||||
// receive input, behind another app, or represented by another window.
|
||||
window.alphaValue = 1
|
||||
window.ignoresMouseEvents = false
|
||||
activateApplication()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
self.window = window
|
||||
wantsPresentation = false
|
||||
presenterInvokedForOutstandingRequest = false
|
||||
runtimeHandoffCheckRequired = true
|
||||
isOpen = true
|
||||
phase = .presented
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
import AgentPetCompanionCore
|
||||
import SwiftUI
|
||||
|
||||
enum InitialAppearanceReadiness: Equatable {
|
||||
case pending
|
||||
case authoritative
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum InitialAppearanceWindowPolicy {
|
||||
static func appearanceName(
|
||||
for readiness: InitialAppearanceReadiness,
|
||||
theme: AppearanceTheme
|
||||
) -> NSAppearance.Name? {
|
||||
switch readiness {
|
||||
case .pending, .unavailable:
|
||||
nil
|
||||
case .authoritative:
|
||||
APCApplicationAppearance.appearanceName(for: theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the persisted appearance to AppKit window chrome without ever
|
||||
/// gating the window's visibility or input. While PetCore is still hydrating,
|
||||
/// the window is immediately usable with the system appearance; an
|
||||
/// authoritative setting is an ordinary idempotent appearance update.
|
||||
struct InitialAppearanceWindowView: NSViewRepresentable {
|
||||
let readiness: InitialAppearanceReadiness
|
||||
let theme: AppearanceTheme
|
||||
|
||||
func makeNSView(context: Context) -> InitialAppearanceWindowHostView {
|
||||
InitialAppearanceWindowHostView(readiness: readiness, theme: theme)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: InitialAppearanceWindowHostView, context: Context) {
|
||||
nsView.update(readiness: readiness, theme: theme)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InitialAppearanceWindowHostView: NSView {
|
||||
private(set) var readiness: InitialAppearanceReadiness
|
||||
private(set) var theme: AppearanceTheme
|
||||
private weak var configuredWindow: NSWindow?
|
||||
|
||||
init(readiness: InitialAppearanceReadiness, theme: AppearanceTheme) {
|
||||
self.readiness = readiness
|
||||
self.theme = theme
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
func update(readiness: InitialAppearanceReadiness, theme: AppearanceTheme) {
|
||||
self.readiness = readiness
|
||||
self.theme = theme
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
private func applyAppearance() {
|
||||
guard let window else { return }
|
||||
let appearanceName = InitialAppearanceWindowPolicy.appearanceName(
|
||||
for: readiness,
|
||||
theme: theme
|
||||
)
|
||||
let nextAppearance = appearanceName.flatMap(NSAppearance.init(named:))
|
||||
guard configuredWindow !== window
|
||||
|| window.appearance?.name != nextAppearance?.name
|
||||
else { return }
|
||||
|
||||
configuredWindow = window
|
||||
window.appearance = nextAppearance
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import AppKit
|
||||
import AgentPetCompanionCore
|
||||
import SwiftUI
|
||||
|
||||
enum InitialAppearanceReadiness: Equatable {
|
||||
case pending
|
||||
case authoritative
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum InitialAppearanceWindowGate {
|
||||
static func shouldRevealWindow(for readiness: InitialAppearanceReadiness) -> Bool {
|
||||
readiness != .pending
|
||||
}
|
||||
|
||||
static func action(
|
||||
for readiness: InitialAppearanceReadiness,
|
||||
theme: AppearanceTheme,
|
||||
hasRevealed: Bool
|
||||
) -> InitialAppearanceWindowGateAction {
|
||||
switch readiness {
|
||||
case .pending:
|
||||
hasRevealed ? .noChange : .conceal
|
||||
case .authoritative:
|
||||
.reveal(appearanceName: APCApplicationAppearance.appearanceName(for: theme))
|
||||
case .unavailable:
|
||||
.reveal(appearanceName: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum InitialAppearanceWindowGateAction: Equatable {
|
||||
case conceal
|
||||
case reveal(appearanceName: NSAppearance.Name?)
|
||||
case noChange
|
||||
}
|
||||
|
||||
/// Hides AppKit chrome as well as SwiftUI content until the persisted theme is
|
||||
/// known. A SwiftUI opacity modifier cannot cover a title-bar flash, so the
|
||||
/// gate controls the owning NSWindow without ordering it on screen or taking
|
||||
/// input focus.
|
||||
struct InitialAppearanceWindowGateView: NSViewRepresentable {
|
||||
let readiness: InitialAppearanceReadiness
|
||||
let theme: AppearanceTheme
|
||||
|
||||
func makeNSView(context: Context) -> InitialAppearanceWindowGateHostView {
|
||||
InitialAppearanceWindowGateHostView(readiness: readiness, theme: theme)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: InitialAppearanceWindowGateHostView, context: Context) {
|
||||
nsView.update(readiness: readiness, theme: theme)
|
||||
}
|
||||
|
||||
static func dismantleNSView(
|
||||
_ nsView: InitialAppearanceWindowGateHostView,
|
||||
coordinator: Void
|
||||
) {
|
||||
nsView.detachFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum InitialAppearanceWindowGateCoordinatorStore {
|
||||
static let coordinators =
|
||||
NSMapTable<NSWindow, InitialAppearanceWindowGateWindowCoordinator>
|
||||
.weakToStrongObjects()
|
||||
|
||||
static func coordinator(
|
||||
for window: NSWindow
|
||||
) -> InitialAppearanceWindowGateWindowCoordinator {
|
||||
if let coordinator = coordinators.object(forKey: window) {
|
||||
return coordinator
|
||||
}
|
||||
let coordinator = InitialAppearanceWindowGateWindowCoordinator(window: window)
|
||||
coordinators.setObject(coordinator, forKey: window)
|
||||
return coordinator
|
||||
}
|
||||
}
|
||||
|
||||
/// One coordinator owns the shared NSWindow state even when SwiftUI replaces
|
||||
/// or temporarily duplicates its representable host views. The window must
|
||||
/// never depend on a transient host surviving until the deferred reveal turn.
|
||||
@MainActor
|
||||
private final class InitialAppearanceWindowGateWindowCoordinator {
|
||||
private weak var window: NSWindow?
|
||||
private var owners: Set<ObjectIdentifier> = []
|
||||
private var resolvedReadiness = InitialAppearanceReadiness.pending
|
||||
private var theme = AppearanceTheme.system
|
||||
private var originalAlphaValue: CGFloat?
|
||||
private var originalIgnoresMouseEvents: Bool?
|
||||
private var isConcealed = false
|
||||
private var revealScheduled = false
|
||||
private(set) var hasRevealed = false
|
||||
|
||||
init(window: NSWindow) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func attach(
|
||||
owner: InitialAppearanceWindowGateHostView,
|
||||
readiness: InitialAppearanceReadiness,
|
||||
theme: AppearanceTheme
|
||||
) {
|
||||
owners.insert(ObjectIdentifier(owner))
|
||||
update(readiness: readiness, theme: theme)
|
||||
}
|
||||
|
||||
func update(readiness: InitialAppearanceReadiness, theme: AppearanceTheme) {
|
||||
self.theme = theme
|
||||
if readiness != .pending {
|
||||
resolvedReadiness = readiness
|
||||
}
|
||||
applyGate()
|
||||
}
|
||||
|
||||
func detach(owner: InitialAppearanceWindowGateHostView) {
|
||||
owners.remove(ObjectIdentifier(owner))
|
||||
guard owners.isEmpty, isConcealed else { return }
|
||||
|
||||
// A representable can disappear between updateNSView and the next
|
||||
// main-queue turn. Fail open with the system appearance instead of
|
||||
// leaving the whole App permanently transparent and non-interactive.
|
||||
if resolvedReadiness == .pending {
|
||||
resolvedReadiness = .unavailable
|
||||
}
|
||||
scheduleReveal()
|
||||
}
|
||||
|
||||
private func applyGate() {
|
||||
guard let window else { return }
|
||||
switch InitialAppearanceWindowGate.action(
|
||||
for: resolvedReadiness,
|
||||
theme: theme,
|
||||
hasRevealed: hasRevealed
|
||||
) {
|
||||
case .conceal:
|
||||
conceal(window)
|
||||
case .noChange:
|
||||
return
|
||||
case let .reveal(appearanceName):
|
||||
window.appearance = appearanceName.flatMap(NSAppearance.init(named:))
|
||||
scheduleReveal()
|
||||
}
|
||||
}
|
||||
|
||||
private func conceal(_ window: NSWindow) {
|
||||
guard !isConcealed, !hasRevealed else { return }
|
||||
|
||||
// A second representable may attach after the first already concealed
|
||||
// this window. Window-scoped ownership prevents it from recording zero
|
||||
// alpha or ignored mouse events as the state that should be restored.
|
||||
originalAlphaValue = window.alphaValue > 0 ? window.alphaValue : 1
|
||||
originalIgnoresMouseEvents = window.alphaValue > 0
|
||||
? window.ignoresMouseEvents
|
||||
: false
|
||||
window.alphaValue = 0
|
||||
window.ignoresMouseEvents = true
|
||||
isConcealed = true
|
||||
}
|
||||
|
||||
private func scheduleReveal() {
|
||||
guard let window else { return }
|
||||
guard isConcealed else {
|
||||
hasRevealed = true
|
||||
return
|
||||
}
|
||||
guard !revealScheduled else { return }
|
||||
revealScheduled = true
|
||||
|
||||
// `updateNSView` runs while SwiftUI may still be rendering its
|
||||
// NSHostingView. Defer the AppKit mutation, but keep its ownership in
|
||||
// this window-retained coordinator rather than the transient host.
|
||||
DispatchQueue.main.async { [self, weak window] in
|
||||
revealScheduled = false
|
||||
guard let window, self.window === window else { return }
|
||||
guard resolvedReadiness != .pending || owners.isEmpty else { return }
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0
|
||||
context.allowsImplicitAnimation = false
|
||||
window.alphaValue = originalAlphaValue ?? 1
|
||||
}
|
||||
window.ignoresMouseEvents = originalIgnoresMouseEvents ?? false
|
||||
originalAlphaValue = nil
|
||||
originalIgnoresMouseEvents = nil
|
||||
isConcealed = false
|
||||
hasRevealed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InitialAppearanceWindowGateHostView: NSView {
|
||||
private(set) var readiness: InitialAppearanceReadiness
|
||||
private(set) var theme: AppearanceTheme
|
||||
|
||||
var hasRevealed: Bool {
|
||||
windowCoordinator?.hasRevealed ?? false
|
||||
}
|
||||
|
||||
private weak var controlledWindow: NSWindow?
|
||||
private var windowCoordinator: InitialAppearanceWindowGateWindowCoordinator?
|
||||
|
||||
init(readiness: InitialAppearanceReadiness, theme: AppearanceTheme) {
|
||||
self.readiness = readiness
|
||||
self.theme = theme
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
attachToWindowIfNeeded()
|
||||
}
|
||||
|
||||
func update(readiness: InitialAppearanceReadiness, theme: AppearanceTheme) {
|
||||
self.readiness = readiness
|
||||
self.theme = theme
|
||||
attachToWindowIfNeeded()
|
||||
windowCoordinator?.update(readiness: readiness, theme: theme)
|
||||
}
|
||||
|
||||
func detachFromWindow() {
|
||||
if let windowCoordinator {
|
||||
windowCoordinator.detach(owner: self)
|
||||
}
|
||||
windowCoordinator = nil
|
||||
controlledWindow = nil
|
||||
}
|
||||
|
||||
private func attachToWindowIfNeeded() {
|
||||
guard controlledWindow !== window else { return }
|
||||
detachFromWindow()
|
||||
guard let window else { return }
|
||||
|
||||
let coordinator = InitialAppearanceWindowGateCoordinatorStore.coordinator(
|
||||
for: window
|
||||
)
|
||||
controlledWindow = window
|
||||
windowCoordinator = coordinator
|
||||
coordinator.attach(owner: self, readiness: readiness, theme: theme)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ struct OverlayDesktopVisibilityPolicy {
|
||||
|
||||
func shouldHide(
|
||||
rendererValidationActive: Bool,
|
||||
controlCenterWindowIsConcealed: Bool,
|
||||
frontmostApplicationIsFinder: Bool,
|
||||
finderHasRegularWindow: Bool,
|
||||
hasVisibleRegularApplicationWindow: Bool
|
||||
@@ -30,10 +29,6 @@ struct OverlayDesktopVisibilityPolicy {
|
||||
guard controlCenterIsOpen, !rendererValidationActive else {
|
||||
return false
|
||||
}
|
||||
// The initial appearance gate may briefly conceal the real control
|
||||
// center while applying persisted presentation. That transition is
|
||||
// not the system Show Desktop gesture and must never hide the pet.
|
||||
guard !controlCenterWindowIsConcealed else { return false }
|
||||
if frontmostApplicationIsFinder {
|
||||
return !finderHasRegularWindow
|
||||
}
|
||||
@@ -115,7 +110,6 @@ final class PetOverlayController {
|
||||
private var desktopVisibilityTimer: Timer?
|
||||
private var hiddenForDesktop = false
|
||||
private var desktopVisibilityPolicy = OverlayDesktopVisibilityPolicy()
|
||||
private weak var controlCenterWindow: NSWindow?
|
||||
private let controlPresentation = OverlayControlPresentationState()
|
||||
private let interactionPresentation = OverlayInteractionPresentationState()
|
||||
private var controlPanelFadeOutTask: Task<Void, Never>?
|
||||
@@ -219,7 +213,7 @@ final class PetOverlayController {
|
||||
bubblePanel.ignoresMouseEvents = false
|
||||
bubblePanel.acceptsMouseMovedEvents = true
|
||||
bubblePanel.isMovableByWindowBackground = false
|
||||
bubblePanel.becomesKeyOnlyIfNeeded = false
|
||||
bubblePanel.becomesKeyOnlyIfNeeded = true
|
||||
bubblePanel.collectionBehavior = Self.companionCollectionBehavior
|
||||
bubblePanel.canHide = true
|
||||
bubblePanel.hidesOnDeactivate = false
|
||||
@@ -411,13 +405,11 @@ final class PetOverlayController {
|
||||
}
|
||||
}
|
||||
|
||||
func controlCenterDidOpen(window: NSWindow) {
|
||||
controlCenterWindow = window
|
||||
func controlCenterDidOpen() {
|
||||
desktopVisibilityPolicy.controlCenterDidOpen()
|
||||
}
|
||||
|
||||
func controlCenterDidClose() {
|
||||
controlCenterWindow = nil
|
||||
desktopVisibilityPolicy.controlCenterDidClose()
|
||||
hiddenForDesktop = false
|
||||
guard store?.behavior.enabled == true else { return }
|
||||
@@ -1000,10 +992,6 @@ final class PetOverlayController {
|
||||
}
|
||||
return desktopVisibilityPolicy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed:
|
||||
controlCenterWindow.map {
|
||||
$0.alphaValue <= 0 || $0.ignoresMouseEvents
|
||||
} ?? false,
|
||||
frontmostApplicationIsFinder: frontmostApplicationIsFinder,
|
||||
finderHasRegularWindow: finderHasRegularWindow,
|
||||
hasVisibleRegularApplicationWindow:
|
||||
@@ -1088,12 +1076,13 @@ final class BubbleOverlayPanel: NSPanel {
|
||||
private let pointerMonitor = OverlayPointerEventMonitor()
|
||||
private var clickMenuTarget: BubbleClickMenuTarget?
|
||||
private var pendingBubbleClick: OverlayBubblePressGesture?
|
||||
private(set) var keyboardNavigationLeaseIsActive = false
|
||||
|
||||
override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect {
|
||||
frameRect
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeKey: Bool { keyboardNavigationLeaseIsActive }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
override func performDrag(with event: NSEvent) {}
|
||||
@@ -1113,6 +1102,7 @@ final class BubbleOverlayPanel: NSPanel {
|
||||
func stopPointerTracking() {
|
||||
pointerMonitor.stop()
|
||||
pendingBubbleClick = nil
|
||||
endKeyboardNavigation()
|
||||
setIgnoresMouseEventsIfNeeded(false)
|
||||
}
|
||||
|
||||
@@ -1131,12 +1121,28 @@ final class BubbleOverlayPanel: NSPanel {
|
||||
}
|
||||
|
||||
func beginKeyboardNavigation() {
|
||||
beginKeyboardNavigationLease()
|
||||
setIgnoresMouseEventsIfNeeded(false)
|
||||
makeKeyAndOrderFront(nil)
|
||||
_ = makeFirstResponder(contentView)
|
||||
selectNextKeyView(nil)
|
||||
}
|
||||
|
||||
func beginKeyboardNavigationLease() {
|
||||
keyboardNavigationLeaseIsActive = true
|
||||
}
|
||||
|
||||
func endKeyboardNavigation() {
|
||||
guard keyboardNavigationLeaseIsActive else { return }
|
||||
if isKeyWindow {
|
||||
resignKey()
|
||||
} else {
|
||||
keyboardNavigationLeaseIsActive = false
|
||||
pendingBubbleClick = nil
|
||||
onKeyboardNavigationChanged?(false)
|
||||
}
|
||||
}
|
||||
|
||||
override func becomeKey() {
|
||||
super.becomeKey()
|
||||
setIgnoresMouseEventsIfNeeded(false)
|
||||
@@ -1144,9 +1150,13 @@ final class BubbleOverlayPanel: NSPanel {
|
||||
}
|
||||
|
||||
override func resignKey() {
|
||||
let hadKeyboardNavigationLease = keyboardNavigationLeaseIsActive
|
||||
super.resignKey()
|
||||
keyboardNavigationLeaseIsActive = false
|
||||
pendingBubbleClick = nil
|
||||
onKeyboardNavigationChanged?(false)
|
||||
if hadKeyboardNavigationLease {
|
||||
onKeyboardNavigationChanged?(false)
|
||||
}
|
||||
refreshPointerPassthrough()
|
||||
}
|
||||
|
||||
@@ -1457,7 +1467,7 @@ final class ControlOverlayPanel: NSPanel {
|
||||
frameRect
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
override func becomeKey() {
|
||||
@@ -1522,7 +1532,7 @@ private final class OverlayPanel: NSPanel {
|
||||
frameRect
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
override func performDrag(with event: NSEvent) {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import SwiftUI
|
||||
import Testing
|
||||
@testable import AgentPetCompanion
|
||||
|
||||
@Suite
|
||||
@Suite(.serialized)
|
||||
struct AppLifecycleContractTests {
|
||||
@MainActor
|
||||
@Test
|
||||
@@ -25,13 +25,12 @@ struct AppLifecycleContractTests {
|
||||
onReady: { _ in }
|
||||
)
|
||||
)
|
||||
let window = NSPanel(
|
||||
let window = NSWindow(
|
||||
contentRect: .zero,
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.close() }
|
||||
|
||||
store.registerControlCenterWindow(window)
|
||||
#expect(store.controlCenterIsOpen)
|
||||
@@ -40,7 +39,6 @@ struct AppLifecycleContractTests {
|
||||
name: NSWindow.willCloseNotification,
|
||||
object: window
|
||||
)
|
||||
await Task.yield()
|
||||
|
||||
#expect(!store.controlCenterIsOpen)
|
||||
}
|
||||
@@ -52,7 +50,6 @@ struct AppLifecycleContractTests {
|
||||
|
||||
#expect(policy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed: false,
|
||||
frontmostApplicationIsFinder: true,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: false
|
||||
@@ -62,14 +59,12 @@ struct AppLifecycleContractTests {
|
||||
|
||||
#expect(!policy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed: false,
|
||||
frontmostApplicationIsFinder: true,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: false
|
||||
))
|
||||
#expect(!policy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed: false,
|
||||
frontmostApplicationIsFinder: false,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: false
|
||||
@@ -84,32 +79,22 @@ struct AppLifecycleContractTests {
|
||||
|
||||
#expect(policy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed: false,
|
||||
frontmostApplicationIsFinder: false,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: false
|
||||
))
|
||||
#expect(!policy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed: false,
|
||||
frontmostApplicationIsFinder: false,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: true
|
||||
))
|
||||
#expect(!policy.shouldHide(
|
||||
rendererValidationActive: true,
|
||||
controlCenterWindowIsConcealed: false,
|
||||
frontmostApplicationIsFinder: true,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: false
|
||||
))
|
||||
#expect(!policy.shouldHide(
|
||||
rendererValidationActive: false,
|
||||
controlCenterWindowIsConcealed: true,
|
||||
frontmostApplicationIsFinder: false,
|
||||
finderHasRegularWindow: false,
|
||||
hasVisibleRegularApplicationWindow: false
|
||||
))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -127,10 +112,12 @@ struct AppLifecycleContractTests {
|
||||
|
||||
store.presentMainWindow()
|
||||
store.presentMainWindow()
|
||||
#expect(store.controlCenterPresentationPhase == .waitingForPresenter)
|
||||
store.setMainWindowPresenter {
|
||||
presentations += 1
|
||||
}
|
||||
#expect(presentations == 1)
|
||||
#expect(store.controlCenterPresentationPhase == .waitingForWindow)
|
||||
|
||||
store.setMainWindowPresenter {
|
||||
presentations += 1
|
||||
@@ -138,6 +125,42 @@ struct AppLifecycleContractTests {
|
||||
#expect(presentations == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func presenterAndWindowRegistrationOrderConvergeOnOneKeyPresentation() {
|
||||
let store = AppStore(
|
||||
bootstrapHooks: AppStoreBootstrapHooks(
|
||||
ensureRunning: { .alreadyHealthy },
|
||||
recover: { .alreadyHealthy },
|
||||
refreshSnapshot: { _ in },
|
||||
onReady: { _ in }
|
||||
),
|
||||
runtimeHandoffIfNeeded: { false }
|
||||
)
|
||||
let window = ControlCenterFrontRecordingWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
var presentations = 0
|
||||
store.setMainWindowPresenter {
|
||||
presentations += 1
|
||||
}
|
||||
|
||||
store.presentMainWindow()
|
||||
store.presentMainWindow()
|
||||
|
||||
#expect(presentations == 1)
|
||||
#expect(store.controlCenterPresentationPhase == .waitingForWindow)
|
||||
|
||||
store.registerControlCenterWindow(window)
|
||||
|
||||
#expect(window.frontRequestCount == 1)
|
||||
#expect(store.controlCenterPresentationPhase == .presented)
|
||||
#expect(store.controlCenterIsOpen)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func coldLaunchPresentationFrontsTheWindowWhenRegistrationWinsTheRace() {
|
||||
@@ -160,12 +183,42 @@ struct AppLifecycleContractTests {
|
||||
store.presentMainWindow()
|
||||
#expect(window.frontRequestCount == 0)
|
||||
|
||||
window.alphaValue = 0
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
store.registerControlCenterWindow(window)
|
||||
|
||||
#expect(window.frontRequestCount == 1)
|
||||
#expect(window.alphaValue == 1)
|
||||
#expect(!window.ignoresMouseEvents)
|
||||
#expect(store.controlCenterIsOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
func coldLaunchJitterIsBoundedAndHostValidationOnly() {
|
||||
#expect(ControlCenterColdLaunchTiming.validationJitterMilliseconds(
|
||||
environment: [:]
|
||||
) == 0)
|
||||
#expect(ControlCenterColdLaunchTiming.validationJitterMilliseconds(
|
||||
environment: [
|
||||
"APC_VALIDATE_HOST_UI": "0",
|
||||
"APC_CONTROL_CENTER_LAUNCH_JITTER_MS": "750",
|
||||
]
|
||||
) == 0)
|
||||
#expect(ControlCenterColdLaunchTiming.validationJitterMilliseconds(
|
||||
environment: [
|
||||
"APC_VALIDATE_HOST_UI": "1",
|
||||
"APC_CONTROL_CENTER_LAUNCH_JITTER_MS": "750",
|
||||
]
|
||||
) == 750)
|
||||
#expect(ControlCenterColdLaunchTiming.validationJitterMilliseconds(
|
||||
environment: [
|
||||
"APC_VALIDATE_HOST_UI": "1",
|
||||
"APC_CONTROL_CENTER_LAUNCH_JITTER_MS": "1501",
|
||||
]
|
||||
) == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func aboutWindowFirstDoesNotInterceptAControlCenterReopen() {
|
||||
@@ -271,7 +324,9 @@ struct AppLifecycleContractTests {
|
||||
|
||||
#expect(store.appUpdateConvergenceState == .idle)
|
||||
#expect(store.manualAppInstallationRequest == request)
|
||||
#expect(presentations == 2)
|
||||
// Both forced recovery requests target the same outstanding singleton
|
||||
// scene presentation; the visible guide state updates independently.
|
||||
#expect(presentations == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -307,7 +362,7 @@ struct AppLifecycleContractTests {
|
||||
in: appSource
|
||||
))
|
||||
#expect(LifecycleSource.matches(
|
||||
#"applicationDidFinishLaunching[\s\S]*?DispatchQueue\.main\.async\s*\{\s*AppSingleInstanceCoordinator\.shared\.activatePrimaryInstance\(\)"#,
|
||||
#"applicationDidFinishLaunching[\s\S]*?ControlCenterColdLaunchTiming\.schedule\s*\{\s*AppSingleInstanceCoordinator\.shared\.activatePrimaryInstance\(\)"#,
|
||||
in: appSource
|
||||
))
|
||||
#expect(LifecycleSource.matches(
|
||||
@@ -337,10 +392,7 @@ struct AppLifecycleContractTests {
|
||||
))
|
||||
#expect(overlaySource.contains("onOpenMainWindow: { store.presentMainWindow() }"))
|
||||
#expect(appStoreSource.contains(
|
||||
"private func presentMainWindow(checkRuntimeHandoff: Bool)"
|
||||
))
|
||||
#expect(appStoreSource.contains(
|
||||
"guard !checkRuntimeHandoff || !runtimeHandoffIfNeeded() else { return }"
|
||||
"controlCenterPresentationCoordinator.requestPresentation("
|
||||
))
|
||||
#expect(appSource.components(separatedBy: ".sheet(").count - 1 == 1)
|
||||
#expect(appSource.contains(
|
||||
|
||||
@@ -21,26 +21,6 @@ struct ControlCenterShellTests {
|
||||
#expect(window.titlebarSeparatorStyle == .none)
|
||||
}
|
||||
|
||||
@Test
|
||||
func initialOverlayOnlyRestoresAnActiveVisibleKeyControlCenter() {
|
||||
let scenarios = [
|
||||
(true, true, true, true, true),
|
||||
(false, true, true, true, false),
|
||||
(true, false, true, true, false),
|
||||
(true, true, false, true, false),
|
||||
(true, true, true, false, false),
|
||||
]
|
||||
|
||||
for scenario in scenarios {
|
||||
#expect(InitialOverlayFocusPolicy.shouldRestoreControlCenter(
|
||||
controlCenterWasKeyBeforePresentation: scenario.0,
|
||||
appIsActive: scenario.1,
|
||||
controlCenterIsOpen: scenario.2,
|
||||
windowIsVisible: scenario.3
|
||||
) == scenario.4)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func navigationUsesTheFixedProductOrderAndTypedSelection() {
|
||||
let items = ControlCenterNavigationPresentation.items(
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ControlOverlayPanelTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func bubblePanelSupportsExplicitFullKeyboardNavigation() {
|
||||
func bubblePanelRequiresAnExplicitKeyboardNavigationLease() {
|
||||
let panel = BubbleOverlayPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
@@ -48,8 +48,18 @@ struct ControlOverlayPanelTests {
|
||||
defer: false
|
||||
)
|
||||
|
||||
#expect(panel.canBecomeKey)
|
||||
#expect(!panel.canBecomeKey)
|
||||
#expect(!panel.canBecomeMain)
|
||||
|
||||
panel.beginKeyboardNavigationLease()
|
||||
|
||||
#expect(panel.keyboardNavigationLeaseIsActive)
|
||||
#expect(panel.canBecomeKey)
|
||||
|
||||
panel.endKeyboardNavigation()
|
||||
|
||||
#expect(!panel.keyboardNavigationLeaseIsActive)
|
||||
#expect(!panel.canBecomeKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,10 +75,20 @@ struct ControlOverlayPanelTests {
|
||||
focusTransitions.append($0)
|
||||
}
|
||||
|
||||
panel.beginKeyboardNavigationLease()
|
||||
panel.becomeKey()
|
||||
panel.resignKey()
|
||||
|
||||
#expect(focusTransitions == [true, false])
|
||||
#expect(!panel.canBecomeKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ordinaryControlPanelCanNeverBecomeKey() {
|
||||
let panel = makePanel()
|
||||
|
||||
#expect(!panel.canBecomeKey)
|
||||
#expect(!panel.canBecomeMain)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -7,74 +7,28 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
struct InitialAppearanceBootstrapTests {
|
||||
@Test
|
||||
func windowGateOnlyHoldsWhileAuthoritativeAppearanceIsPending() {
|
||||
#expect(!InitialAppearanceWindowGate.shouldRevealWindow(for: .pending))
|
||||
#expect(InitialAppearanceWindowGate.shouldRevealWindow(for: .authoritative))
|
||||
#expect(InitialAppearanceWindowGate.shouldRevealWindow(for: .unavailable))
|
||||
#expect(InitialAppearanceWindowGate.action(
|
||||
func windowAppearanceUsesSystemFallbackUntilAuthoritativeThemeArrives() {
|
||||
#expect(InitialAppearanceWindowPolicy.appearanceName(
|
||||
for: .pending,
|
||||
theme: .system,
|
||||
hasRevealed: false
|
||||
) == .conceal)
|
||||
#expect(InitialAppearanceWindowGate.action(
|
||||
theme: .dark
|
||||
) == nil)
|
||||
#expect(InitialAppearanceWindowPolicy.appearanceName(
|
||||
for: .authoritative,
|
||||
theme: .dark,
|
||||
hasRevealed: false
|
||||
) == .reveal(appearanceName: .darkAqua))
|
||||
#expect(InitialAppearanceWindowGate.action(
|
||||
theme: .dark
|
||||
) == .darkAqua)
|
||||
#expect(InitialAppearanceWindowPolicy.appearanceName(
|
||||
for: .authoritative,
|
||||
theme: .light,
|
||||
hasRevealed: false
|
||||
) == .reveal(appearanceName: .aqua))
|
||||
#expect(InitialAppearanceWindowGate.action(
|
||||
theme: .light
|
||||
) == .aqua)
|
||||
#expect(InitialAppearanceWindowPolicy.appearanceName(
|
||||
for: .unavailable,
|
||||
theme: .dark,
|
||||
hasRevealed: false
|
||||
) == .reveal(appearanceName: nil))
|
||||
#expect(InitialAppearanceWindowGate.action(
|
||||
for: .pending,
|
||||
theme: .dark,
|
||||
hasRevealed: true
|
||||
) == .noChange)
|
||||
theme: .dark
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func windowGateRevealDefersPresentationWithoutForcingContentLayout() async {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||
styleMask: [.titled],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let contentView = InitialAppearanceLayoutProbe(frame: window.contentLayoutRect)
|
||||
let gate = InitialAppearanceWindowGateHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
window.contentView = contentView
|
||||
contentView.addSubview(gate)
|
||||
|
||||
#expect(window.alphaValue == 0)
|
||||
#expect(window.ignoresMouseEvents)
|
||||
contentView.reset()
|
||||
|
||||
gate.update(readiness: .authoritative, theme: .dark)
|
||||
|
||||
#expect(contentView.layoutPasses == 0)
|
||||
#expect(contentView.displayPasses == 0)
|
||||
#expect(window.alphaValue == 0)
|
||||
|
||||
await waitForNextMainQueueTurn()
|
||||
|
||||
#expect(window.alphaValue == 1)
|
||||
#expect(!window.ignoresMouseEvents)
|
||||
#expect(gate.hasRevealed)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func windowGateRevealSurvivesHostRemovalBeforeDeferredTurn() async {
|
||||
func pendingAppearanceNeverConcealsOrDisablesTheWindow() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||
styleMask: [.titled],
|
||||
@@ -82,28 +36,27 @@ struct InitialAppearanceBootstrapTests {
|
||||
defer: false
|
||||
)
|
||||
let contentView = NSView(frame: window.contentLayoutRect)
|
||||
var gate: InitialAppearanceWindowGateHostView? =
|
||||
InitialAppearanceWindowGateHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
let appearanceView = InitialAppearanceWindowHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
window.contentView = contentView
|
||||
contentView.addSubview(gate!)
|
||||
contentView.addSubview(appearanceView)
|
||||
|
||||
#expect(window.alphaValue == 0)
|
||||
gate?.update(readiness: .authoritative, theme: .dark)
|
||||
gate?.removeFromSuperview()
|
||||
gate = nil
|
||||
#expect(window.alphaValue == 1)
|
||||
#expect(!window.ignoresMouseEvents)
|
||||
#expect(window.appearance == nil)
|
||||
|
||||
await waitForNextMainQueueTurn()
|
||||
appearanceView.update(readiness: .authoritative, theme: .dark)
|
||||
|
||||
#expect(window.appearance?.name == .darkAqua)
|
||||
#expect(window.alphaValue == 1)
|
||||
#expect(!window.ignoresMouseEvents)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func windowGateReplacementCannotRecordConcealedStateAsOriginal() async {
|
||||
func appearanceHostReplacementCannotChangeVisibilityOrInputState() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||
styleMask: [.titled],
|
||||
@@ -111,63 +64,22 @@ struct InitialAppearanceBootstrapTests {
|
||||
defer: false
|
||||
)
|
||||
let contentView = NSView(frame: window.contentLayoutRect)
|
||||
let firstGate = InitialAppearanceWindowGateHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
let replacementGate = InitialAppearanceWindowGateHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
window.contentView = contentView
|
||||
contentView.addSubview(firstGate)
|
||||
contentView.addSubview(replacementGate)
|
||||
|
||||
#expect(window.alphaValue == 0)
|
||||
#expect(window.ignoresMouseEvents)
|
||||
replacementGate.update(readiness: .authoritative, theme: .light)
|
||||
|
||||
await waitForNextMainQueueTurn()
|
||||
let firstHost = InitialAppearanceWindowHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
let replacementHost = InitialAppearanceWindowHostView(
|
||||
readiness: .authoritative,
|
||||
theme: .light
|
||||
)
|
||||
contentView.addSubview(firstHost)
|
||||
firstHost.removeFromSuperview()
|
||||
contentView.addSubview(replacementHost)
|
||||
|
||||
#expect(window.alphaValue == 1)
|
||||
#expect(!window.ignoresMouseEvents)
|
||||
#expect(firstGate.hasRevealed)
|
||||
#expect(replacementGate.hasRevealed)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func pendingWindowGateFailsOpenWhenItsLastHostIsDismantled() async {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||
styleMask: [.titled],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let contentView = NSView(frame: window.contentLayoutRect)
|
||||
let gate = InitialAppearanceWindowGateHostView(
|
||||
readiness: .pending,
|
||||
theme: .system
|
||||
)
|
||||
window.contentView = contentView
|
||||
contentView.addSubview(gate)
|
||||
|
||||
#expect(window.alphaValue == 0)
|
||||
gate.detachFromWindow()
|
||||
|
||||
await waitForNextMainQueueTurn()
|
||||
|
||||
#expect(window.alphaValue == 1)
|
||||
#expect(!window.ignoresMouseEvents)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForNextMainQueueTurn() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
#expect(window.appearance?.name == .aqua)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -219,7 +131,7 @@ struct InitialAppearanceBootstrapTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func behaviorFetchFailureKeepsTheGateThroughSnapshotAttemptThenFallsBack() async {
|
||||
func behaviorFetchFailureKeepsAppearancePendingThroughSnapshotThenFallsBack() async {
|
||||
let probe = InitialAppearanceProbe()
|
||||
let store = AppStore(
|
||||
bootstrapHooks: AppStoreBootstrapHooks(
|
||||
@@ -245,7 +157,7 @@ struct InitialAppearanceBootstrapTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func behaviorFetchFailureReleasesTheWindowBeforeAStalledReadyPipelineCompletes() async {
|
||||
func behaviorFetchFailurePublishesFallbackBeforeAStalledReadyPipelineCompletes() async {
|
||||
let gate = InitialAppearanceReadyGate()
|
||||
let fallback = InitialAppearanceFallbackProbe()
|
||||
let store = AppStore(
|
||||
@@ -598,7 +510,7 @@ struct InitialAppearanceBootstrapTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func petCoreStartupFailureAlsoReleasesTheWindowGateToFallbackAppearance() async {
|
||||
func petCoreStartupFailureAlsoPublishesTheSystemAppearanceFallback() async {
|
||||
let probe = InitialAppearanceProbe()
|
||||
let store = AppStore(
|
||||
bootstrapHooks: AppStoreBootstrapHooks(
|
||||
@@ -919,27 +831,6 @@ private actor InitialAppearanceFallbackProbe {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class InitialAppearanceLayoutProbe: NSView {
|
||||
private(set) var layoutPasses = 0
|
||||
private(set) var displayPasses = 0
|
||||
|
||||
override func layoutSubtreeIfNeeded() {
|
||||
layoutPasses += 1
|
||||
super.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
override func displayIfNeeded() {
|
||||
displayPasses += 1
|
||||
super.displayIfNeeded()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
layoutPasses = 0
|
||||
displayPasses = 0
|
||||
}
|
||||
}
|
||||
|
||||
private actor InitialBootstrapRaceProbe {
|
||||
private var ensureAttempts = 0
|
||||
private var recoveryAttempts = 0
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": "apc.changelog-fragment.v1",
|
||||
"kind": "Fixed",
|
||||
"scope": "macos",
|
||||
"summary_en": "Control Center presentation now has one focus-safe window owner, nonblocking appearance hydration, and a repeated cold-launch release gate.",
|
||||
"summary_zh": "控制中心现由单一且焦点安全的窗口协调器展示,主题加载不再阻塞窗口,并新增重复冷启动发布门禁。"
|
||||
}
|
||||
@@ -1497,6 +1497,16 @@ fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::OnceLock;
|
||||
use time::{format_description::well_known::Rfc3339, Duration as TimeDuration, OffsetDateTime};
|
||||
|
||||
fn retained_event_timestamp(offset_seconds: i64) -> String {
|
||||
static BASE: OnceLock<OffsetDateTime> = OnceLock::new();
|
||||
(*BASE.get_or_init(|| OffsetDateTime::now_utc() - TimeDuration::minutes(1))
|
||||
+ TimeDuration::seconds(offset_seconds))
|
||||
.format(&Rfc3339)
|
||||
.expect("format retained event fixture timestamp")
|
||||
}
|
||||
|
||||
fn sqlite_contention_error(code: ErrorCode, extended_code: i32) -> PetCoreError {
|
||||
PetCoreError::Sqlite(rusqlite::Error::SqliteFailure(
|
||||
@@ -2553,7 +2563,7 @@ mod tests {
|
||||
"diagnostic": diagnostic,
|
||||
"affects_activity": affects_activity
|
||||
}),
|
||||
created_at: "2026-07-20T00:00:00Z".to_string(),
|
||||
created_at: retained_event_timestamp(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2630,7 +2640,7 @@ mod tests {
|
||||
"terminal_app": fixture.terminal_app,
|
||||
"session_open_url": fixture.session_open_url
|
||||
}),
|
||||
created_at: "2026-07-20T00:00:00Z".to_string(),
|
||||
created_at: retained_event_timestamp(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2712,7 +2722,7 @@ mod tests {
|
||||
Some("warp://session/0123456789abcdef0123456789abcdef"),
|
||||
),
|
||||
);
|
||||
current_cli_old.created_at = "2026-07-20T00:00:10Z".to_string();
|
||||
current_cli_old.created_at = retained_event_timestamp(10);
|
||||
let mut current_app_new = codex_runtime_marker(CodexRuntimeMarkerFixture::new(
|
||||
"current-app-new",
|
||||
"current-session",
|
||||
@@ -2720,7 +2730,7 @@ mod tests {
|
||||
"PostToolUse",
|
||||
"chatgpt_app",
|
||||
));
|
||||
current_app_new.created_at = "2026-07-20T00:00:01Z".to_string();
|
||||
current_app_new.created_at = retained_event_timestamp(1);
|
||||
for marker in [
|
||||
current_cli_old,
|
||||
current_app_new,
|
||||
@@ -2883,7 +2893,7 @@ mod tests {
|
||||
event.payload_json["outcome"] = json!("session_closed");
|
||||
event.payload_json["session_active"] = json!(false);
|
||||
event.payload_json["session_open"] = json!(false);
|
||||
event.created_at = "2026-07-20T00:01:00Z".to_string();
|
||||
event.created_at = retained_event_timestamp(60);
|
||||
event
|
||||
};
|
||||
let listed_closure = closure(listed_thread);
|
||||
@@ -2994,30 +3004,40 @@ mod tests {
|
||||
let database = Database::new(temp.path().join("events.sqlite"));
|
||||
database.init().unwrap();
|
||||
for event in [
|
||||
message_event("user-first", "user", "first prompt", "2026-07-20T00:00:00Z"),
|
||||
message_event(
|
||||
"user-first",
|
||||
"user",
|
||||
"first prompt",
|
||||
&retained_event_timestamp(0),
|
||||
),
|
||||
message_event(
|
||||
"assistant-first",
|
||||
"assistant",
|
||||
"first answer",
|
||||
"2026-07-20T00:00:01Z",
|
||||
&retained_event_timestamp(1),
|
||||
),
|
||||
message_event(
|
||||
"user-latest",
|
||||
"user",
|
||||
"next prompt",
|
||||
&retained_event_timestamp(2),
|
||||
),
|
||||
message_event("user-latest", "user", "next prompt", "2026-07-20T00:00:02Z"),
|
||||
message_event(
|
||||
"assistant-empty",
|
||||
"assistant",
|
||||
" ",
|
||||
"2026-07-20T00:00:03Z",
|
||||
&retained_event_timestamp(3),
|
||||
),
|
||||
message_event(
|
||||
"assistant-latest",
|
||||
"assistant",
|
||||
"latest answer",
|
||||
"2026-07-20T00:00:04Z",
|
||||
&retained_event_timestamp(4),
|
||||
),
|
||||
session_title_event(
|
||||
"title-latest",
|
||||
"Generated session title",
|
||||
"2026-07-20T00:00:05Z",
|
||||
&retained_event_timestamp(5),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
@@ -3072,7 +3092,7 @@ mod tests {
|
||||
"before",
|
||||
"user",
|
||||
"before",
|
||||
"2026-07-20T00:00:00Z",
|
||||
&retained_event_timestamp(0),
|
||||
))
|
||||
.unwrap();
|
||||
let old_revision = database.state_revision().unwrap();
|
||||
@@ -3095,7 +3115,7 @@ mod tests {
|
||||
enum_name(AgentEventType::Start),
|
||||
AgentEventType::Start.zh_label(),
|
||||
"{not-json",
|
||||
"2026-07-20T00:00:01Z",
|
||||
retained_event_timestamp(1),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3140,7 +3160,7 @@ mod tests {
|
||||
"before",
|
||||
"user",
|
||||
"before",
|
||||
"2026-07-20T00:00:00Z",
|
||||
&retained_event_timestamp(0),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
@@ -3157,7 +3177,7 @@ mod tests {
|
||||
"concurrent",
|
||||
"assistant",
|
||||
"concurrent answer",
|
||||
"2026-07-20T00:00:01Z",
|
||||
&retained_event_timestamp(1),
|
||||
))
|
||||
.unwrap();
|
||||
})
|
||||
|
||||
@@ -5,7 +5,16 @@ use petcore_types::{
|
||||
AgentVerification, BehaviorSettings, ConnectionCheckMode,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::sync::{Arc, Barrier, OnceLock};
|
||||
use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
|
||||
|
||||
fn fixture_timestamp(offset_seconds: i64) -> String {
|
||||
static BASE: OnceLock<OffsetDateTime> = OnceLock::new();
|
||||
(*BASE.get_or_init(|| OffsetDateTime::now_utc() - Duration::minutes(1))
|
||||
+ Duration::seconds(offset_seconds))
|
||||
.format(&Rfc3339)
|
||||
.expect("format retained event fixture timestamp")
|
||||
}
|
||||
|
||||
fn event(id: &str, kind: AgentEventType, source_event: &str, turn_id: &str) -> AgentEvent {
|
||||
AgentEvent {
|
||||
@@ -23,7 +32,7 @@ fn event(id: &str, kind: AgentEventType, source_event: &str, turn_id: &str) -> A
|
||||
"affects_activity": true,
|
||||
"session_active": !matches!(kind, AgentEventType::Done | AgentEventType::Failed)
|
||||
}),
|
||||
created_at: "2026-07-17T10:00:00Z".to_string(),
|
||||
created_at: fixture_timestamp(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +56,7 @@ fn metadata_only_connector_events_never_drive_the_pet_state() {
|
||||
preferred_app_navigation_payload: None,
|
||||
tool_activity_run_marker: None,
|
||||
}];
|
||||
let now = time::OffsetDateTime::parse(
|
||||
"2026-07-17T10:00:01Z",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.unwrap();
|
||||
let now = OffsetDateTime::parse(&fixture_timestamp(1), &Rfc3339).unwrap();
|
||||
assert!(
|
||||
agent_state::select_active_agent_state(&BehaviorSettings::default(), &candidates, now)
|
||||
.is_none()
|
||||
@@ -114,7 +119,7 @@ fn newer_metadata_does_not_hide_the_latest_activity_for_a_session() {
|
||||
assert_eq!(candidates[0].event.event_type, AgentEventType::Tool);
|
||||
|
||||
let mut done = event("evt-done", AgentEventType::Done, "Stop", "prompt-one");
|
||||
done.created_at = "2026-07-17T10:00:01Z".to_string();
|
||||
done.created_at = fixture_timestamp(1);
|
||||
database.insert_event(&done).unwrap();
|
||||
let mut config = event(
|
||||
"evt-config",
|
||||
@@ -122,7 +127,7 @@ fn newer_metadata_does_not_hide_the_latest_activity_for_a_session() {
|
||||
"ConfigChange",
|
||||
"prompt-one",
|
||||
);
|
||||
config.created_at = "2026-07-17T10:00:02Z".to_string();
|
||||
config.created_at = fixture_timestamp(2);
|
||||
config.payload_json["affects_activity"] = json!(false);
|
||||
database.insert_event(&config).unwrap();
|
||||
|
||||
@@ -156,7 +161,7 @@ fn concurrent_single_source_status_writes_preserve_all_four_agents() {
|
||||
verification: AgentVerification::default(),
|
||||
capabilities: AgentConnectorCapabilities::default(),
|
||||
check_mode: ConnectionCheckMode::Runtime,
|
||||
checked_at: "2026-07-17T10:00:00Z".to_string(),
|
||||
checked_at: fixture_timestamp(0),
|
||||
};
|
||||
barrier.wait();
|
||||
database.upsert_connection_status(&status).unwrap();
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
|
||||
|
||||
fn received_at() -> &'static str {
|
||||
// Ordinary fixtures stay inside the retention window without embedding a
|
||||
@@ -22,6 +22,13 @@ fn received_at() -> &'static str {
|
||||
})
|
||||
.as_str()
|
||||
}
|
||||
|
||||
fn received_at_offset(seconds: i64) -> String {
|
||||
(OffsetDateTime::parse(received_at(), &Rfc3339).expect("parse retained fixture timestamp")
|
||||
+ Duration::seconds(seconds))
|
||||
.format(&Rfc3339)
|
||||
.expect("format retained fixture timestamp")
|
||||
}
|
||||
const FORBIDDEN_VALUES: &[&str] = &[
|
||||
"FORBIDDEN_PROMPT_VALUE_7f16",
|
||||
"FORBIDDEN_COMMAND_VALUE_b8c2",
|
||||
@@ -918,17 +925,18 @@ fn event_retention_prunes_oldest_rows() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database = Database::new(temp.path().join("events.sqlite"));
|
||||
database.init().unwrap();
|
||||
let oldest_created_at = received_at_offset(0);
|
||||
for (id, created_at) in [
|
||||
("oldest", "2099-07-01T00:00:00Z"),
|
||||
("middle", "2099-07-02T00:00:00Z"),
|
||||
("newest", "2099-07-03T00:00:00Z"),
|
||||
("oldest", oldest_created_at.clone()),
|
||||
("middle", received_at_offset(1)),
|
||||
("newest", received_at_offset(2)),
|
||||
] {
|
||||
database
|
||||
.insert_event(&strict_event(
|
||||
id,
|
||||
AgentSource::ClaudeCode,
|
||||
Some("session"),
|
||||
created_at,
|
||||
&created_at,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
@@ -947,17 +955,18 @@ fn event_retention_prunes_oldest_rows() {
|
||||
let recent = database.recent_events(10).unwrap();
|
||||
assert_eq!(recent.len(), 2);
|
||||
assert!(recent.iter().all(|event| event.id != "oldest"));
|
||||
let summarized_day = &oldest_created_at[..10];
|
||||
let summarized: i64 = Connection::open(database.path())
|
||||
.unwrap()
|
||||
.query_row(
|
||||
r#"
|
||||
SELECT event_count
|
||||
FROM agent_event_daily_counts
|
||||
WHERE event_day = '2099-07-01'
|
||||
WHERE event_day = ?1
|
||||
AND source = 'claude_code'
|
||||
AND event_type = 'tool'
|
||||
"#,
|
||||
[],
|
||||
params![summarized_day],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -971,15 +980,15 @@ fn session_alias_authority_survives_restart_migration_and_tracks_event_retention
|
||||
let database = Database::new(path.clone());
|
||||
database.init().unwrap();
|
||||
for (id, session_id, created_at) in [
|
||||
("alias-a-first", "raw-session-a", "2026-07-20T00:00:00Z"),
|
||||
("alias-b-first", "raw-session-b", "2026-07-20T00:00:01Z"),
|
||||
("alias-a-first", "raw-session-a", received_at_offset(0)),
|
||||
("alias-b-first", "raw-session-b", received_at_offset(1)),
|
||||
] {
|
||||
database
|
||||
.insert_event(&strict_event(
|
||||
id,
|
||||
AgentSource::ClaudeCode,
|
||||
Some(session_id),
|
||||
created_at,
|
||||
&created_at,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1037,7 +1046,7 @@ fn session_alias_authority_survives_restart_migration_and_tracks_event_retention
|
||||
"alias-a-newest",
|
||||
AgentSource::ClaudeCode,
|
||||
Some("raw-session-a"),
|
||||
"2026-07-20T00:00:02Z",
|
||||
&received_at_offset(2),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(read_aliases(), initial);
|
||||
@@ -1054,7 +1063,7 @@ fn session_alias_authority_survives_restart_migration_and_tracks_event_retention
|
||||
"alias-c-after-retention",
|
||||
AgentSource::ClaudeCode,
|
||||
Some("raw-session-c"),
|
||||
"2026-07-20T00:00:03Z",
|
||||
&received_at_offset(3),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"apps/macos/Sources/AgentPetCompanion/App/AppRuntimeLifecycle.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/BrandAssets.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/BundledPetInventory.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/InitialAppearanceWindowGate.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/InitialAppearanceWindowAppearance.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/Localization.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/LocalizationKeys/**",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/PackagedResourceBundle.swift",
|
||||
@@ -177,7 +177,7 @@
|
||||
"apps/macos/Sources/AgentPetCompanion/App/AppRuntimeLifecycle.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/BrandAssets.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/BundledPetInventory.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/InitialAppearanceWindowGate.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/InitialAppearanceWindowAppearance.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/Localization.swift",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/LocalizationKeys/**",
|
||||
"apps/macos/Sources/AgentPetCompanion/App/PackagedResourceBundle.swift",
|
||||
@@ -522,12 +522,14 @@
|
||||
"script/development_flow.py",
|
||||
"script/engineering_metrics.py",
|
||||
"script/interaction-contract-files.txt",
|
||||
"script/fixtures/MainWindowColdLaunchProbe.swift",
|
||||
"script/resolve_release_source_proof.py",
|
||||
"script/release_source_proof.py",
|
||||
"script/swift_build_artifact.py",
|
||||
"script/validate_build_scripts_safety.sh",
|
||||
"script/validate_local_tests.sh",
|
||||
"script/validate_local_tests.py",
|
||||
"script/validate_main_window_ui.sh",
|
||||
"script/validate_overlay_offline.sh",
|
||||
"script/validate_portable_pet_maker.sh",
|
||||
"script/validate_rust_test_shards.py",
|
||||
@@ -535,6 +537,7 @@
|
||||
"script/validate_security_boundaries.sh",
|
||||
"script/validate_swift_tests.sh",
|
||||
"script/validate_test_isolation.sh",
|
||||
"script/validate_time_independent_tests.py",
|
||||
"script/validation_scope.py"
|
||||
],
|
||||
"dependencies": [
|
||||
@@ -673,12 +676,14 @@
|
||||
"script/development_flow.py",
|
||||
"script/engineering_metrics.py",
|
||||
"script/interaction-contract-files.txt",
|
||||
"script/fixtures/MainWindowColdLaunchProbe.swift",
|
||||
"script/resolve_release_source_proof.py",
|
||||
"script/release_source_proof.py",
|
||||
"script/swift_build_artifact.py",
|
||||
"script/validate_build_scripts_safety.sh",
|
||||
"script/validate_local_tests.py",
|
||||
"script/validate_local_tests.sh",
|
||||
"script/validate_main_window_ui.sh",
|
||||
"script/validate_overlay_offline.sh",
|
||||
"script/validate_portable_pet_maker.sh",
|
||||
"script/validate_rust_test_shards.py",
|
||||
@@ -686,6 +691,7 @@
|
||||
"script/validate_security_boundaries.sh",
|
||||
"script/validate_swift_tests.sh",
|
||||
"script/validate_test_isolation.sh",
|
||||
"script/validate_time_independent_tests.py",
|
||||
"script/validation_scope.py"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ The App composition root retains cross-feature orchestration in `AppStore`, whil
|
||||
4. After the launch-critical snapshot path and any release connector convergence complete, the App runs one full check of all four Agent connections and projects actionable failures into a control-center-wide notice that opens Agent Connections. An Agent App or CLI that is not installed remains informational on Agent Connections and never enters this global notice.
|
||||
5. Subsequent state arrives through revision-based `state.wait`; the App does not read SQLite or poll bundle files.
|
||||
|
||||
Cold launch and reopen use the registered control-center window as their single fronting authority. Initial desktop-pet presentation preserves an already-key control center, but never reactivates the App after the user has switched away; keyboard focus moves to the session bubble only through its explicit command. Closing the control center leaves the menu bar and enabled pet running. Standard Quit closes the UI host and overlay; the LaunchAgent-hosted PetCore may remain available.
|
||||
Cold launch, Dock reopen, secondary-instance activation, menu/overlay actions, and update recovery all enter one control-center presentation coordinator. It owns the desired visibility, application activation, exact registered window, and key-window transition regardless of whether SwiftUI installs its presenter or registers the `NSWindow` first. Overlay panels are passive and cannot take keyboard focus during ordering or state updates; only the session bubble's explicit keyboard command grants a focus lease, which ends when that panel resigns key. Closing the control center leaves the menu bar and enabled pet running. Standard Quit closes the UI host and overlay; the LaunchAgent-hosted PetCore may remain available.
|
||||
|
||||
The pet, bubble, and menu form one AppKit composition. A drag has one presentation owner, uses absolute screen coordinates from a captured anchor, and commits one final hard-clamped position without momentum. Pointer ownership is exact and non-configurable: opaque pet pixels and rendered bubble/menu surfaces are interactive, while transparent pet and panel regions pass through to lower apps; missing or stale masks retain a geometric pet fallback. Display size is a separate Pet Configuration setting; the overlay exposes no resize interaction. See [Runtime and IPC](runtime-and-ipc.md) for lifecycle and persistence.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ The App and PetCore each hold a private instance lock. A second App instance req
|
||||
|
||||
Closing the control-center window leaves the menu bar and enabled overlay active. Reopen targets the registered control-center scene; About cannot intercept it. Standard Quit closes the App and overlay. A LaunchAgent-hosted PetCore remains available, while a direct-child fallback is tied to the App process.
|
||||
|
||||
A primary cold launch queues one activation through that same registered control-center presenter on the main run-loop turn after AppKit finishes launching. If SwiftUI has not installed the presenter or registered its NSWindow yet, the request remains pending until both steps complete; AppKit activation then fronts that exact window. Cold launch, Dock reopen, and secondary-instance activation therefore converge on the same single window.
|
||||
A primary cold launch queues one request through the control-center presentation coordinator after AppKit finishes launching. The coordinator is the only owner of desired visibility, App activation, exact-window registration, and the key-window transition. If SwiftUI has not installed the presenter or registered its `NSWindow` yet, the request remains pending and is fulfilled once in either ordering. Cold launch, Dock reopen, secondary-instance activation, product actions, and update recovery therefore converge on the same singleton window without activating the App before that window can be fronted.
|
||||
|
||||
## Startup and managed runtime
|
||||
|
||||
@@ -40,7 +40,7 @@ Every current managed runtime also carries the build-bound `interaction-attestat
|
||||
|
||||
After the healthy commit boundary, the runtime store retains the exact current build, the compatible last-known-good build, and any source/candidate builds named by a live rollback checkpoint. It prunes only unreferenced version directories whose private ownership, closed file inventory, regular-file identity, and manifest build ID prove that they are App-managed runtimes. Unknown, malformed, linked, or foreign entries remain untouched; pruning failure never reverses a healthy runtime commit. This retention does not affect SQLite, pets, generation workspaces, connectors, logs, or settings.
|
||||
|
||||
At bootstrap, the App applies persisted interface language and appearance before showing ordinary windows. A short system-default fallback prevents a stalled PetCore read from leaving them invisible. The appearance gate is owned once per NSWindow: transient SwiftUI host removal, replacement, or duplication cannot recapture a concealed state or cancel the deferred reveal, and dismantling the final host fails open. Initial launch and recovery coalesce onto one behavior → seed → snapshot → overlay pipeline. PetCore's first complete snapshot is the final authority for settings, onboarding, pets, placement, connections, and active sessions.
|
||||
At bootstrap, ordinary windows are visible and interactive immediately with the system appearance. Persisted appearance hydration is an idempotent `NSAppearance` update only: it never changes window alpha, input handling, ordering, layout, activation, or key state. A stalled PetCore read therefore cannot leave a window blank or transparent. Initial launch and recovery coalesce onto one behavior → seed → snapshot → overlay pipeline. PetCore's first complete snapshot is the final authority for settings, onboarding, pets, placement, connections, and active sessions.
|
||||
|
||||
## Overlay and state delivery
|
||||
|
||||
@@ -50,7 +50,7 @@ The App reads a consistent `state.snapshot`, then waits with `state.wait(after_r
|
||||
|
||||
Overlay pointer ownership is permission-free: a main-run-loop 120 Hz sampler reads each overlay panel's window-local cursor position and updates whole-window passthrough, while App-local mouse monitors immediately reconcile events already delivered to the App. The implementation never reads global cursor, button, or modifier-key state; creates a system event tap; registers a cross-application event monitor; or installs a keyboard event monitor, so desktop-pet interaction does not require Input Monitoring access. The current frame's Alpha mask owns only opaque pet pixels; the bubble owns only its rendered rounded cards, and all surrounding transparent panel area remains available to lower applications. A finite playback substate has its own render identity but not a second semantic owner: when `burst_then_idle` presents its settled idle frame, that frame publishes its real visible envelope and Alpha mask under the originating Agent event's entry ID. The App therefore accepts the current settled geometry while continuing to reject callbacks owned by an earlier semantic state. Missing or stale pet masks deliberately fall back to the geometric pet region so launch and frame transitions never disable dragging. The same policy is reapplied in AppKit hit testing as a defensive boundary. No enlarged proximity region may take ownership on behalf of pet or bubble content.
|
||||
|
||||
Display width is stored separately and changed only through Pet Configuration. Bubble and menu geometry stays App-local and does not cross RPC. A temporarily concealed Control Center is never classified as the system Show Desktop gesture, so appearance hydration cannot hide the desktop pet as a secondary effect. Interaction telemetry is aggregate-only and excludes coordinates, display/session identifiers, paths, payloads, and user text.
|
||||
Display width is stored separately and changed only through Pet Configuration. Bubble and menu geometry stays App-local and does not cross RPC. Explicitly closing the Control Center is not classified as the system Show Desktop gesture. Interaction telemetry is aggregate-only and excludes coordinates, display/session identifiers, paths, payloads, and user text.
|
||||
|
||||
The App exposes typed service states (`checking`, `recovering`, `online`, `offline`, `runtimeMismatch`, `error`). Human-readable copy never grants recovery authority. Service & Diagnostics projects one aggregate state while preserving typed component and renderer detail.
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ Build and validation are separate profiles. `build_and_run.sh --run` and the env
|
||||
|
||||
The local default is deliberately bounded: it checks diff and source syntax, localization and lightweight Skill/workflow contracts when touched, Rust formatting plus `cargo check`, and strict Swift compilation. `validate_local_tests.sh` is an optional domain-manifest-driven focused entrypoint; it uses explicit changed paths, rejects stale or unknown domains, and falls back conservatively when no exact focused suite exists. Neither local path runs complete Rust or Swift tests, simulated connector/producer roundtrips, App assembly, stress, or release artifact validation. Those duplicate proofs belong to required GitHub CI. The local gate never launches the GUI, mutates user LaunchAgents, invokes real Agents, or reads credentials. / 本地默认门禁刻意保持轻量;可选的领域聚焦入口根据类型化领域清单和明确改动路径生成计划,拒绝未知领域,并在无法精确命中时保守回退。两条本地路径都不重复远端全量测试、组装、压力或发布验证,也不会启动 GUI、修改 LaunchAgent、调用真实 Agent 或读取凭据。
|
||||
|
||||
Source syntax validation also rejects absolute RFC 3339 dates in Rust fixtures that call event-retention write APIs, including dates reached through same-file fixture helpers. Such tests must capture one current UTC base and express ordering or expiry with relative offsets; fixed dates remain valid in isolated schema, serialization, and migration fixtures that do not cross a real wall clock. / 源码语法门禁还会拒绝 Rust 测试中调用事件保留写入 API(包括经同文件 fixture helper 间接调用)时使用固定 RFC 3339 绝对日期。这类测试必须捕获一次当前 UTC 基准并用相对偏移表达顺序或过期;不接触真实时钟的独立 schema、序列化与迁移 fixture 仍可保留固定日期。
|
||||
|
||||
`validation_scope.py` is the single path classifier used by local pre-push and CI. The routing contract is: / 本地预推送与 CI 共用 `validation_scope.py`,范围合同如下:
|
||||
|
||||
| Change / 变更 | Automatic proof / 自动证明 | Not automatic / 不自动执行 |
|
||||
@@ -70,6 +72,7 @@ Branch/worktree ownership and direct/train PR coordination are defined by [Paral
|
||||
## Environment-dependent gates / 环境门禁
|
||||
|
||||
- `APC_VALIDATE_HOST_UI=1` permits repository validators that affect a packaged App runtime. It does not itself invoke Computer Use.
|
||||
- `APC_MAIN_UI_COLD_LAUNCH_ITERATIONS=1..100` controls the real foreground Control Center stress loop inside `validate_main_window_ui.sh` (default `3`; Release acceptance requires at least `20`). Each iteration uses an isolated owned runtime and varied launch-request timing, then proves the exact window identifier, layer, alpha, frame, nonblank ScreenCaptureKit pixels, overlay presentation, and retained key-window identity. Screen Recording and Accessibility access must be available to the executing host; an unavailable observation fails this host gate rather than being inferred as passed. / 该变量控制真实前台控制中心的冷启动压力循环(默认 `3`,Release 验收至少 `20`);每轮使用隔离运行时与不同的展示请求时序,并验证精确窗口标识、层级、透明度、尺寸、非空白像素、悬浮层展示及持续的 key-window 身份。主机缺少录屏或辅助功能权限时门禁直接失败,不得推断为通过。
|
||||
- `APC_VALIDATE_REAL_AGENT_CONNECTORS=1` permits checks against installed Agent CLIs and managed connector files. It never permits reading credential stores.
|
||||
- `APC_VALIDATE_REAL_APP_SERVER=1` permits the real App Server lifecycle validation. It deliberately requests native input, completes one pet, interrupts and restarts its owned PetCore, resumes a second task, then strictly cancels and verifies the exact Studio thread is archived or absent from the ordinary list. `APC_REQUIRE_EXTERNAL_SKILL_SOURCE=1` keeps the strict release proof; lowering it weakens the result.
|
||||
- `APC_RUN_SIX_HOUR_MAKER_SOAK=1` runs the non-default six-hour gate. It holds a real task in durable waiting-for-user state for just over six hours, verifies the backend-authoritative duration includes that interval, and then runs the same resume/cancel lifecycle checks.
|
||||
@@ -86,6 +89,8 @@ APC_VALIDATE_REAL_AGENT_CONNECTORS=1 ./script/validate_real_agent_connectors.sh
|
||||
APC_VALIDATE_REAL_APP_SERVER=1 ./script/validate_real_app_server.sh
|
||||
APC_RUN_SIX_HOUR_MAKER_SOAK=1 ./script/soak_ai_pet_maker_six_hours.sh
|
||||
APC_EVENT_STORM_COUNT=1000 ./script/validate_event_storm.sh
|
||||
APC_VALIDATE_HOST_UI=1 APC_MAIN_UI_COLD_LAUNCH_ITERATIONS=20 \
|
||||
./script/validate_main_window_ui.sh
|
||||
./script/validate_overlay_performance_summary.sh /absolute/summary.json 60
|
||||
```
|
||||
|
||||
|
||||
@@ -109,9 +109,14 @@ git switch main
|
||||
git pull --ff-only
|
||||
test -z "$(git status --porcelain)"
|
||||
git rev-parse HEAD
|
||||
APC_VALIDATE_HOST_UI=1 \
|
||||
APC_MAIN_UI_COLD_LAUNCH_ITERATIONS=20 \
|
||||
./script/validate_main_window_ui.sh
|
||||
./script/build_and_run.sh --run
|
||||
```
|
||||
|
||||
The stress command is a fail-closed Release prerequisite, not a substitute for Computer Use. It runs twenty real foreground cold launches with deterministic timing jitter and requires the exact singleton Control Center to be visible, opaque, on layer 0, pixel-nonblank, and still key after the overlay is ordered. A ScreenCaptureKit or Accessibility observation that cannot be completed is a failed gate. / 冷启动压力命令是 fail-closed 的发布前置条件,不能替代 Computer Use。它以确定性时序扰动执行二十次真实前台冷启动,并要求精确的单例控制中心可见、不透明、位于 layer 0、像素非空,且桌宠浮层完成排序后仍保持 key window;ScreenCaptureKit 或辅助功能观察无法完成时按失败处理。
|
||||
|
||||
Use Computer Use—not `--run-ui-validation`, a hidden `NSHostingView`, AX-only validation, or source inspection—to observe and operate the running App. The acceptance is one bounded basic-function pass:
|
||||
|
||||
必须使用 Computer Use 直接观察并操作正在运行的 App;`--run-ui-validation`、隐藏的 `NSHostingView`、仅 AX 验证或源码检查都不能替代。验收只需完成一次有界的基础功能检查:
|
||||
@@ -122,9 +127,9 @@ Use Computer Use—not `--run-ui-validation`, a hidden `NSHostingView`, AX-only
|
||||
4. The desktop pet can be hidden and shown from the Control Center. / 可从控制中心隐藏并重新显示桌宠。
|
||||
5. Closing and reopening the Control Center, then quitting and relaunching the App twice consecutively, restores a visible usable window after each cold launch. / 关闭并重新打开控制中心后,连续两次退出并重启 App;每次冷启动都必须恢复可见且可用的窗口。
|
||||
|
||||
Record the full tested commit and the result. A pass authorizes one dispatch for that exact commit. If any item fails or cannot be directly observed, do not dispatch, do not enter `passed`, and do not autonomously waive the failure; preserve the evidence and stop so the next action is 交由用户决定. After any source change, even a release-only fix, repeat the acceptance against the new commit.
|
||||
Record the full tested commit and the combined stress plus Computer Use result. Enter `host_ui_result=passed` only when both gates passed on the same exact clean commit; that pass authorizes one dispatch for that commit. If any item fails or cannot be directly observed, do not dispatch, do not enter `passed`, and do not autonomously waive the failure; preserve the evidence and stop so the next action is 交由用户决定. After any source change, even a release-only fix, repeat both gates against the new commit.
|
||||
|
||||
记录完整的被测 commit 与结果。通过结果只授权发布这个精确 commit。若任一检查失败或无法直接观察,不得触发发布、不得填写 `passed`、也不得由 Agent 自行豁免;应保留证据并停止,后续动作交由用户决定。任何源码变化(包括仅用于发布的修复)都会使旧结果失效,必须针对新 commit 重新验收。
|
||||
记录完整的被测 commit,以及压力门禁与 Computer Use 的组合结果。只有两项门禁都在同一个精确且干净的 commit 上通过,才能填写 `host_ui_result=passed`,且该结果只授权发布这个 commit。若任一检查失败或无法直接观察,不得触发发布、不得填写 `passed`、也不得由 Agent 自行豁免;应保留证据并停止,后续动作交由用户决定。任何源码变化(包括仅用于发布的修复)都会使旧结果失效,必须针对新 commit 重新执行两项门禁。
|
||||
|
||||
## GitHub automation / GitHub 自动化
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ScreenCaptureKit
|
||||
|
||||
private let controlCenterIdentifier = "dev.agentpet.companion.control-center"
|
||||
|
||||
private func fail(_ message: String) -> Never {
|
||||
fputs("main window cold-launch probe failed: \(message)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
private func copyAttribute(
|
||||
_ element: AXUIElement,
|
||||
_ attribute: String
|
||||
) -> AnyObject? {
|
||||
var value: AnyObject?
|
||||
guard AXUIElementCopyAttributeValue(
|
||||
element,
|
||||
attribute as CFString,
|
||||
&value
|
||||
) == .success else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func stringAttribute(
|
||||
_ element: AXUIElement,
|
||||
_ attribute: String
|
||||
) -> String {
|
||||
copyAttribute(element, attribute) as? String ?? ""
|
||||
}
|
||||
|
||||
private func pointAttribute(
|
||||
_ element: AXUIElement,
|
||||
_ attribute: String
|
||||
) -> CGPoint? {
|
||||
guard let value = copyAttribute(element, attribute) else { return nil }
|
||||
var result = CGPoint.zero
|
||||
guard AXValueGetValue(value as! AXValue, .cgPoint, &result) else {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func sizeAttribute(
|
||||
_ element: AXUIElement,
|
||||
_ attribute: String
|
||||
) -> CGSize? {
|
||||
guard let value = copyAttribute(element, attribute) else { return nil }
|
||||
var result = CGSize.zero
|
||||
guard AXValueGetValue(value as! AXValue, .cgSize, &result) else {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private struct VisibleWindow {
|
||||
let id: CGWindowID
|
||||
let layer: Int
|
||||
let alpha: Double
|
||||
let bounds: CGRect
|
||||
}
|
||||
|
||||
private func visibleWindows(ownerPID: pid_t) -> [VisibleWindow] {
|
||||
let options = CGWindowListOption(
|
||||
arrayLiteral: .optionOnScreenOnly,
|
||||
.excludeDesktopElements
|
||||
)
|
||||
let rows = CGWindowListCopyWindowInfo(options, kCGNullWindowID)
|
||||
as? [[String: Any]] ?? []
|
||||
return rows.compactMap { row in
|
||||
guard (row[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value
|
||||
== ownerPID,
|
||||
let id = (row[kCGWindowNumber as String] as? NSNumber)?.uint32Value,
|
||||
let layer = (row[kCGWindowLayer as String] as? NSNumber)?.intValue,
|
||||
let boundsDictionary = row[kCGWindowBounds as String]
|
||||
as? [String: Any],
|
||||
let bounds = CGRect(
|
||||
dictionaryRepresentation: boundsDictionary as CFDictionary
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return VisibleWindow(
|
||||
id: id,
|
||||
layer: layer,
|
||||
alpha: (row[kCGWindowAlpha as String] as? NSNumber)?.doubleValue ?? 0,
|
||||
bounds: bounds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func framesMatch(_ lhs: CGRect, _ rhs: CGRect) -> Bool {
|
||||
abs(lhs.minX - rhs.minX) <= 3
|
||||
&& abs(lhs.minY - rhs.minY) <= 3
|
||||
&& abs(lhs.width - rhs.width) <= 3
|
||||
&& abs(lhs.height - rhs.height) <= 3
|
||||
}
|
||||
|
||||
private func pixelRange(_ image: CGImage) -> (opaqueFraction: Double, range: Int)? {
|
||||
let width = 160
|
||||
let height = max(1, Int(Double(width) * Double(image.height) / Double(image.width)))
|
||||
var pixels = [UInt8](repeating: 0, count: width * height * 4)
|
||||
guard let context = CGContext(
|
||||
data: &pixels,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
context.interpolationQuality = .low
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
var opaquePixels = 0
|
||||
var minimumLuminance = 255
|
||||
var maximumLuminance = 0
|
||||
for offset in stride(from: 0, to: pixels.count, by: 4) {
|
||||
let alpha = Int(pixels[offset + 3])
|
||||
guard alpha > 16 else { continue }
|
||||
opaquePixels += 1
|
||||
let red = Int(pixels[offset])
|
||||
let green = Int(pixels[offset + 1])
|
||||
let blue = Int(pixels[offset + 2])
|
||||
let luminance = (54 * red + 183 * green + 19 * blue) / 256
|
||||
minimumLuminance = min(minimumLuminance, luminance)
|
||||
maximumLuminance = max(maximumLuminance, luminance)
|
||||
}
|
||||
return (
|
||||
Double(opaquePixels) / Double(width * height),
|
||||
maximumLuminance - minimumLuminance
|
||||
)
|
||||
}
|
||||
|
||||
@main
|
||||
private struct MainWindowColdLaunchProbe {
|
||||
static func main() async {
|
||||
guard let pidValue = ProcessInfo.processInfo.environment["APP_PID"],
|
||||
let appPID = Int32(pidValue),
|
||||
let app = NSRunningApplication(processIdentifier: appPID),
|
||||
!app.isTerminated
|
||||
else {
|
||||
fail("owned App PID is unavailable")
|
||||
}
|
||||
|
||||
let axApp = AXUIElementCreateApplication(appPID)
|
||||
var controlCenter: AXUIElement?
|
||||
var controlCenterFrame: CGRect?
|
||||
var visibleControlCenter: VisibleWindow?
|
||||
var overlayWasPresented = false
|
||||
|
||||
for _ in 0..<160 {
|
||||
let windows = copyAttribute(axApp, kAXWindowsAttribute)
|
||||
as? [AXUIElement] ?? []
|
||||
let exactWindows = windows.filter {
|
||||
stringAttribute($0, kAXIdentifierAttribute)
|
||||
== controlCenterIdentifier
|
||||
}
|
||||
if exactWindows.count > 1 {
|
||||
fail("more than one exact Control Center window is registered")
|
||||
}
|
||||
if let exactWindow = exactWindows.first,
|
||||
let position = pointAttribute(exactWindow, kAXPositionAttribute),
|
||||
let size = sizeAttribute(exactWindow, kAXSizeAttribute) {
|
||||
let frame = CGRect(origin: position, size: size)
|
||||
let appWindows = visibleWindows(ownerPID: appPID)
|
||||
let layerZero = appWindows.first {
|
||||
$0.layer == 0
|
||||
&& $0.alpha > 0
|
||||
&& $0.bounds.width >= 760
|
||||
&& $0.bounds.height >= 520
|
||||
&& framesMatch($0.bounds, frame)
|
||||
}
|
||||
let hasOverlay = appWindows.contains {
|
||||
$0.layer > 0
|
||||
&& $0.alpha > 0
|
||||
&& $0.bounds.width > 20
|
||||
&& $0.bounds.height > 20
|
||||
}
|
||||
if let layerZero {
|
||||
controlCenter = exactWindow
|
||||
controlCenterFrame = frame
|
||||
visibleControlCenter = layerZero
|
||||
}
|
||||
overlayWasPresented = overlayWasPresented || hasOverlay
|
||||
}
|
||||
if controlCenter != nil, visibleControlCenter != nil,
|
||||
overlayWasPresented {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
|
||||
guard let controlCenter, let controlCenterFrame,
|
||||
let visibleControlCenter
|
||||
else {
|
||||
fail("exact layer-0 Control Center never became visible")
|
||||
}
|
||||
guard visibleControlCenter.alpha > 0,
|
||||
visibleControlCenter.layer == 0,
|
||||
controlCenterFrame.width >= 760,
|
||||
controlCenterFrame.height >= 520
|
||||
else {
|
||||
fail("Control Center layer, alpha, or frame invariant failed")
|
||||
}
|
||||
guard overlayWasPresented else {
|
||||
fail("overlay never became visible before focus verification")
|
||||
}
|
||||
|
||||
// Give the first overlay ordering turn time to settle. Passive panels
|
||||
// must not change the exact focused window after this point.
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
guard let focusedWindow = copyAttribute(
|
||||
axApp,
|
||||
kAXFocusedWindowAttribute
|
||||
) as! AXUIElement?,
|
||||
stringAttribute(focusedWindow, kAXIdentifierAttribute)
|
||||
== controlCenterIdentifier,
|
||||
CFEqual(focusedWindow, controlCenter)
|
||||
else {
|
||||
fail("overlay presentation displaced Control Center keyboard focus")
|
||||
}
|
||||
|
||||
do {
|
||||
let shareableContent = try await SCShareableContent.excludingDesktopWindows(
|
||||
false,
|
||||
onScreenWindowsOnly: true
|
||||
)
|
||||
guard let captureWindow = shareableContent.windows.first(where: {
|
||||
$0.windowID == visibleControlCenter.id
|
||||
}) else {
|
||||
fail("ScreenCaptureKit could not resolve the exact Control Center")
|
||||
}
|
||||
let filter = SCContentFilter(desktopIndependentWindow: captureWindow)
|
||||
let configuration = SCStreamConfiguration()
|
||||
configuration.width = min(1_280, max(1, Int(controlCenterFrame.width * 2)))
|
||||
configuration.height = min(1_024, max(1, Int(controlCenterFrame.height * 2)))
|
||||
configuration.showsCursor = false
|
||||
let image = try await SCScreenshotManager.captureImage(
|
||||
contentFilter: filter,
|
||||
configuration: configuration
|
||||
)
|
||||
guard let result = pixelRange(image),
|
||||
result.opaqueFraction >= 0.80,
|
||||
result.range >= 12
|
||||
else {
|
||||
fail("captured Control Center pixels are blank or transparent")
|
||||
}
|
||||
} catch {
|
||||
fail("ScreenCaptureKit pixel proof failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
print(
|
||||
"Control Center cold-launch probe ok: pid=\(appPID) "
|
||||
+ "window=\(visibleControlCenter.id)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ CHANGELOG_FRAGMENTS_PATH = ROOT / "script/changelog_fragments.py"
|
||||
LOCAL_TESTS_PATH = ROOT / "script/validate_local_tests.py"
|
||||
ENGINEERING_METRICS_PATH = ROOT / "script/engineering_metrics.py"
|
||||
SWIFT_BUILD_ARTIFACT_PATH = ROOT / "script/swift_build_artifact.py"
|
||||
TIME_INDEPENDENT_TESTS_PATH = ROOT / "script/validate_time_independent_tests.py"
|
||||
INTERACTION_SUITES = [
|
||||
"OverlayPlacementAuthorityTests",
|
||||
"AppStoreOverlaySnapshotTests",
|
||||
@@ -63,6 +64,71 @@ engineering_metrics = load_module("apc_engineering_metrics", ENGINEERING_METRICS
|
||||
swift_build_artifact = load_module(
|
||||
"apc_swift_build_artifact", SWIFT_BUILD_ARTIFACT_PATH
|
||||
)
|
||||
time_independent_tests = load_module(
|
||||
"apc_time_independent_tests", TIME_INDEPENDENT_TESTS_PATH
|
||||
)
|
||||
|
||||
|
||||
class TimeIndependentRustFixtureTests(unittest.TestCase):
|
||||
def violations(self, source: str) -> list[object]:
|
||||
return time_independent_tests.violations_in_source(
|
||||
pathlib.Path("crates/example/tests/retention.rs"), source
|
||||
)
|
||||
|
||||
def test_rejects_fixed_timestamp_reached_through_fixture_helper(self) -> None:
|
||||
violations = self.violations(
|
||||
r'''
|
||||
fn event() -> AgentEvent {
|
||||
AgentEvent { created_at: "2026-07-17T10:00:00Z".to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_projection() {
|
||||
let database = database();
|
||||
database.insert_event(&event()).unwrap();
|
||||
}
|
||||
'''
|
||||
)
|
||||
|
||||
self.assertEqual(len(violations), 1)
|
||||
self.assertEqual(violations[0].function, "event")
|
||||
self.assertEqual(violations[0].timestamp, "2026-07-17T10:00:00Z")
|
||||
|
||||
def test_rejects_fixed_timestamp_in_raw_sql_retention_fixture(self) -> None:
|
||||
violations = self.violations(
|
||||
r'''
|
||||
#[test]
|
||||
fn retention_projection() {
|
||||
database.insert_event(&event()).unwrap();
|
||||
connection.execute(r#"INSERT INTO agent_events VALUES ('2099-01-01T00:00:00Z')"#);
|
||||
}
|
||||
'''
|
||||
)
|
||||
|
||||
self.assertEqual(len(violations), 1)
|
||||
self.assertEqual(violations[0].function, "retention_projection")
|
||||
|
||||
def test_allows_relative_current_time_and_unrelated_schema_fixture(self) -> None:
|
||||
violations = self.violations(
|
||||
r'''
|
||||
fn event() -> AgentEvent {
|
||||
AgentEvent { created_at: fixture_timestamp(2) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_projection() {
|
||||
database.upsert_codex_activity_event(&event()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_round_trip() {
|
||||
let json = r#"{"created_at":"2026-07-17T10:00:00Z"}"#;
|
||||
assert!(json.contains("created_at"));
|
||||
}
|
||||
'''
|
||||
)
|
||||
|
||||
self.assertEqual(violations, [])
|
||||
|
||||
|
||||
class EngineeringMetricsTests(unittest.TestCase):
|
||||
|
||||
@@ -11,8 +11,20 @@ import sys
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
app = (root / "apps/macos/Sources/AgentPetCompanion/App/AgentPetCompanionApp.swift").read_text()
|
||||
app_store = (root / "apps/macos/Sources/AgentPetCompanion/App/AppStore.swift").read_text()
|
||||
presentation = (
|
||||
root
|
||||
/ "apps/macos/Sources/AgentPetCompanion/App/ControlCenterPresentationCoordinator.swift"
|
||||
).read_text()
|
||||
appearance = (
|
||||
root
|
||||
/ "apps/macos/Sources/AgentPetCompanion/App/InitialAppearanceWindowAppearance.swift"
|
||||
).read_text()
|
||||
app_runtime = (root / "apps/macos/Sources/AgentPetCompanion/App/AppRuntimeLifecycle.swift").read_text()
|
||||
overlay = (root / "apps/macos/Sources/AgentPetCompanion/Overlay/OverlayRootView.swift").read_text()
|
||||
overlay_controller = (
|
||||
root / "apps/macos/Sources/AgentPetCompanion/Overlay/PetOverlayController.swift"
|
||||
).read_text()
|
||||
main_window_validator = (root / "script/validate_main_window_ui.sh").read_text()
|
||||
petcore = (root / "apps/macos/Sources/AgentPetCompanion/App/PetCoreProcessManager.swift").read_text()
|
||||
zh_hans = "\n".join(
|
||||
path.read_text()
|
||||
@@ -58,7 +70,7 @@ checks = {
|
||||
),
|
||||
"primary cold launch is routed through the registered control center presenter": (
|
||||
re.search(
|
||||
r'applicationDidFinishLaunching.*?DispatchQueue\.main\.async\s*\{\s*'
|
||||
r'applicationDidFinishLaunching.*?ControlCenterColdLaunchTiming\.schedule\s*\{\s*'
|
||||
r'AppSingleInstanceCoordinator\.shared\.activatePrimaryInstance\(\)',
|
||||
app,
|
||||
re.S,
|
||||
@@ -93,14 +105,42 @@ checks = {
|
||||
app_store,
|
||||
)
|
||||
is not None
|
||||
and "controlCenterPresentationCoordinator.requestPresentation(" in app_store
|
||||
and "runtimeHandoffIfNeeded" in presentation
|
||||
),
|
||||
"one coordinator owns control-center visibility activation and key state": (
|
||||
"final class ControlCenterPresentationCoordinator" in presentation
|
||||
and "private var wantsPresentation = false" in presentation
|
||||
and "window.alphaValue = 1" in presentation
|
||||
and "window.ignoresMouseEvents = false" in presentation
|
||||
and "activateApplication()" in presentation
|
||||
and "window.makeKeyAndOrderFront(nil)" in presentation
|
||||
and "ControlCenterPresentationCoordinator(" in app_store
|
||||
and re.search(
|
||||
r'private func presentMainWindow\(checkRuntimeHandoff: Bool\)\s*\{\s*'
|
||||
r'guard !checkRuntimeHandoff \|\| !runtimeHandoffIfNeeded\(\) '
|
||||
r'else \{ return \}',
|
||||
app_store,
|
||||
r'setMainWindowPresenter\s*\{\s*openWindow\(id:\s*"main"\)\s*\}',
|
||||
app,
|
||||
)
|
||||
is not None
|
||||
),
|
||||
"appearance hydration never hides or disables a window": (
|
||||
"InitialAppearanceWindowView" in appearance
|
||||
and "window.appearance = nextAppearance" in appearance
|
||||
and "alphaValue" not in appearance
|
||||
and "ignoresMouseEvents" not in appearance
|
||||
and "InitialAppearanceWindowGate" not in app
|
||||
),
|
||||
"overlay panels are passive unless the bubble holds an explicit keyboard lease": (
|
||||
"bubblePanel.becomesKeyOnlyIfNeeded = true" in overlay_controller
|
||||
and "override var canBecomeKey: Bool { keyboardNavigationLeaseIsActive }"
|
||||
in overlay_controller
|
||||
and overlay_controller.count("override var canBecomeKey: Bool { false }") >= 2
|
||||
and "beginKeyboardNavigationLease()" in overlay_controller
|
||||
),
|
||||
"cold-launch acceptance rebuilds the exact worktree App": (
|
||||
'"$ROOT_DIR/script/build_app_bundle.sh" >/dev/null'
|
||||
in main_window_validator
|
||||
and 'if [[ ! -x "$APP_BINARY"' not in main_window_validator
|
||||
),
|
||||
"resident UI host has no periodic installed-build polling": (
|
||||
"AppInstalledBuildMonitor" not in app_runtime
|
||||
and "installedBuildMonitor" not in app
|
||||
|
||||
@@ -9,10 +9,13 @@ APP_BUNDLE="$ROOT_DIR/dist/AgentPetCompanion.app"
|
||||
APP_BINARY="$APP_BUNDLE/Contents/MacOS/AgentPetCompanion"
|
||||
PETCORE_BINARY="$APP_BUNDLE/Contents/Resources/bin/petcore"
|
||||
PETCORE_CLI="$APP_BUNDLE/Contents/Resources/bin/petcore-cli"
|
||||
COLD_LAUNCH_ITERATIONS="${APC_MAIN_UI_COLD_LAUNCH_ITERATIONS:-3}"
|
||||
COLD_LAUNCH_PROBE_SOURCE="$ROOT_DIR/script/fixtures/MainWindowColdLaunchProbe.swift"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apc-main-window-ui.XXXXXX")"
|
||||
apc_use_isolated_home "$TMP_DIR"
|
||||
OWNED_PROTOCOL="$APC_HOME/run/validation-owned-runtime.json"
|
||||
APP_LOG="$TMP_DIR/app.log"
|
||||
COLD_LAUNCH_PROBE="$TMP_DIR/main-window-cold-launch-probe"
|
||||
|
||||
cleanup() {
|
||||
apc_stop_owned_runtime "$PETCORE_CLI" "$PETCORE_BINARY" "$OWNED_PROTOCOL"
|
||||
@@ -20,10 +23,24 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ! -x "$APP_BINARY" || ! -x "$PETCORE_BINARY" || ! -x "$PETCORE_CLI" ]]; then
|
||||
"$ROOT_DIR/script/build_app_bundle.sh" >/dev/null
|
||||
# Host acceptance must exercise the current worktree, never a stale bundle
|
||||
# left in dist by an earlier branch or commit. The project builder remains
|
||||
# incremental when its exact source-bound build identity is unchanged.
|
||||
"$ROOT_DIR/script/build_app_bundle.sh" >/dev/null
|
||||
|
||||
if [[ ! "$COLD_LAUNCH_ITERATIONS" =~ ^[0-9]+$ ]] \
|
||||
|| ((COLD_LAUNCH_ITERATIONS < 1 || COLD_LAUNCH_ITERATIONS > 100)); then
|
||||
printf 'APC_MAIN_UI_COLD_LAUNCH_ITERATIONS must be an integer from 1 through 100\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
xcrun swiftc \
|
||||
-parse-as-library \
|
||||
-strict-concurrency=complete \
|
||||
-warnings-as-errors \
|
||||
"$COLD_LAUNCH_PROBE_SOURCE" \
|
||||
-o "$COLD_LAUNCH_PROBE"
|
||||
|
||||
apc_start_owned_runtime \
|
||||
"$APP_BINARY" \
|
||||
"$PETCORE_CLI" \
|
||||
@@ -890,3 +907,28 @@ guard waitForVisibleControlCenter() else {
|
||||
|
||||
print("Main window UI and close/reopen lifecycle validation ok")
|
||||
SWIFT
|
||||
|
||||
# The structural acceptance above runs once against the complete product. The
|
||||
# bounded loop below then repeatedly tears down and cold-starts the same owned
|
||||
# App/runtime while varying the coordinator request timing. Every iteration
|
||||
# proves the exact singleton identifier, layer-0 visibility, positive alpha,
|
||||
# nonblank pixels, overlay presentation, and retained Control Center focus.
|
||||
apc_stop_owned_runtime "$PETCORE_CLI" "$PETCORE_BINARY" "$OWNED_PROTOCOL"
|
||||
COLD_LAUNCH_JITTERS=(0 1 16 33 83 167 333 667 1000 1500)
|
||||
for ((iteration = 1; iteration <= COLD_LAUNCH_ITERATIONS; iteration += 1)); do
|
||||
jitter_index=$(((iteration - 1) % ${#COLD_LAUNCH_JITTERS[@]}))
|
||||
export APC_CONTROL_CENTER_LAUNCH_JITTER_MS="${COLD_LAUNCH_JITTERS[$jitter_index]}"
|
||||
APP_LOG="$TMP_DIR/cold-launch-$iteration.log"
|
||||
apc_start_owned_runtime \
|
||||
"$APP_BINARY" \
|
||||
"$PETCORE_CLI" \
|
||||
"$PETCORE_BINARY" \
|
||||
"$APP_LOG" \
|
||||
"$OWNED_PROTOCOL"
|
||||
APP_PID="$APC_OWNED_APP_PID" "$COLD_LAUNCH_PROBE"
|
||||
apc_stop_owned_runtime "$PETCORE_CLI" "$PETCORE_BINARY" "$OWNED_PROTOCOL"
|
||||
done
|
||||
unset APC_CONTROL_CENTER_LAUNCH_JITTER_MS
|
||||
|
||||
printf 'Main window cold-launch stress validation ok: %s iterations\n' \
|
||||
"$COLD_LAUNCH_ITERATIONS"
|
||||
@@ -55,4 +55,6 @@ if broken_links:
|
||||
raise SystemExit("broken local Markdown links:\n" + "\n".join(broken_links))
|
||||
PY
|
||||
|
||||
python3 "$ROOT_DIR/script/validate_time_independent_tests.py" --root "$ROOT_DIR"
|
||||
|
||||
echo 'Shell, Python, JSON and local Markdown link syntax ok'
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reject calendar-dependent Rust fixtures that cross real retention clocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
RETENTION_API_RE = re.compile(
|
||||
r"\b(?:insert_event|upsert_codex_activity_event|prune_events)\s*\("
|
||||
)
|
||||
LOCAL_CALL_RE = re.compile(r"(?<![.:])\b([A-Za-z_][A-Za-z0-9_]*)\s*!?\s*\(")
|
||||
FUNCTION_RE = re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\b")
|
||||
RFC3339_RE = re.compile(
|
||||
r"\b[12][0-9]{3}-[01][0-9]-[0-3][0-9][Tt]"
|
||||
r"[0-2][0-9]:[0-5][0-9]:[0-6][0-9](?:\.[0-9]+)?"
|
||||
r"(?:[Zz]|[+-][0-2][0-9]:[0-5][0-9])\b"
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RustFunction:
|
||||
name: str
|
||||
start: int
|
||||
body_start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class StringLiteral:
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Violation:
|
||||
path: pathlib.Path
|
||||
line: int
|
||||
function: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
def _blank(mask: list[str], source: str, start: int, end: int) -> None:
|
||||
for index in range(start, end):
|
||||
if source[index] != "\n":
|
||||
mask[index] = " "
|
||||
|
||||
|
||||
def _rust_code_and_strings(source: str) -> tuple[str, list[StringLiteral]]:
|
||||
"""Return a position-preserving code mask and Rust string spans.
|
||||
|
||||
Comments, strings, and character literals are blanked in the code mask so
|
||||
braces and call names inside fixture data cannot affect the function walk.
|
||||
"""
|
||||
|
||||
mask = list(source)
|
||||
strings: list[StringLiteral] = []
|
||||
index = 0
|
||||
length = len(source)
|
||||
while index < length:
|
||||
if source.startswith("//", index):
|
||||
end = source.find("\n", index + 2)
|
||||
if end < 0:
|
||||
end = length
|
||||
_blank(mask, source, index, end)
|
||||
index = end
|
||||
continue
|
||||
if source.startswith("/*", index):
|
||||
depth = 1
|
||||
end = index + 2
|
||||
while end < length and depth:
|
||||
if source.startswith("/*", end):
|
||||
depth += 1
|
||||
end += 2
|
||||
elif source.startswith("*/", end):
|
||||
depth -= 1
|
||||
end += 2
|
||||
else:
|
||||
end += 1
|
||||
_blank(mask, source, index, end)
|
||||
index = end
|
||||
continue
|
||||
|
||||
raw_prefix = None
|
||||
if source.startswith("br", index):
|
||||
raw_prefix = index + 1
|
||||
elif source[index] == "r":
|
||||
raw_prefix = index
|
||||
if raw_prefix is not None and (
|
||||
index == 0 or not (source[index - 1].isalnum() or source[index - 1] == "_")
|
||||
):
|
||||
cursor = raw_prefix + 1
|
||||
while cursor < length and source[cursor] == "#":
|
||||
cursor += 1
|
||||
if cursor < length and source[cursor] == '"':
|
||||
hashes = source[raw_prefix + 1 : cursor]
|
||||
terminator = '"' + hashes
|
||||
end_marker = source.find(terminator, cursor + 1)
|
||||
end = length if end_marker < 0 else end_marker + len(terminator)
|
||||
strings.append(StringLiteral(index, end))
|
||||
_blank(mask, source, index, end)
|
||||
index = end
|
||||
continue
|
||||
|
||||
if source[index] == '"':
|
||||
end = index + 1
|
||||
escaped = False
|
||||
while end < length:
|
||||
character = source[end]
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == '"':
|
||||
end += 1
|
||||
break
|
||||
end += 1
|
||||
strings.append(StringLiteral(index, end))
|
||||
_blank(mask, source, index, end)
|
||||
index = end
|
||||
continue
|
||||
|
||||
if source[index] == "'":
|
||||
# Mask character literals but leave lifetimes such as `'a` intact.
|
||||
char_match = re.match(r"'(?:\\.|[^\\'\n])'", source[index:])
|
||||
if char_match:
|
||||
end = index + char_match.end()
|
||||
_blank(mask, source, index, end)
|
||||
index = end
|
||||
continue
|
||||
index += 1
|
||||
return "".join(mask), strings
|
||||
|
||||
|
||||
def _matching_brace(code: str, opening: int) -> int | None:
|
||||
depth = 0
|
||||
for index in range(opening, len(code)):
|
||||
if code[index] == "{":
|
||||
depth += 1
|
||||
elif code[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _rust_functions(code: str) -> list[RustFunction]:
|
||||
functions: list[RustFunction] = []
|
||||
for match in FUNCTION_RE.finditer(code):
|
||||
opening = code.find("{", match.end())
|
||||
terminator = code.find(";", match.end())
|
||||
if opening < 0 or (terminator >= 0 and terminator < opening):
|
||||
continue
|
||||
end = _matching_brace(code, opening)
|
||||
if end is None:
|
||||
continue
|
||||
functions.append(RustFunction(match.group(1), match.start(), opening + 1, end))
|
||||
return functions
|
||||
|
||||
|
||||
def violations_in_source(path: pathlib.Path, source: str) -> list[Violation]:
|
||||
code, strings = _rust_code_and_strings(source)
|
||||
functions = _rust_functions(code)
|
||||
by_name: dict[str, list[int]] = {}
|
||||
for index, function in enumerate(functions):
|
||||
by_name.setdefault(function.name, []).append(index)
|
||||
|
||||
marked = {
|
||||
index
|
||||
for index, function in enumerate(functions)
|
||||
if RETENTION_API_RE.search(code[function.body_start : function.end])
|
||||
}
|
||||
pending = list(marked)
|
||||
while pending:
|
||||
function_index = pending.pop()
|
||||
function = functions[function_index]
|
||||
body = code[function.body_start : function.end]
|
||||
for call in LOCAL_CALL_RE.finditer(body):
|
||||
for callee_index in by_name.get(call.group(1), []):
|
||||
if callee_index not in marked:
|
||||
marked.add(callee_index)
|
||||
pending.append(callee_index)
|
||||
|
||||
violations: dict[tuple[int, str], Violation] = {}
|
||||
for literal in strings:
|
||||
literal_source = source[literal.start : literal.end]
|
||||
timestamp_match = RFC3339_RE.search(literal_source)
|
||||
if timestamp_match is None:
|
||||
continue
|
||||
owners = [
|
||||
functions[index]
|
||||
for index in marked
|
||||
if functions[index].start <= literal.start < functions[index].end
|
||||
]
|
||||
if not owners:
|
||||
continue
|
||||
owner = min(owners, key=lambda function: function.end - function.start)
|
||||
timestamp_start = literal.start + timestamp_match.start()
|
||||
line = source.count("\n", 0, timestamp_start) + 1
|
||||
violation = Violation(
|
||||
path=path,
|
||||
line=line,
|
||||
function=owner.name,
|
||||
timestamp=timestamp_match.group(0),
|
||||
)
|
||||
violations[(line, violation.timestamp)] = violation
|
||||
return sorted(violations.values(), key=lambda item: (item.line, item.timestamp))
|
||||
|
||||
|
||||
def scan(root: pathlib.Path, paths: Iterable[pathlib.Path] | None = None) -> list[Violation]:
|
||||
candidates = paths
|
||||
if candidates is None:
|
||||
candidates = sorted((root / "crates").rglob("*.rs"))
|
||||
violations: list[Violation] = []
|
||||
for path in candidates:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
violations.extend(violations_in_source(path.relative_to(root), source))
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd())
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
violations = scan(root)
|
||||
if violations:
|
||||
details = "\n".join(
|
||||
f"{item.path}:{item.line}: {item.function} embeds {item.timestamp}"
|
||||
for item in violations
|
||||
)
|
||||
raise SystemExit(
|
||||
"retention-sensitive Rust fixtures must derive one current-time base and use "
|
||||
"relative offsets; fixed RFC3339 dates become calendar time bombs:\n" + details
|
||||
)
|
||||
print("Retention-sensitive Rust test fixtures are time-independent")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user