Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
10269db
docs(agents): 补充测试权限弹窗隔离约束
iamsyc Mar 19, 2026
d9111ef
docs(retrospective): 补充屏幕监听与共享重构复盘资料
iamsyc Mar 19, 2026
afa41ee
test(capture): 补齐预览模式与共享隔离护栏
iamsyc Mar 19, 2026
4de1e53
fix(capture): 调整监听预览窗口默认尺寸与光标开关展示
iamsyc Mar 19, 2026
6bd2cca
fix(capture): 收口监听缩放模式文案来源
iamsyc Mar 19, 2026
b1008da
refactor(capture): 收口监听会话真值源与回归护栏
iamsyc Mar 19, 2026
de61619
docs(workflow): 明确提交阶段复用最近验证结果
iamsyc Mar 19, 2026
ae72872
docs(ci): 对齐 CI 工作流文档\n\n- 修正文档中的默认 Xcode 版本为 26.3\n- 修正文档中的 release-…
iamsyc Mar 19, 2026
1354b49
refactor(capture): 收口监听生命周期并修复并发启动去重
iamsyc Mar 19, 2026
cf14674
fix(capture-sharing): 收敛显示启动并发与失效状态
iamsyc Mar 25, 2026
66feadf
docs(workflow): 强化代理开发约束
iamsyc Mar 27, 2026
21f0680
docs(agents): 增加执行模式建议规则
iamsyc Mar 27, 2026
152ec90
refactor(capture): 收敛共享显示目录状态
iamsyc Mar 28, 2026
ae8c13b
refactor(capture): 拆分采集服务并收紧并发语义
iamsyc Mar 28, 2026
3de3bca
docs(capture): 同步采集服务拆分后的文件索引
iamsyc Mar 28, 2026
a42cc95
fix(sharing): 收敛 viewer 准入与测试命中门禁
iamsyc Mar 28, 2026
f525263
feat(sharing): 收敛共享状态事件链路与拓扑协调
iamsyc Mar 28, 2026
b2f1e47
docs(sharing): 同步共享链路重构文档与基线说明
iamsyc Mar 28, 2026
bb89a1e
feat(sharing): 提升共享默认帧率并本地化观看页
iamsyc Mar 28, 2026
bc95202
test(sharing): 补齐阶段六共享测试闭环
iamsyc Apr 1, 2026
e5e6ef4
feat(capture): 增加采集性能模式与自适应帧率
iamsyc Apr 1, 2026
1a24207
docs(agents): 收紧执行模式建议输出时机
iamsyc Apr 1, 2026
b4d55a4
fix(capture): 修复性能模式状态同步与会话生效
iamsyc Apr 1, 2026
48ab20e
docs(agents): 补充 code review 场景的执行模式建议规则
iamsyc Apr 1, 2026
e49dce1
fix(capture): 串行化采集流配置更新
iamsyc Apr 2, 2026
7f39d48
fix(sharing): 归一化主屏别名路由与预览依赖
iamsyc Apr 2, 2026
a85def4
fix(capture): 修正共享游标覆盖状态释放时序
iamsyc Apr 3, 2026
f09cae3
fix(sharing): 修正失效分享连接与 WebRTC 信令积压
iamsyc Apr 3, 2026
1166166
fix(capture): 隔离测试环境下的屏幕目录加载
iamsyc Apr 3, 2026
1b97fdf
fix(capture-sharing): 修正目录拓扑刷新与共享状态收口
iamsyc Apr 3, 2026
2809a88
fix(sharing): 修复共享状态串扰与拓扑刷新错配
iamsyc Apr 3, 2026
c549eeb
refactor(capture-sharing): 收敛屏幕目录与采集编排复杂度
iamsyc Apr 4, 2026
856adc3
fix(ui): 修复屏幕监听与预览诊断回归
iamsyc Apr 4, 2026
414502d
test(ui): 补回重建失败 smoke 入口
iamsyc Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/_reusable-ui-smoke-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ jobs:
set -e

if [ "$status" -eq 0 ]; then
if ! bash scripts/test/xcresult_test_count_guard.sh \
--xcresult UISmokeTests.xcresult \
--label "UI smoke attempt ${attempt}/${max_attempts}"; then
write_summary "failed" "selector_mismatch" "$attempt" "$log_file"
echo "UI smoke selector mismatch detected (0 tests executed)."
exit 1
fi
write_summary "passed" "none" "$attempt" "$log_file"
exit 0
fi
Expand Down
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
## Test Execution Policy
- After every code change, explicitly check whether related tests need to be updated or added, and complete required test updates before handoff.
- Default: run targeted tests related to changed module/feature.
- If related verification has already completed after the latest code change, and no repo-tracked file has changed since that verification, a later commit-only instruction must reuse the existing fresh verification result instead of rerunning the same tests.
- For small, explicit, low-risk changes with tightly bounded impact, do not run the full `HomeSmokeTests` suite by default. Prefer build-only verification or a narrower targeted test that covers the changed control or flow.
- Run full suite when changes are broad/high-risk or impact cannot be bounded:
- shared/common code changes
Expand All @@ -40,6 +41,13 @@
- high-risk runtime behavior (concurrency/persistence/network/security)
- user explicitly requests full suite

## Test Permission Prompt Isolation
- Automated tests must not trigger app-driven macOS privacy prompts such as screen recording, microphone, or camera authorization dialogs.
- Test runs must remain non-interactive and must not depend on a human waiting for app permission prompts during execution.
- Any code path that may request app privacy permissions must switch to a test-specific provider, mock, stub, or equivalent isolation layer under test environments.
- macOS authorization required by the test harness itself, such as Automation, Accessibility, Input Monitoring, or related administrator approval for UI automation, is environment setup and should be handled separately from app permission flows.
- Reject any test change that can block local or CI execution by introducing new app-driven privacy authorization prompts.

## UI Test Port Injection
- Preferred port key is `SharingPortPreferenceKeys.preferredPort` (`sharing.preferredPort`).
- For UI tests, inject with launch arguments: `-sharing.preferredPort <port>`.
Expand All @@ -58,6 +66,17 @@
- If the user goal or instruction is ambiguous, do not guess. Ask for clarification promptly before continuing.
- Clarification questions must include all reasonable current interpretations from the agent, so the user can confirm or correct them directly.

## Execution Mode Recommendation
- Provide an execution mode recommendation only before starting work on an actionable request and only when there is a meaningful choice between immediate execution and plan-first handling.
- Do not provide this recommendation in completion handoff, commit summaries, verification summaries, or meta discussions about process, prompts, or repository policy.
- Do not provide this recommendation for analysis-only or question-only requests, unless the current turn is a code review that produced actionable findings requiring follow-up implementation.
- For code review requests with actionable findings, append exactly one execution mode recommendation after the findings summary.
- Do not provide this recommendation when the user has already explicitly chosen the mode for the current turn.
- Once execution has started in the current turn, stop emitting execution mode recommendations.
- Use `建议:直接执行` only when implementation has not started, scope is clear, affected area is bounded, validation path is clear, and there is no material decision gate.
- Use `建议:开启计划模式` only when implementation has not started and the task is ambiguous, cross-module, high-risk, multi-stage, blocked by unknowns, or depends on user choice between materially different options.
- Keep the recommendation to one sentence and state the concrete reason.

## Code Review Output Policy
- When review finds an issue, identify the root cause and provide a root-cause fix plan by default.
- Always include a structural refactor assessment: whether it is needed, expected benefits, risks, and validation impact.
Expand All @@ -66,6 +85,9 @@
## Complexity and Size Guardrail
- Default goal: solve problems without increasing code complexity and code size.
- If that is not feasible, lower complexity first.
- Reject temporary fixes, glue code, and patch-style handling. Solve the root problem with a clean structural change.
- Do not add transitional adapters, one-off shims, or workaround layers unless the user explicitly requires them for a defined migration window.
- Do not preserve backward compatibility by default. Only keep it when explicitly required, and document the caller, removal condition, and validation impact in the handoff.
- Prefer deleting duplicate branches and duplicate checks.
- Keep equivalent validation at one convergence layer. Avoid multi-layer duplicate defense.
- When adding defensive branches, prioritize deleting equivalent legacy branches in the same module.
Expand Down
2 changes: 1 addition & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Key files for debugging:
| Area | Files |
|------|-------|
| Virtual Display | `VirtualDisplayService.swift`, `CreateVirtualDisplayObjectView.swift`, `EditVirtualDisplayConfigView.swift` |
| Screen Capture | `CaptureChooseViewModel.swift`, `ScreenCaptureFunction.swift` |
| Screen Capture | `CaptureChooseViewModel.swift`, `DisplayCaptureRegistry.swift`, `DisplayCaptureSession.swift`, `DisplayStartCoordinator.swift` |
| LAN Sharing | `ShareViewModel.swift`, `SharingService.swift`, `Features/Sharing/Web/WebServer.swift` |

Unified logs (`Logger`, subsystem `com.developerchen.voiddisplay`):
Expand Down
24 changes: 23 additions & 1 deletion VoidDisplay/App/AppSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,28 @@ import SwiftUI

struct AppSettingsView: View {
@Environment(VirtualDisplayController.self) private var virtualDisplay
@Environment(CapturePerformancePreferences.self) private var capturePerformancePreferences
@State private var showResetConfirmation = false
@State private var resetCompleted = false

var body: some View {
@Bindable var bindableVirtualDisplay = virtualDisplay

VStack(alignment: .leading, spacing: 12) {
Text("Capture Performance")
.font(.headline)

Text("Choose how screen monitoring and sharing balance smoothness and resource usage.")
.font(.subheadline)
.foregroundStyle(.secondary)

Picker("Capture Performance", selection: performanceModeBinding) {
Text("Automatic").tag(CapturePerformanceMode.automatic)
Text("Smooth").tag(CapturePerformanceMode.smooth)
Text("Power Efficient").tag(CapturePerformanceMode.powerEfficient)
}
.pickerStyle(.segmented)

Text("Virtual Displays")
.font(.headline)

Expand All @@ -35,7 +50,7 @@ struct AppSettingsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
.frame(width: 420, height: 170, alignment: .topLeading)
.frame(width: 420, height: 270, alignment: .topLeading)
.confirmationDialog(
"Reset Virtual Display Configurations?",
isPresented: $showResetConfirmation
Expand All @@ -60,4 +75,11 @@ struct AppSettingsView: View {
)
}
}

private var performanceModeBinding: Binding<CapturePerformanceMode> {
Binding(
get: { capturePerformancePreferences.mode },
set: { capturePerformancePreferences.saveMode($0) }
)
}
}
86 changes: 68 additions & 18 deletions VoidDisplay/App/CaptureController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,99 @@

import Foundation
import CoreGraphics
import ScreenCaptureKit
import Observation

@MainActor
@Observable
final class CaptureController {
var screenCaptureSessions: [ScreenMonitoringSession] = []
@ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState()
private(set) var startingDisplayIDs: Set<CGDirectDisplayID> = []
@ObservationIgnored let catalogService: ScreenCaptureCatalogService

@ObservationIgnored private let captureMonitoringService: any CaptureMonitoringServiceProtocol
@ObservationIgnored private let captureMonitoringLifecycleService: any CaptureMonitoringLifecycleServiceProtocol
@ObservationIgnored private let startTracker = DisplayStartTracker()
@ObservationIgnored private lazy var mutationRunner = SnapshotMutationRunner { [weak self] in
self?.syncCaptureMonitoringState()
}

init(captureMonitoringService: any CaptureMonitoringServiceProtocol) {
init(
captureMonitoringService: any CaptureMonitoringServiceProtocol,
captureMonitoringLifecycleService: (any CaptureMonitoringLifecycleServiceProtocol)? = nil,
catalogService: ScreenCaptureCatalogService? = nil
) {
self.captureMonitoringService = captureMonitoringService
self.captureMonitoringLifecycleService = captureMonitoringLifecycleService
?? CaptureMonitoringLifecycleService(captureMonitoringService: captureMonitoringService)
self.catalogService = catalogService ?? ScreenCaptureCatalogService()
self.screenCaptureSessions = captureMonitoringService.currentSessions
}

var displayCatalogState: ScreenCaptureDisplayCatalogState {
catalogService.store
}

func monitoringSession(for id: UUID) -> ScreenMonitoringSession? {
captureMonitoringService.monitoringSession(for: id)
}

func addMonitoringSession(_ session: ScreenMonitoringSession) {
mutateAndSync {
captureMonitoringService.addMonitoringSession(session)
func isStarting(displayID: CGDirectDisplayID) -> Bool {
startTracker.contains(displayID: displayID)
}

func startMonitoring(
display: SCDisplay,
metadata: CaptureMonitoringDisplayMetadata
) async throws -> DisplayStartOutcome<UUID> {
let displayID = display.displayID
let startToken = startTracker.begin(displayID: displayID)
syncCaptureMonitoringState()
defer {
startTracker.end(displayID: displayID, token: startToken)
syncCaptureMonitoringState()
}

return try await captureMonitoringLifecycleService.startMonitoring(
display: display,
metadata: metadata
)
}

func activateMonitoringSession(id: UUID) {
mutationRunner.run {
captureMonitoringLifecycleService.activateMonitoringSession(id: id)
}
}

func markMonitoringSessionActive(id: UUID) {
mutateAndSync {
captureMonitoringService.updateMonitoringSessionState(id: id, state: .active)
func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) {
mutationRunner.run {
captureMonitoringLifecycleService.attachPreviewSink(sink, to: id)
}
}

func setMonitoringSessionCapturesCursor(id: UUID, capturesCursor: Bool) {
mutateAndSync {
captureMonitoringService.updateMonitoringSessionCapturesCursor(
func setMonitoringSessionCapturesCursor(
id: UUID,
capturesCursor: Bool
) async throws {
try await mutationRunner.run {
try await captureMonitoringLifecycleService.setMonitoringSessionCapturesCursor(
id: id,
capturesCursor: capturesCursor
)
}
}

func removeMonitoringSession(id: UUID) {
mutateAndSync {
captureMonitoringService.removeMonitoringSession(id: id)
func closeMonitoringSession(id: UUID) {
mutationRunner.run {
captureMonitoringLifecycleService.closeMonitoringSession(id: id)
}
}

func removeMonitoringSessions(displayID: CGDirectDisplayID) {
mutateAndSync {
captureMonitoringService.removeMonitoringSessions(displayID: displayID)
startTracker.clear(displayID: displayID)
mutationRunner.run {
captureMonitoringLifecycleService.removeMonitoringSessions(displayID: displayID)
}
}

Expand All @@ -69,10 +113,16 @@ final class CaptureController {

private func syncCaptureMonitoringState() {
screenCaptureSessions = captureMonitoringService.currentSessions
startingDisplayIDs = startTracker.activeDisplayIDs
}

private func mutateAndSync(_ mutation: () -> Void) {
mutation()
#if DEBUG
func installStartingDisplayIDsForTesting(_ displayIDs: Set<CGDirectDisplayID>) {
startTracker.clearAll()
for displayID in displayIDs {
_ = startTracker.begin(displayID: displayID)
}
syncCaptureMonitoringState()
}
#endif
}
31 changes: 25 additions & 6 deletions VoidDisplay/App/CaptureDisplayWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,33 @@ import SwiftUI
struct CaptureDisplayWindowRoot: View {
@Environment(\.dismiss) private var dismiss
let sessionId: UUID?
@State private var hasSeenSessionID = false

var body: some View {
if let sessionId {
CaptureDisplayView(sessionId: sessionId)
.navigationTitle("Screen Monitoring")
} else {
Color.clear
.onAppear { dismiss() }
Group {
if let sessionId {
CaptureDisplayView(sessionId: sessionId)
.navigationTitle("Screen Monitoring")
} else {
Color.clear
}
}
.task(id: sessionId) {
if sessionId != nil {
hasSeenSessionID = true
return
}

// Value-based windows can briefly render before their payload is
// attached. Give SwiftUI one turn to supply the session ID.
if !hasSeenSessionID {
try? await Task.sleep(for: .milliseconds(150))
guard sessionId == nil, !hasSeenSessionID else { return }
dismiss()
return
}

dismiss()
}
}
}
42 changes: 42 additions & 0 deletions VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import CoreGraphics
import Foundation

@MainActor
final class DisplayStartTracker {
private var tokensByDisplayID: [CGDirectDisplayID: Set<UUID>] = [:]

var activeDisplayIDs: Set<CGDirectDisplayID> {
Set(tokensByDisplayID.keys)
}

func contains(displayID: CGDirectDisplayID) -> Bool {
tokensByDisplayID[displayID]?.isEmpty == false
}

@discardableResult
func begin(displayID: CGDirectDisplayID) -> UUID {
let token = UUID()
var tokens = tokensByDisplayID[displayID] ?? []
tokens.insert(token)
tokensByDisplayID[displayID] = tokens
return token
}

func end(displayID: CGDirectDisplayID, token: UUID) {
guard var tokens = tokensByDisplayID[displayID] else { return }
tokens.remove(token)
if tokens.isEmpty {
tokensByDisplayID.removeValue(forKey: displayID)
} else {
tokensByDisplayID[displayID] = tokens
}
}

func clear(displayID: CGDirectDisplayID) {
tokensByDisplayID.removeValue(forKey: displayID)
}

func clearAll() {
tokensByDisplayID.removeAll()
}
}
25 changes: 25 additions & 0 deletions VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

@MainActor
final class SnapshotMutationRunner {
private let sync: @MainActor () -> Void

init(sync: @escaping @MainActor () -> Void) {
self.sync = sync
}

func run(_ mutation: () -> Void) {
mutation()
sync()
}

func run<T>(_ mutation: () async -> T) async -> T {
defer { sync() }
return await mutation()
}

func run<T>(_ mutation: () async throws -> T) async rethrows -> T {
defer { sync() }
return try await mutation()
}
}
Loading