From 5b9057e8e1d7d676572e69b3c55376d159fb373d Mon Sep 17 00:00:00 2001 From: Lee JaeHyeon <87473022+JaeHyeon-KAIST@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:50:00 +0900 Subject: [PATCH 1/5] docs: refine portal layout fix plan after review --- docs/portal-bug-fix-plan.md | 225 ++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/portal-bug-fix-plan.md diff --git a/docs/portal-bug-fix-plan.md b/docs/portal-bug-fix-plan.md new file mode 100644 index 0000000..0f37ad7 --- /dev/null +++ b/docs/portal-bug-fix-plan.md @@ -0,0 +1,225 @@ +# 포털 사용에 영향을 주는 두 버그 수정 계획 + +- 상태: 검토 의견을 반영하여 구현 진행. 검증 결과는 문서 끝에 기록. +- 작성일: 2026-09-08 +- 검토 기준: `b88f507` 및 현재 작업 트리의 Swift 소스 +- 사용 환경: 맥북 내장 화면 + 가로 모니터 + 세로 모니터. 포털 위주로 사용하며 단축키와 창 복원은 사용하지 않음. + +## 1. 목표와 범위 + +다음 두 버그를 해결한다. + +1. 저장된 화면 배치를 정리한 후 이전 배열 인덱스를 사용하여 앱이 종료되거나 잘못된 배치를 반환하는 문제. +2. 실제 화면 배치가 바뀌었을 때 그 배치에 저장된 포털이 마우스 처리 서비스에 적용되지 않는 문제. + +완료 후에는 실제 화면 구성의 인식이 끝났을 때 **현재 배치 ID, 해당 배치의 저장된 포털, 실제 마우스 처리에 사용하는 포털이 일치**해야 한다. 메인 창을 닫고 메뉴 막대에서 실행 중이어도 같은 규칙을 적용한다. + +이번 작업에서 제외할 항목: + +- 사용자 단축키가 모니터 재연결 후 기본값으로 돌아가는 별도 문제. +- 포털 전체 켜짐/꺼짐 상태의 재실행 간 유지. +- 창 복원 기능의 개선 또는 삭제. +- 포털 이동 좌표 계산, 감지 허용 오차, 키를 누르는 방식 등 입력 알고리즘 변경. +- 배치 보존 기준 변경, 새로운 클라우드 동기화, 외부 패키지 도입. + +## 2. 버그 A: 정리 후 이전 인덱스 사용 + +### 원인과 발생 조건 + +`DisplayLayoutService.matchOrCreateLayout(for:preserving:)`의 기존 배치 매칭 경로는 다음 순서다. + +```swift +if let matchedIndex = layouts.firstIndex(where: { $0.matches(displays: displays) }) { + currentLayoutID = layouts[matchedIndex].id + pruneLayouts(preserving: displayLayoutSignatures) + save() + return layouts[matchedIndex] +} +``` + +목표 배치 앞의 항목이 정리되면 `matchedIndex`는 더 이상 목표 배치를 가리키지 않는다. + +| 정리 전 목록 | 정리 후 목록 | 현재 결과 | 수정 후 결과 | +| --- | --- | --- | --- | +| `[삭제 대상, 목표 배치]` | `[목표 배치]` | 인덱스 1 접근으로 종료 | 목표 배치 반환 | +| `[삭제 대상, 목표 배치, 보존 배치]` | `[목표 배치, 보존 배치]` | 보존 배치를 잘못 반환 | 목표 배치 반환 | + +삭제 대상은 현재 배치가 아니면서 잠금·포털·창 복원 기록이 모두 없는 배치다. 잠금 해제 외에도 마지막 포털 또는 마지막 창 복원 기록의 삭제가 조건을 만들 수 있다. 각 동작 직후 항상 발생한다는 뜻은 아니며, 이후 매칭 및 정리 경로에서 앞 항목이 실제로 삭제되어야 한다. + +현재 앱의 호출 경로는 `ContentView.onAppear`와 화면 변경 알림이다. 따라서 화면 연결·해제뿐 아니라 메인 화면이 나타날 때도 노출될 수 있다. + +`save()`가 잘못된 접근보다 먼저 호출되지만, 내부의 `UserDefaults` 저장 완료를 보장하는 것은 아니다. 재실행 복구를 수정 대신 사용하지 않는다. 잘못된 배치 반환 시 `currentLayoutID` 자체는 목표 배치 ID이며, 첫 화면 선택 등 반환값을 사용하는 부분이 잘못될 수 있다. 단축키 초기화에 넘기는 화면 변환은 현재 화면의 ID·좌표·순서를 유지하고 이름만 적용하므로, 이 문제로 단축키 대상 화면까지 바뀐다고 확대 해석하지 않는다. + +### 제안 수정 + +인덱스 대신 매칭한 `DisplayLayout` 값을 먼저 보관하고, 정리 후 고유 ID로 다시 찾는다. 새 배치를 생성하는 경로와 동일한 방식이며, 배열 위치에 의존하지 않는다. + +```swift +// 제안 코드이며 아직 적용하지 않음. +if let matchedLayout = layouts.first(where: { $0.matches(displays: displays) }) { + currentLayoutID = matchedLayout.id + pruneLayouts(preserving: displayLayoutSignatures) + save() + return layouts.first(where: { $0.id == matchedLayout.id }) ?? matchedLayout +} +``` + +- 현재 배치를 먼저 지정하여 삭제 대상에서 보호하는 순서를 유지한다. +- 정리와 저장을 유지한다. 정리를 제거하거나 배열 인덱스를 임의로 보정하지 않는다. +- 기존 배치·포털의 ID, 이름, 잠금 상태, 저장 형식은 유지한다. +- 새로운 배치를 생성하는 경로는 현재도 ID로 반환하므로 불필요하게 변경하지 않는다. + +## 3. 버그 B: 배치 전환 시 포털 미적용 + +### 원인과 재현 + +배치별 포털의 원본은 `DisplayLayout.portals`다. 실제 입력 처리는 `PortalService.portals`와 콜백용 상태를 사용한다. + +현재 `syncToPortalService()`는 포털 추가·수정·삭제·개별 활성화 변경 때 호출되지만, 화면 배치를 인식하고 전환하는 경로에서는 호출되지 않는다. `PortalService`의 화면 변경 처리는 화면 좌표 캐시만 갱신한다. + +재현 순서: + +1. 화면 3개 구성 A에서 포털 A를 설정한다. +2. 화면 2개 구성 B로 바꾸고 포털 B를 설정한다. +3. 구성 A로 돌아온다. +4. 현재 배치는 A이고 저장된 포털도 A인데, 실제 서비스의 포털은 B로 남는다. + +이는 실제 서비스 메서드에 모의 화면 데이터를 넣어 확인한 상태 불일치다. 물리적 모니터 연결·해제 및 커서 이동까지 재현한 것은 아니다. + +### 제안 수정: 앱 수명 동안 유지되는 공통 적용 경로 + +메인 화면에 동기화 호출만 추가하면 화면의 생명주기에 의존한다. 메뉴 막대에서 계속 쓰는 용도를 고려하여 화면 인식·배치 선택·포털 적용을 앱 수명 동안 유지되는 작은 조정 서비스로 모은다. + +제안 이름: `DisplayConfigurationCoordinator` (`@MainActor`). 이름은 구현 시 프로젝트 관례에 맞춰 조정 가능하다. + +역할은 다음으로 제한한다. + +1. 앱 시작과 `NSApplication.didChangeScreenParametersNotification`을 받는다. +2. 현재 활성 화면 목록을 조회한다. +3. 기존 배치 매칭·보존 로직으로 실제 현재 배치를 결정한다. +4. 해당 배치의 포털과 같은 화면 목록에서 만든 좌표 캐시를 `PortalService`에 적용한다. + +소유자는 `AppDelegate`로 두며, 알림 등록은 앱 실행 중 하나만 유지한다. 시작·종료는 중복 호출해도 안전해야 한다. `ContentView`는 배치를 표시하고 사용자가 조회할 배치를 선택하는 역할을 유지한다. + +### 적용 규칙 + +- 기존 배치로 돌아오면 그 배치의 포털을 적용한다. 이전 실행에서 로드한 전역 `portalPairs` 값을 계속 사용하지 않는다. +- 새 배치 또는 포털이 없는 배치는 빈 포털 목록을 적용한다. 이전 배치의 포털을 새 배치에 복사하지 않는다. +- 비어 있는 활성 화면 조회 결과로 배치를 만들거나 저장 목록을 정리하지 않는다. 일시적 조회 실패 시 마지막 정상 배치를 보존하고 다음 화면 변경 또는 명시적 새로고침에서 재확인한다. +- 화면 변경 알림이 연속되면 마지막 알림으로부터 0.5초 후 실제 화면을 다시 조회하는 순수 디바운스를 사용한다. `requiredStableCount`, `pendingSignature`, `stableCount`, `pendingDisplays`는 제거한다. 기존 3회 경로는 실행 가능하나 동일 알림 반복 횟수에 의존하므로 안정성의 근거로 사용하지 않는다. UI 측 타이머와 서비스 측 캐시 갱신 지연을 중복 운영하지 않는다. +- 포털 목록과 화면 좌표 캐시는 같은 화면 스냅샷으로 갱신한다. `PortalEventTapState`에 한 번의 잠금으로 두 상태를 반영하는 경로를 마련하여, 새 포털과 이전 좌표가 섞인 콜백 상태를 피한다. +- `PortalService.portals`의 기존 `didSet`도 콜백을 갱신하므로, 일괄 적용 중 중간 상태를 공개하지 않도록 내부 적용 메서드를 정리한다. 일반 포털 편집 때의 기존 갱신 동작은 유지한다. +- 포털 설정 적용 때문에 이벤트 감시를 중지·재생성하지 않는다. 현재 포털 서비스가 꺼져 있다면 설정만 갱신하고 임의로 켜지 않는다. +- 앱 시작 시 최초 화면 인식 및 포털 적용을 수행한 뒤 기존 조건에 따라 입력 감시를 시작한다. 권한이 없어 감시를 시작하지 못해도 배치 목록 표시는 가능해야 한다. +- 화면에서 다른 저장 배치를 클릭하는 것은 조회 동작이다. `selectedLayoutID` 변경만으로 실제 적용 포털을 바꾸지 않는다. 실제 현재 배치와 조회 중인 배치를 구분한다. +- 창 복원 기록이 있는 배치를 보호하기 위한 기존 `preserving` 정보는 공통 경로에도 전달한다. 창 복원 자체를 실행하거나 활성화하지 않는다. +- 화면 변경 처리가 UI에서 빠지더라도 기존 화면 목록 갱신과 단축키 초기화 호출이 누락되지 않도록 이동한다. 단축키 초기화 알고리즘은 수정하지 않는다. +- 설정 씬의 `PortalSettingsView`도 현재 배치의 포털을 표시하고 삭제·토글은 `DisplayLayoutService`로 전달한다. 복수 삭제 시 삭제 전 ID들을 확보하여 인덱스 이동 문제도 피한다. +- 호출되지 않는 `syncFromPortalService()`는 제거한다. 배치 원본에서 런타임으로 적용하는 방향을 유지한다. 현재 참조가 없는 `PortalEditorView`의 기능 개편은 제외한다. + +### 구현 범위를 작게 유지하는 방법 + +- 배치 모델과 매칭·정리 로직은 `DisplayLayoutService`에 남긴다. +- `DisplayConfigurationCoordinator`는 기존 서비스를 연결하고 알림·예약 실행을 소유하는 역할만 갖는다. +- 테스트용 화면 제공자와 적용 결과 수신자를 주입할 수 있도록 최소한의 생성자 또는 클로저를 제공한다. 전체 프로젝트의 싱글턴 구조를 개편하지 않는다. +- 공통 경로로 옮긴 UI 알림과 `PortalService`의 독립적인 화면 캐시 갱신은 제거하거나 공통 경로로 위임한다. 같은 알림으로 배치 선택·저장이 중복 수행되지 않게 한다. + +## 4. 예상 변경 파일 + +| 파일 | 변경 목적 | +| --- | --- | +| `MousePortal/Models/DisplayLayout.swift` | 버그 A 수정. 화면 안정화 예약의 소유권을 조정 서비스로 옮기는 데 필요한 최소 변경 | +| `MousePortal/Services/DisplayConfigurationCoordinator.swift` (신규) | 앱 시작·화면 변경 시 배치 인식과 포털 적용을 연결 | +| `MousePortal/Services/PortalService.swift` | 포털과 화면 캐시 일괄 적용. 중복 화면 알림 처리 정리 | +| `MousePortal/AppDelegate.swift` | 조정 서비스 보관 및 시작·종료 연결, 초기 설정 적용 순서 보장 | +| `MousePortal/ContentView.swift` | UI에 있던 배치 자동 인식 책임 이동, 현재 배치 관찰 및 기존 표시 동작 유지 | +| `MousePortal/Views/SettingsView.swift` | 포털 삭제·토글의 원본을 현재 배치로 통일 | +| `MousePortalTests/Models/DisplayLayoutTests.swift` | 두 가지 인덱스 실패 모드 회귀 테스트 | +| `MousePortalTests/Services/DisplayConfigurationCoordinatorTests.swift` (신규) | 배치 전환·포털 적용·알림 생명주기 검증 | +| `MousePortalTests/Services/PortalServiceTests.swift` (필요 시 신규) | 화면 캐시와 콜백 포털 일관성을 이벤트 감시 없이 검증 | + +관련 테스트나 목에 대한 최소 변경은 포함한다. 전체 클래스에 일괄적으로 `@MainActor`를 추가하는 등 광범위한 동시성 변경은 피하고, 새 조정 및 적용 경로의 격리를 명시한다. + +## 5. 검증 계획 + +### 자동 테스트 + +| 구분 | 시나리오 | 기대 결과 | +| --- | --- | --- | +| A-1 | `[삭제 대상, 목표 배치]`에서 목표 매칭 | 정상 반환, 반환 ID와 현재 배치 ID가 목표와 일치 | +| A-2 | `[삭제 대상, 목표 배치, 잠긴 다른 배치]`에서 목표 매칭 | 다른 배치가 아닌 목표 반환, 잠긴 배치 보존 | +| A-3 | 목표보다 앞에 여러 삭제 대상 존재 | 삭제 개수와 관계없이 동일한 목표 반환 | +| A-4 | 잠긴 배치·포털 있는 배치·창 기록 있는 배치 존재 | 기존 보호 정책 유지 | +| A-5 | 삭제 대상 없음 또는 신규 배치 생성 | 기존 정상 동작 유지 | +| B-1 | 포털 A가 있는 배치 A → 포털 B가 있는 B → A | 각 단계에서 현재 배치와 런타임 포털 ID가 일치 | +| B-2 | 포털 있는 A → 포털 없는 B → A | B에서 런타임 포털은 빈 목록, A 복귀 시 A 복원 | +| B-3 | 저장된 전역 포털은 B, 앱 시작 시 실제 화면은 A | 입력 감시 시작 전 A 적용 | +| B-4 | 메인 화면을 만들지 않고 화면 변경 알림 전달 | 조정 서비스만으로 배치와 포털 갱신 | +| B-5 | 여러 화면 변경 알림이 짧게 연속 발생 | 마지막 실제 화면 구성을 적용, 중복 관찰·예약 누적 없음 | +| B-6 | 화면 조회가 일시적으로 빈 목록 반환 | 기존 저장 배치 보존, 이후 유효 조회에서 정상 적용 | +| B-7 | 실제 배치는 A인 상태에서 UI로 B 조회 | 실제 적용 포털은 A 유지 | +| B-8 | 포털 감시가 꺼진 상태에서 화면 변경 | 설정은 갱신되지만 감시는 꺼진 상태 유지 | +| B-9 | 조정 서비스 시작·종료·재시작 및 해제 | 중복 알림 처리 없음, 종료 후 예약 작업 미실행. 예약 작업은 UI 클로저를 받거나 보관하지 않음 | +| B-10 | 포털 편집 및 일괄 화면 전환 | 포털 편집 반영 유지, 콜백에서 포털·화면 캐시의 일관성 유지 | +| B-11 | 설정 씬의 삭제·토글 경로 실행 후 배치 전환 및 저장소 재로딩 | 삭제한 포털이 되살아나지 않고 비활성화가 유지됨. 복수 삭제도 올바른 ID만 삭제 | + +배치 A와 B는 각기 다른 실제 화면 좌표 조합으로 구성한다. 포털 이름만 비교하지 않고 ID와 선분의 화면 식별 정보를 확인한다. 보존이 필요한 배치는 포털 또는 잠금을 설정하여 테스트 중 의도치 않게 정리되지 않도록 한다. + +테스트는 실제 손쉬운 사용 권한, 전역 입력 감시 또는 마우스 이동을 요구하지 않도록 구성한다. 저장 검증은 별도 `UserDefaults` suite를 사용한다. 기존 싱글턴을 사용하는 테스트가 필요한 경우 변경하는 모든 설정 키와 런타임 상태를 복원한다. 크래시 재현 테스트는 수정 전 실행 시 테스트 프로세스를 종료시킬 수 있으므로, 실패 재현은 별도 실행으로 분리하고 수정 후 전체 테스트에 포함한다. + +### 실제 사용 확인 + +구현 이후 자동 테스트와 별도로 다음을 확인한다. + +1. 맥북 + 가로 + 세로 화면에서 포털을 설정하고 커서 이동이 정상인지 확인. +2. 모니터 하나를 분리한 구성에서도 별도 포털을 설정한 후 원래 구성으로 복귀하여 원래 포털이 적용되는지 확인. +3. 메인 창을 닫고 같은 전환을 반복하여 메뉴 막대 실행 중에도 적용되는지 확인. +4. 앱을 종료한 뒤 다른 저장된 화면 구성에서 재실행하여 처음부터 올바른 포털을 사용하는지 확인. +5. 포털을 꺼 둔 실행 세션에서 연결·해제가 포털 감시를 다시 켜지 않는지 확인. + +물리적 모니터 조작이 필요한 항목은 자동 테스트 통과만으로 완료 처리하지 않는다. 직접 확인하지 못한 항목은 구현 결과에 미확인으로 기록한다. + +### 빌드 및 리소스 확인 + +- `swift test` +- `swift build -c release` +- 새 상시 반복 타이머가 없는지, 화면 변경 후 예약 작업이 종료되는지 확인. +- 단순 마우스 이동 경로에 화면 조회, 배치 매칭, 디스크 저장이 추가되지 않았는지 확인. +- 이벤트 감시가 화면 전환마다 중복 생성되지 않는지 확인. + +이전 검토에서는 기존 테스트 165개와 Release 빌드가 통과했다. 이는 수정 후 검증 결과가 아니며, CPU·메모리의 실제 측정치도 아니다. + +## 6. 호환성과 주의점 + +- 배치 저장 JSON과 `portalPairs` 저장 키는 유지한다. 현재 배치의 포털을 적용할 때 기존 전역 저장 값도 같은 포털로 갱신하여 재실행 상태를 맞춘다. +- 최초 공개 소스 커밋부터 배치 모델이 있으므로 가상의 전역 전용 구버전을 위한 자동 마이그레이션은 추가하지 않는다. 다만 이력만으로 모든 사용자 데이터가 일관적이라고 단정하지 않는다. 기존 설정 씬의 직접 수정도 불일치를 만들 수 있다. 최초 배치 적용 전에 원래 `portalPairs` 데이터를 별도 로컬 키에 한 번 보관하고, 이후 덮어쓰지 않는다. 이를 현재 배치에 역으로 복사하거나 자동 병합하지 않는다. +- 화면을 모두 다시 확인하기까지의 안정화 대기 자체를 없애지는 않는다. 연결 순간부터 지연이 전혀 없다는 보장은 목표가 아니다. +- 배치 선택의 의미를 바꾸지 않는다. 사용자가 과거 배치를 조회하는 행위와 실제 화면 구성의 변경을 혼동하지 않는다. +- 기존 `.omc/` 등 작업 트리의 다른 파일은 이번 작업 대상이 아니다. + +## 7. 구현 순서와 완료 기준 + +1. 버그 A의 두 실패 모드 회귀 테스트를 추가하고, 값 기반 반환으로 수정한다. +2. 앱 수명 동안 동작하는 화면 구성 조정 경로와 테스트 연결 지점을 만든다. +3. 시작·화면 변경 시 배치 포털 및 화면 캐시의 일괄 적용을 연결하고 UI의 중복 책임을 정리한다. +4. B-1부터 B-10까지 검증하고 기존 전체 테스트와 Release 빌드를 실행한다. +5. 가능한 실제 화면 연결 시나리오를 확인하고 미확인 항목을 명시한다. + +완료 기준: + +- 버그 A의 크래시 경로와 잘못된 배치 반환 경로가 모두 제거됨. +- 유효한 화면 배치를 인식한 후 저장된 현재 배치의 포털과 런타임 포털이 일치함. +- 메인 창의 존재 여부가 포털 배치 적용의 전제 조건이 아님. +- 기존 포털 편집, 배치 보존 기준, 감시 켜짐/꺼짐 동작에 회귀가 없음. +- 신규 외부 의존성·통신·상시 폴링이 없음. + +## 8. 검토 의견에 대한 판단 및 작업 단위 + +- P1: 수용. 설정 씬의 두 번째 작성자를 수정하지 않으면 설정이 되살아나는 회귀가 발생한다. +- P2: 수용. 미사용 역방향 동기화 함수를 제거한다. +- P3: 단순화 수용. 3회 경로가 실행 불가능하다는 판단은 하지 않는다. 순수 디바운스로 바꾸고 관련 테스트도 목적 중심으로 대체한다. +- P4: 생명주기 방어 수용. 창 닫힘과 뷰 즉시 소멸을 동일시하거나 메모리 안전성 문제로 단정하지 않는다. 예약 작업에서 UI completion 자체를 없앤다. +- P5: 조사 범위 축소는 수용하나 전역 전용 또는 불일치 데이터의 부재는 확정하지 않는다. 로컬 복구 사본으로 기존 데이터를 보존한다. +- A의 반환 방식은 ID 재조회로 통일하고, 크래시 회귀 테스트에는 불필요한 목록 길이 단언을 넣지 않는다. + +예정 커밋: (1) 검토 반영 계획서, (2) 버그 A 및 회귀 테스트, (3) 버그 B·설정 씬 일관성·디바운스 및 회귀 테스트. 추가 수정이 필요하면 독립된 후속 커밋으로 기록한다. 사용자 요청에 따라 로컬 커밋까지 수행하며 원격 push나 PR 생성은 이 작업의 완료 조건이 아니다. From bd2a89c6828fae887d6a92d3983e1bda158cd682 Mon Sep 17 00:00:00 2001 From: Lee JaeHyeon <87473022+JaeHyeon-KAIST@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:51:42 +0900 Subject: [PATCH 2/5] fix: preserve matched layout identity after pruning --- MousePortal/Models/DisplayLayout.swift | 6 +-- .../Models/DisplayLayoutTests.swift | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/MousePortal/Models/DisplayLayout.swift b/MousePortal/Models/DisplayLayout.swift index 6a9a858..b822571 100644 --- a/MousePortal/Models/DisplayLayout.swift +++ b/MousePortal/Models/DisplayLayout.swift @@ -457,11 +457,11 @@ class DisplayLayoutService: ObservableObject { preserving displayLayoutSignatures: Set = [] ) -> DisplayLayout { // 尝试匹配现有配置 - if let matchedIndex = layouts.firstIndex(where: { $0.matches(displays: displays) }) { - currentLayoutID = layouts[matchedIndex].id + if let matchedLayout = layouts.first(where: { $0.matches(displays: displays) }) { + currentLayoutID = matchedLayout.id pruneLayouts(preserving: displayLayoutSignatures) save() - return layouts[matchedIndex] + return layouts.first(where: { $0.id == matchedLayout.id }) ?? matchedLayout } // 创建新配置 diff --git a/MousePortalTests/Models/DisplayLayoutTests.swift b/MousePortalTests/Models/DisplayLayoutTests.swift index 076066a..19e2262 100644 --- a/MousePortalTests/Models/DisplayLayoutTests.swift +++ b/MousePortalTests/Models/DisplayLayoutTests.swift @@ -327,6 +327,47 @@ final class DisplayLayoutTests: XCTestCase { XCTAssertEqual(service.currentLayoutID, existing.id) } + func testMatchReturnsTargetAfterPruningEarlierLayout() { + let service = DisplayLayoutService.shared + let unused = DisplayLayout(name: "Unused", displays: sampleSingleDisplay()) + let target = DisplayLayout(name: "Target", displays: sampleDualDisplays()) + service.layouts = [unused, target] + service.currentLayoutID = unused.id + + let matched = service.matchOrCreateLayout(for: sampleDualDisplays()) + + XCTAssertEqual(matched.id, target.id) + XCTAssertEqual(service.currentLayoutID, target.id) + } + + func testMatchDoesNotReturnFollowingLayoutAfterPruning() { + let service = DisplayLayoutService.shared + let unused = DisplayLayout(name: "Unused", displays: sampleSingleDisplay()) + let target = DisplayLayout(name: "Target", displays: sampleDualDisplays()) + var other = DisplayLayout(name: "Other locked", displays: sampleSingleDisplay()) + other.isLocked = true + service.layouts = [unused, target, other] + service.currentLayoutID = unused.id + + let matched = service.matchOrCreateLayout(for: sampleDualDisplays()) + + XCTAssertEqual(matched.id, target.id) + XCTAssertEqual(service.currentLayoutID, target.id) + } + + func testMatchReturnsTargetAfterPruningMultipleEarlierLayouts() { + let service = DisplayLayoutService.shared + let target = DisplayLayout(name: "Target", displays: sampleDualDisplays()) + service.layouts = [ + DisplayLayout(name: "Unused A", displays: sampleSingleDisplay()), + DisplayLayout(name: "Unused B", displays: sampleSingleDisplay()), + target + ] + service.currentLayoutID = service.layouts[0].id + + XCTAssertEqual(service.matchOrCreateLayout(for: sampleDualDisplays()).id, target.id) + } + func testRenameLayoutUpdatesNameAndModifiedDate() { let service = DisplayLayoutService.shared let layout = DisplayLayout(name: "Before", displays: sampleSingleDisplay()) From 2aa123e94bf8df32476101e85f6cdefbe7249e43 Mon Sep 17 00:00:00 2001 From: Lee JaeHyeon <87473022+JaeHyeon-KAIST@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:01:08 +0900 Subject: [PATCH 3/5] fix: apply saved portals across display configuration changes --- MousePortal/AppDelegate.swift | 22 + MousePortal/ContentView.swift | 32 +- MousePortal/Models/DisplayLayout.swift | 149 +++---- .../DisplayConfigurationCoordinator.swift | 107 +++++ MousePortal/Services/DisplayService.swift | 9 +- MousePortal/Services/PortalService.swift | 102 ++--- MousePortal/Views/SettingsView.swift | 19 +- .../Models/DisplayLayoutTests.swift | 82 ---- ...DisplayConfigurationCoordinatorTests.swift | 388 ++++++++++++++++++ docs/portal-bug-fix-plan.md | 26 +- 10 files changed, 658 insertions(+), 278 deletions(-) create mode 100644 MousePortal/Services/DisplayConfigurationCoordinator.swift create mode 100644 MousePortalTests/Services/DisplayConfigurationCoordinatorTests.swift diff --git a/MousePortal/AppDelegate.swift b/MousePortal/AppDelegate.swift index 66cf26d..6f02624 100644 --- a/MousePortal/AppDelegate.swift +++ b/MousePortal/AppDelegate.swift @@ -15,6 +15,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let windowLayoutService = WindowLayoutService.shared private var hotkeyConfigStore = HotkeyConfigStore.shared private var cancellables: Set = [] + private lazy var displayCoordinator = DisplayConfigurationCoordinator( + provider: SystemDisplayProvider(), + layouts: .shared, + portals: .shared, + displayService: .shared, + preservedSignatures: { Set(WindowLayoutService.shared.snapshots.map(\.displayLayoutSignature)) }, + updateHotkeys: { HotkeyConfigStore.shared.initializeDefaults(from: $0) } + ) // 应用状态 @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true @@ -82,6 +90,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return false } + func applicationWillTerminate(_ notification: Notification) { + displayCoordinator.stop() + hotkeyService?.stop() + portalService?.stop() + windowLayoutService.stopMonitoring() + } + + func refreshDisplayConfiguration() { + displayCoordinator.refresh() + } + // MARK: - Menu Bar Setup private func setupMenuBarItem() { @@ -169,6 +188,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func setupServices() { hotkeyService = HotkeyService.shared portalService = PortalService.shared + // Resolve the active layout before accepting any input events. + displayCoordinator.start() if hotkeyConfigStore.globalEnabled { hotkeyService?.start() @@ -274,6 +295,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @objc private func quitApp() { + displayCoordinator.stop() hotkeyService?.stop() portalService?.stop() windowLayoutService.stopMonitoring() diff --git a/MousePortal/ContentView.swift b/MousePortal/ContentView.swift index f07e43b..cd977c5 100644 --- a/MousePortal/ContentView.swift +++ b/MousePortal/ContentView.swift @@ -264,7 +264,7 @@ struct ContentView: View { .buttonStyle(.bordered) } - Button(action: { displayService.fetchDisplays() }) { + Button(action: { AppDelegate.shared?.refreshDisplayConfiguration() }) { Image(systemName: "arrow.clockwise") } .help(L("button.refresh")) @@ -393,35 +393,17 @@ struct ContentView: View { .background(HomeSurface.background) .frame(minWidth: 1000, minHeight: 680) .onAppear { - displayService.fetchDisplays() - // 只在首次加载时设置 selectedLayoutID + AppDelegate.shared?.refreshDisplayConfiguration() if selectedLayoutID == nil { - let layout = layoutService.matchOrCreateLayout( - for: displayService.displays, - preserving: savedWindowLayoutSignatures - ) - selectedLayoutID = layout.id - hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) - } else { - // 确保 currentLayoutID 正确更新 - let layout = layoutService.matchOrCreateLayout( - for: displayService.displays, - preserving: savedWindowLayoutSignatures - ) - hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) + selectedLayoutID = layoutService.currentLayoutID } checkPermissionOnLaunch() synchronizeWindowSnapshotSelection() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in - displayService.fetchDisplays() - hotkeyConfigStore.initializeDefaults(from: displayService.displays) - // 使用稳定性检测,避免唤醒时产生大量中间状态的排列 - layoutService.handleDisplayChange( - displays: displayService.displays, - preserving: savedWindowLayoutSignatures - ) { layout in - hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) + .onReceive(layoutService.$currentLayoutID.receive(on: RunLoop.main)) { currentID in + // Preserve explicit browsing of saved layouts, unless that layout was pruned. + if selectedLayoutID == nil || !layoutService.layouts.contains(where: { $0.id == selectedLayoutID }) { + selectedLayoutID = currentID } } .onReceive(NotificationCenter.default.publisher(for: .languageDidChange)) { _ in diff --git a/MousePortal/Models/DisplayLayout.swift b/MousePortal/Models/DisplayLayout.swift index b822571..edcb943 100644 --- a/MousePortal/Models/DisplayLayout.swift +++ b/MousePortal/Models/DisplayLayout.swift @@ -291,40 +291,30 @@ class DisplayLayoutService: ObservableObject { private let storageKey = "displayLayouts" private let currentLayoutKey = "currentLayoutID" - - // MARK: - 稳定性检测 - - /// 稳定性检测所需的连续匹配次数 - private let requiredStableCount = 3 - - /// 稳定性检测的时间间隔(秒) - private let stabilityCheckInterval: TimeInterval = 0.5 - - /// 当前检测到的显示器签名 - private var pendingSignature: String? - - /// 连续检测到相同签名的次数 - private var stableCount = 0 - - /// 稳定性检测定时器 - private var stabilityTimer: Timer? - - /// 待处理的显示器列表 - private var pendingDisplays: [DisplayInfo] = [] - - private init() { + private let defaults: UserDefaults + private let applyPortals: @MainActor ([PortalPair]) -> Void + + init( + defaults: UserDefaults = .standard, + applyPortals: @escaping @MainActor ([PortalPair]) -> Void = { portals in + PortalService.shared.portals = portals + PortalService.shared.save() + } + ) { + self.defaults = defaults + self.applyPortals = applyPortals load() } // MARK: - 存储 func load() { - if let data = UserDefaults.standard.data(forKey: storageKey), + if let data = defaults.data(forKey: storageKey), let layouts = try? JSONDecoder().decode([DisplayLayout].self, from: data) { self.layouts = layouts } - if let idString = UserDefaults.standard.string(forKey: currentLayoutKey), + if let idString = defaults.string(forKey: currentLayoutKey), let id = UUID(uuidString: idString) { currentLayoutID = id } @@ -332,13 +322,13 @@ class DisplayLayoutService: ObservableObject { func save() { if let data = try? JSONEncoder().encode(layouts) { - UserDefaults.standard.set(data, forKey: storageKey) + defaults.set(data, forKey: storageKey) } if let id = currentLayoutID { - UserDefaults.standard.set(id.uuidString, forKey: currentLayoutKey) + defaults.set(id.uuidString, forKey: currentLayoutKey) } else { - UserDefaults.standard.removeObject(forKey: currentLayoutKey) + defaults.removeObject(forKey: currentLayoutKey) } } @@ -393,64 +383,6 @@ class DisplayLayoutService: ObservableObject { return sorted.map { "\($0.width)x\($0.height)@\($0.x),\($0.y)\($0.isMain ? "M" : "")" }.joined(separator: "|") } - /// 处理显示器变化(带稳定性检测) - /// - Parameter displays: 当前检测到的显示器列表 - /// - Parameter completion: 当排列稳定后的回调,返回匹配或创建的 Layout - func handleDisplayChange( - displays: [DisplayInfo], - preserving displayLayoutSignatures: Set = [], - completion: ((DisplayLayout) -> Void)? = nil - ) { - let newSignature = generateSignature(for: displays) - - // 如果签名与上次相同,增加稳定计数 - if newSignature == pendingSignature { - stableCount += 1 - } else { - // 签名变化,重置计数 - pendingSignature = newSignature - pendingDisplays = displays - stableCount = 1 - } - - // 取消之前的定时器 - stabilityTimer?.invalidate() - - // 检查是否达到稳定阈值 - if stableCount >= requiredStableCount { - // 已稳定,执行匹配或创建 - let layout = matchOrCreateLayout( - for: displays, - preserving: displayLayoutSignatures - ) - resetStabilityState() - completion?(layout) - } else { - // 未稳定,设置定时器继续检测 - stabilityTimer = Timer.scheduledTimer(withTimeInterval: stabilityCheckInterval, repeats: false) { [weak self] _ in - guard let self = self else { return } - // 超时后,如果有待处理的显示器,强制处理 - if !self.pendingDisplays.isEmpty { - let layout = self.matchOrCreateLayout( - for: self.pendingDisplays, - preserving: displayLayoutSignatures - ) - self.resetStabilityState() - completion?(layout) - } - } - } - } - - /// 重置稳定性检测状态 - private func resetStabilityState() { - pendingSignature = nil - stableCount = 0 - pendingDisplays = [] - stabilityTimer?.invalidate() - stabilityTimer = nil - } - /// 根据当前显示器自动匹配或创建配置(内部方法,不带稳定性检测) func matchOrCreateLayout( for displays: [DisplayInfo], @@ -458,6 +390,7 @@ class DisplayLayoutService: ObservableObject { ) -> DisplayLayout { // 尝试匹配现有配置 if let matchedLayout = layouts.first(where: { $0.matches(displays: displays) }) { + migrateLegacyPortalLines(in: matchedLayout.id) currentLayoutID = matchedLayout.id pruneLayouts(preserving: displayLayoutSignatures) save() @@ -478,6 +411,30 @@ class DisplayLayoutService: ObservableObject { return layouts.first(where: { $0.id == layout.id }) ?? layout } + /// Saved display IDs identify the original geometry even after macOS assigns new IDs. + private func migrateLegacyPortalLines(in layoutID: UUID) { + guard let index = layouts.firstIndex(where: { $0.id == layoutID }) else { return } + let snapshots = layouts[index].displaySnapshots + func migrate(_ line: PortalLine) -> PortalLine { + guard line.displayLayoutKey == nil, + let legacyID = line.legacyDisplayID, + let snapshot = snapshots.first(where: { $0.displayID == legacyID }) else { return line } + var migrated = line + migrated.displayLayoutKey = snapshot.layoutKey + migrated.legacyDisplayID = nil + return migrated + } + let migrated = layouts[index].portals.map { portal in + var portal = portal + portal.lineA = migrate(portal.lineA) + portal.lineB = migrate(portal.lineB) + return portal + } + if migrated != layouts[index].portals { + layouts[index].portals = migrated + } + } + /// 生成排列名称 private func generateLayoutName(for displays: [DisplayInfo]) -> String { DisplayLayout.defaultLayoutName(displayCount: displays.count) @@ -579,10 +536,16 @@ class DisplayLayoutService: ObservableObject { /// 删除传送门 @MainActor func removePortal(id portalID: UUID) { + removePortals(ids: [portalID]) + } + + /// Resolve list selections to IDs before mutating the array. + @MainActor + func removePortals(ids portalIDs: Set) { guard let layoutID = currentLayoutID, let layoutIndex = layouts.firstIndex(where: { $0.id == layoutID }) else { return } - layouts[layoutIndex].portals.removeAll { $0.id == portalID } + layouts[layoutIndex].portals.removeAll { portalIDs.contains($0.id) } layouts[layoutIndex].modifiedAt = Date() save() @@ -611,18 +574,6 @@ class DisplayLayoutService: ObservableObject { /// 同步当前排列的传送门到 PortalService @MainActor func syncToPortalService() { - PortalService.shared.portals = currentPortals - PortalService.shared.save() - } - - /// 从 PortalService 同步传送门到当前排列 - @MainActor - func syncFromPortalService() { - guard let id = currentLayoutID, - let index = layouts.firstIndex(where: { $0.id == id }) else { return } - - layouts[index].portals = PortalService.shared.portals - layouts[index].modifiedAt = Date() - save() + applyPortals(currentPortals) } } diff --git a/MousePortal/Services/DisplayConfigurationCoordinator.swift b/MousePortal/Services/DisplayConfigurationCoordinator.swift new file mode 100644 index 0000000..ddec70b --- /dev/null +++ b/MousePortal/Services/DisplayConfigurationCoordinator.swift @@ -0,0 +1,107 @@ +import AppKit + +@MainActor +protocol DisplayChangeScheduling { + /// Returns a cancellation action. Callbacks may already be queued when cancelled. + func schedule(after delay: TimeInterval, action: @escaping @MainActor () -> Void) -> () -> Void +} + +@MainActor +private final class DisplayChangeScheduler: DisplayChangeScheduling { + func schedule(after delay: TimeInterval, action: @escaping @MainActor () -> Void) -> () -> Void { + let timer = Timer(timeInterval: delay, repeats: false) { _ in + Task { @MainActor in action() } + } + RunLoop.main.add(timer, forMode: .common) + return { timer.invalidate() } + } +} + +/// Owns display changes for the lifetime of the app, including when no window is open. +@MainActor +final class DisplayConfigurationCoordinator: NSObject { + private let provider: DisplayProviding + private let layouts: DisplayLayoutService + private let portals: PortalService + private let displayService: DisplayService + private let preservedSignatures: () -> Set + private let updateHotkeys: ([DisplayInfo]) -> Void + private let notificationCenter: NotificationCenter + private let scheduler: DisplayChangeScheduling + private var cancelPendingChange: (() -> Void)? + private var generation = 0 + private var isMonitoring = false + + init( + provider: DisplayProviding, + layouts: DisplayLayoutService, + portals: PortalService, + displayService: DisplayService, + preservedSignatures: @escaping () -> Set, + updateHotkeys: @escaping ([DisplayInfo]) -> Void, + notificationCenter: NotificationCenter = .default, + scheduler: DisplayChangeScheduling? = nil + ) { + self.provider = provider + self.layouts = layouts + self.portals = portals + self.displayService = displayService + self.preservedSignatures = preservedSignatures + self.updateHotkeys = updateHotkeys + self.notificationCenter = notificationCenter + self.scheduler = scheduler ?? DisplayChangeScheduler() + super.init() + } + + deinit { + cancelPendingChange?() + notificationCenter.removeObserver(self) + } + + func start() { + guard !isMonitoring else { return } + isMonitoring = true + notificationCenter.addObserver( + self, + selector: #selector(screenParametersDidChange), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + refresh() + } + + func stop() { + isMonitoring = false + cancelScheduledChange() + notificationCenter.removeObserver(self) + } + + /// Also used when reopening the main window after an empty/failed system query. + func refresh() { + guard isMonitoring else { return } + cancelScheduledChange() + let displays = provider.getActiveDisplays() + guard !displays.isEmpty else { return } + + let layout = layouts.matchOrCreateLayout(for: displays, preserving: preservedSignatures()) + portals.applyDisplayConfiguration(portals: layout.portals, displays: displays) + displayService.updateDisplays(displays) + updateHotkeys(layout.displaysApplyingCustomNames(to: displays)) + } + + @objc private func screenParametersDidChange() { + guard isMonitoring else { return } + cancelScheduledChange() + let scheduledGeneration = generation + cancelPendingChange = scheduler.schedule(after: 0.5) { [weak self] in + guard let self, self.isMonitoring, self.generation == scheduledGeneration else { return } + self.refresh() + } + } + + private func cancelScheduledChange() { + generation += 1 + cancelPendingChange?() + cancelPendingChange = nil + } +} diff --git a/MousePortal/Services/DisplayService.swift b/MousePortal/Services/DisplayService.swift index 34a9378..a92f6d6 100644 --- a/MousePortal/Services/DisplayService.swift +++ b/MousePortal/Services/DisplayService.swift @@ -21,7 +21,7 @@ class DisplayService: ObservableObject { // 先计算外接显示器的编号(跳过主显示器) var externalIndex = 0 - displays = (0.. [CGDirectDisplayID] { @@ -369,7 +353,7 @@ class PortalService: ObservableObject { callbackState.updateKeyPressed(with: event.flags.rawValue) } - nonisolated fileprivate func handleMouseMovedForTap(_ event: CGEvent) -> (target: CGPoint, deltaX: Double, deltaY: Double)? { + nonisolated func handleMouseMovedForTap(_ event: CGEvent) -> (target: CGPoint, deltaX: Double, deltaY: Double)? { let snapshot = callbackState.snapshot() if snapshot.triggerMode == .withKey && !snapshot.isKeyPressed { diff --git a/MousePortal/Views/SettingsView.swift b/MousePortal/Views/SettingsView.swift index 19c9b4e..905bd8d 100644 --- a/MousePortal/Views/SettingsView.swift +++ b/MousePortal/Views/SettingsView.swift @@ -210,7 +210,6 @@ struct GeneralSettingsView: View { /// 快捷键设置 struct HotkeySettingsView: View { @ObservedObject private var hotkeyConfigStore = HotkeyConfigStore.shared - @ObservedObject private var displayService = DisplayService.shared var body: some View { Form { @@ -271,8 +270,7 @@ struct HotkeySettingsView: View { } .formStyle(.grouped) .onAppear { - displayService.fetchDisplays() - hotkeyConfigStore.initializeDefaults(from: displayService.displays) + AppDelegate.shared?.refreshDisplayConfiguration() } } } @@ -280,6 +278,7 @@ struct HotkeySettingsView: View { /// 传送门设置 struct PortalSettingsView: View { @ObservedObject private var portalService = PortalService.shared + @ObservedObject private var layoutService = DisplayLayoutService.shared var body: some View { Form { @@ -323,17 +322,17 @@ struct PortalSettingsView: View { } Section { - if portalService.portals.isEmpty { + if layoutService.currentPortals.isEmpty { Text(L("settings.no_portals")) .foregroundColor(.secondary) } else { - ForEach(portalService.portals) { portal in + ForEach(layoutService.currentPortals) { portal in PortalRowView(portal: portal) } .onDelete { indexSet in - for index in indexSet { - portalService.removePortal(id: portalService.portals[index].id) - } + let portals = layoutService.currentPortals + let ids = Set(indexSet.compactMap { portals.indices.contains($0) ? portals[$0].id : nil }) + layoutService.removePortals(ids: ids) } } } header: { @@ -353,7 +352,7 @@ struct PortalSettingsView: View { /// 传送门行视图 struct PortalRowView: View { let portal: PortalPair - @ObservedObject private var portalService = PortalService.shared + @ObservedObject private var layoutService = DisplayLayoutService.shared var body: some View { HStack { @@ -373,7 +372,7 @@ struct PortalRowView: View { Toggle("", isOn: Binding( get: { portal.isEnabled }, - set: { _ in portalService.togglePortal(id: portal.id) } + set: { _ in layoutService.togglePortal(id: portal.id) } )) .toggleStyle(.switch) .labelsHidden() diff --git a/MousePortalTests/Models/DisplayLayoutTests.swift b/MousePortalTests/Models/DisplayLayoutTests.swift index 19e2262..f339045 100644 --- a/MousePortalTests/Models/DisplayLayoutTests.swift +++ b/MousePortalTests/Models/DisplayLayoutTests.swift @@ -643,88 +643,6 @@ final class DisplayLayoutTests: XCTestCase { XCTAssertEqual(layout.displayName, "Desk Setup") } - // MARK: - Stability Detection Tests - - func testStabilityDetectionSameSignatureMultipleTimes() { - let service = DisplayLayoutService.shared - - let displays = [ - DisplayInfo(id: 99, frame: CGRect(x: 0, y: 0, width: 3840, height: 2160), isMain: true, name: "Test 4K") - ] - - // 模拟连续3次相同的显示器变化 - let expectation = XCTestExpectation(description: "Stability callback") - - // 第一次调用 - service.handleDisplayChange(displays: displays) { _ in } - - // 第二次调用 - service.handleDisplayChange(displays: displays) { _ in } - - // 第三次调用 - 应该触发稳定 - service.handleDisplayChange(displays: displays) { layout in - XCTAssertEqual(layout.displaySnapshots.count, 1) - XCTAssertEqual(layout.displaySnapshots.first?.width, 3840) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 2.0) - - // 清理测试数据 - if let testLayout = service.layouts.first(where: { $0.displaySnapshots.first?.width == 3840 }) { - service.deleteLayout(testLayout) - } - } - - func testStabilityDetectionResetsOnDifferentSignature() { - let service = DisplayLayoutService.shared - - let singleDisplay = [ - DisplayInfo(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), isMain: true, name: "Main") - ] - - let dualDisplay = [ - DisplayInfo(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), isMain: true, name: "Main"), - DisplayInfo(id: 2, frame: CGRect(x: 1920, y: 0, width: 1920, height: 1080), isMain: false, name: "External") - ] - - var callbackCount = 0 - - // 模拟唤醒过程:单显示器 -> 单显示器 -> 双显示器(签名变化,重置计数) - service.handleDisplayChange(displays: singleDisplay) { _ in callbackCount += 1 } - service.handleDisplayChange(displays: singleDisplay) { _ in callbackCount += 1 } - service.handleDisplayChange(displays: dualDisplay) { _ in callbackCount += 1 } // 签名变化 - - // 此时不应该触发回调,因为双显示器签名只出现了1次 - XCTAssertEqual(callbackCount, 0, "Callback should not be triggered when signature changes") - } - - func testStabilityDetectionPreventsIntermediateLayouts() { - let service = DisplayLayoutService.shared - let initialCount = service.layouts.count - - // 模拟唤醒过程:1屏 -> 1屏 -> 2屏 -> 2屏 -> 2屏(稳定) - let oneDisplay = [ - DisplayInfo(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), isMain: true, name: "Main") - ] - - let twoDisplays = [ - DisplayInfo(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), isMain: true, name: "Main"), - DisplayInfo(id: 2, frame: CGRect(x: 1920, y: 100, width: 2560, height: 1440), isMain: false, name: "External") - ] - - // 中间状态不应创建新排列 - service.handleDisplayChange(displays: oneDisplay) { _ in } - service.handleDisplayChange(displays: oneDisplay) { _ in } - service.handleDisplayChange(displays: twoDisplays) { _ in } - service.handleDisplayChange(displays: twoDisplays) { _ in } - - let afterIntermediateCount = service.layouts.count - - // 在达到稳定阈值之前,不应创建新排列 - XCTAssertEqual(afterIntermediateCount, initialCount, "No new layout should be created before stability threshold") - } - private func sampleSingleDisplay() -> [DisplayInfo] { [ DisplayInfo(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), isMain: true, name: "Main") diff --git a/MousePortalTests/Services/DisplayConfigurationCoordinatorTests.swift b/MousePortalTests/Services/DisplayConfigurationCoordinatorTests.swift new file mode 100644 index 0000000..63ad7e4 --- /dev/null +++ b/MousePortalTests/Services/DisplayConfigurationCoordinatorTests.swift @@ -0,0 +1,388 @@ +import XCTest +import AppKit +@testable import MousePortal + +@MainActor +final class DisplayConfigurationCoordinatorTests: XCTestCase { + func testSwitchesSavedPortalsWithoutCreatingAView() throws { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + try assertTarget(f.portals, x: 3845) + + f.changeDisplays(to: f.twoDisplays) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutB.id) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalB.id]) + try assertTarget(f.portals, x: 1925) + + f.changeDisplays(to: f.threeDisplays) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutA.id) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + try assertTarget(f.portals, x: 3845) + XCTAssertEqual(f.displays.displays.map(\.id), f.threeDisplays.map(\.id)) + XCTAssertFalse(f.portals.isRunning, "Applying configuration must not enable the event tap") + } + + func testEmptyNewLayoutClearsOldCallbackPortalsAndCanReturn() throws { + let f = Fixture() + defer { f.cleanUp() } + f.layouts.layouts = [f.layoutA] + f.coordinator.start() + f.changeDisplays(to: f.twoDisplays) + XCTAssertTrue(f.layouts.currentPortals.isEmpty) + XCTAssertTrue(f.portals.portals.isEmpty) + XCTAssertNil(f.portals.handleMouseMovedForTap(try mouseEvent())) + + f.changeDisplays(to: f.threeDisplays) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + try assertTarget(f.portals, x: 3845) + } + + func testStartupReplacesStaleRuntimePortalsAndPreservesOriginalDataOnce() throws { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + let originalData = try JSONEncoder().encode([f.portalB]) + f.defaults.set(originalData, forKey: "portalPairs") + f.portals.load() + f.coordinator.start() + + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + XCTAssertEqual(f.defaults.data(forKey: "portalPairsBeforeLayoutSync"), originalData) + f.changeDisplays(to: f.twoDisplays) + f.changeDisplays(to: f.threeDisplays) + let reloaded = PortalService(defaults: f.defaults) + reloaded.applyDisplayConfiguration(portals: [], displays: f.twoDisplays) + XCTAssertEqual(f.defaults.data(forKey: "portalPairsBeforeLayoutSync"), originalData) + } + + func testDebounceRereadsLatestDisplaysAndRejectsCancelledCallbacks() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + f.provider.mockDisplays = f.twoDisplays + f.notifyChange() + f.notifyChange() + f.notifyChange() + XCTAssertEqual(f.output.updates.count, 1, "Three identical notifications must not bypass debounce") + XCTAssertEqual(f.scheduler.jobs.map(\.delay), [0.5, 0.5, 0.5]) + + // Change the provider after scheduling, without delivering another notification. + f.provider.mockDisplays = f.threeDisplays + f.scheduler.fire(0) + f.scheduler.fire(1) + XCTAssertEqual(f.output.updates.count, 1) + f.scheduler.fire(2) + XCTAssertEqual(f.output.updates.count, 2) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutA.id) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + } + + func testEmptyQueryPreservesSavedLayoutsUntilAValidNotification() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + let stored = f.defaults.data(forKey: "displayLayouts") + f.changeDisplays(to: []) + XCTAssertEqual(f.defaults.data(forKey: "displayLayouts"), stored) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutA.id) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + XCTAssertEqual(f.output.updates.count, 1) + + f.changeDisplays(to: f.twoDisplays) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutB.id) + } + + func testEmptyStartupCanRecoverOnExplicitRefresh() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.provider.mockDisplays = [] + f.coordinator.start() + XCTAssertTrue(f.output.updates.isEmpty) + f.provider.mockDisplays = f.threeDisplays + f.coordinator.refresh() + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + } + + func testStopAndRestartInvalidateQueuedWorkAndDoNotDuplicateObservers() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + f.coordinator.start() + f.notifyChange() + XCTAssertEqual(f.scheduler.jobs.count, 1) + f.coordinator.stop() + XCTAssertTrue(f.scheduler.jobs[0].isCancelled) + f.provider.mockDisplays = f.twoDisplays + f.scheduler.fire(0) + f.notifyChange() + XCTAssertEqual(f.output.updates.count, 1) + XCTAssertEqual(f.scheduler.jobs.count, 1) + + f.coordinator.start() + XCTAssertEqual(f.output.updates.count, 2) + f.scheduler.fire(0) + XCTAssertEqual(f.output.updates.count, 2, "Old work must remain invalid after restart") + f.notifyChange() + XCTAssertEqual(f.scheduler.jobs.count, 2) + f.scheduler.fire(1) + XCTAssertEqual(f.output.updates.count, 3) + } + + func testPendingWorkDoesNotRetainCoordinator() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + f.notifyChange() + weak var coordinator: DisplayConfigurationCoordinator? + coordinator = f.coordinator + f.coordinator = nil + XCTAssertNil(coordinator) + XCTAssertTrue(f.scheduler.jobs[0].isCancelled) + f.scheduler.fire(0) + XCTAssertEqual(f.output.updates.count, 1) + } + + func testReadingAnotherSavedLayoutDoesNotActivateItsPortals() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + let preview = f.layouts.layouts.first { $0.id == f.layoutB.id } + XCTAssertEqual(preview?.portals.map(\.id), [f.portalB.id]) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutA.id) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalA.id]) + } + + func testSettingsDeleteAndTogglePersistAcrossSwitchAndReload() throws { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + let deleted = f.portalA + let toggled = f.makePortal(name: "Toggle", destination: f.threeDisplays[2]) + let alsoDeleted = f.makePortal(name: "Delete too", destination: f.threeDisplays[2]) + f.layouts.addPortal(toggled) + f.layouts.addPortal(alsoDeleted) + + // Same model entry points used by the Settings scene; no PortalService writes. + f.layouts.togglePortal(id: toggled.id) + f.layouts.removePortals(ids: [deleted.id, alsoDeleted.id]) + XCTAssertEqual(f.portals.portals.map(\.id), [toggled.id]) + XCTAssertFalse(try XCTUnwrap(f.portals.portals.first).isEnabled) + f.changeDisplays(to: f.twoDisplays) + f.changeDisplays(to: f.threeDisplays) + XCTAssertEqual(f.portals.portals.map(\.id), [toggled.id]) + XCTAssertNil(f.portals.handleMouseMovedForTap(try mouseEvent())) + + let reloaded = DisplayLayoutService(defaults: f.defaults, applyPortals: { _ in }) + XCTAssertEqual(reloaded.currentPortals.map(\.id), [toggled.id]) + XCTAssertFalse(try XCTUnwrap(reloaded.currentPortals.first).isEnabled) + XCTAssertEqual(PortalService(defaults: f.defaults).portals.map(\.id), [toggled.id]) + } + + func testDeletingLastPortalDoesNotResurrectItOnReturn() { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + f.layouts.removePortal(id: f.portalA.id) + f.changeDisplays(to: f.twoDisplays) + f.changeDisplays(to: f.threeDisplays) + XCTAssertTrue(f.portals.portals.isEmpty) + XCTAssertTrue(f.layouts.currentPortals.isEmpty) + } + + func testEditedPortalGeometryReachesCallbackAndSurvivesTransition() throws { + let f = Fixture() + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + var edited = f.portalA + edited.lineB = f.portalB.lineB + f.layouts.updatePortal(edited) + try assertTarget(f.portals, x: 1925) + f.changeDisplays(to: f.twoDisplays) + f.changeDisplays(to: f.threeDisplays) + try assertTarget(f.portals, x: 1925) + } + + func testDisplayIDChangesRefreshLegacyCacheWithCurrentConfiguration() throws { + let f = Fixture() + defer { f.cleanUp() } + var legacy = f.portalA + legacy.lineA.displayLayoutKey = nil + legacy.lineA.legacyDisplayID = 10 + legacy.lineB.displayLayoutKey = nil + legacy.lineB.legacyDisplayID = 30 + let displays = f.threeDisplays.map { + DisplayInfo(id: $0.id * 10, frame: $0.frame, isMain: $0.isMain, name: $0.name) + } + f.portals.applyDisplayConfiguration(portals: [legacy], displays: displays) + try assertTarget(f.portals, x: 3845) + f.portals.applyDisplayConfiguration(portals: [legacy], displays: f.threeDisplays) + XCTAssertNil(f.portals.handleMouseMovedForTap(try mouseEvent())) + } + + func testCoordinatorPreservesLayoutsWithWindowSnapshots() { + let f = Fixture() + defer { f.cleanUp() } + var saved = f.layoutB + saved.portals = [] + f.layouts.layouts = [saved] + f.output.preserved = [saved.signature] + f.coordinator.start() + XCTAssertTrue(f.layouts.layouts.contains { $0.id == saved.id }) + } + + func testLegacyLayoutPortalsResolveUsingSavedDisplayIDsAfterReconnect() throws { + let f = Fixture() + defer { f.cleanUp() } + var legacyLayout = f.layoutA + var legacyPortal = f.portalA + legacyPortal.lineA.displayLayoutKey = nil + legacyPortal.lineA.legacyDisplayID = 1 + legacyPortal.lineB.displayLayoutKey = nil + legacyPortal.lineB.legacyDisplayID = 3 + legacyLayout.portals = [legacyPortal] + f.layouts.layouts = [legacyLayout] + f.provider.mockDisplays = f.threeDisplays.map { + DisplayInfo(id: $0.id * 10, frame: $0.frame, isMain: $0.isMain, name: $0.name) + } + f.coordinator.start() + + try assertTarget(f.portals, x: 3845) + let migrated = try XCTUnwrap(f.layouts.currentPortals.first) + XCTAssertEqual(migrated.id, legacyPortal.id) + XCTAssertEqual(migrated.lineA.id, legacyPortal.lineA.id) + XCTAssertEqual(migrated.lineB.id, legacyPortal.lineB.id) + XCTAssertEqual(migrated.lineA.displayLayoutKey, f.threeDisplays[0].layoutKey) + XCTAssertEqual(migrated.lineB.displayLayoutKey, f.threeDisplays[2].layoutKey) + let reloaded = DisplayLayoutService(defaults: f.defaults, applyPortals: { _ in }) + XCTAssertEqual(reloaded.currentPortals.first?.lineB.displayLayoutKey, migrated.lineB.displayLayoutKey) + } + + func testProductionSchedulerProcessesDisplayChange() async { + let f = Fixture(useProductionScheduler: true) + defer { f.cleanUp() } + f.installSavedLayouts() + f.coordinator.start() + let applied = expectation(description: "Debounced display application") + f.output.onUpdate = { applied.fulfill() } + f.provider.mockDisplays = f.twoDisplays + f.notifyChange() + await fulfillment(of: [applied], timeout: 2) + XCTAssertEqual(f.layouts.currentLayoutID, f.layoutB.id) + XCTAssertEqual(f.portals.portals.map(\.id), [f.portalB.id]) + } + + private func mouseEvent() throws -> CGEvent { + try XCTUnwrap(CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, + mouseCursorPosition: CGPoint(x: 1919, y: 50), mouseButton: .left)) + } + + private func assertTarget(_ portals: PortalService, x: CGFloat, file: StaticString = #filePath, line: UInt = #line) throws { + let target = try XCTUnwrap(portals.handleMouseMovedForTap(mouseEvent()), file: file, line: line) + XCTAssertEqual(target.target.x, x, file: file, line: line) + XCTAssertEqual(target.target.y, 50, file: file, line: line) + } +} + +@MainActor +private final class ManualDisplayScheduler: DisplayChangeScheduling { + final class Job { + let delay: TimeInterval + let action: @MainActor () -> Void + var isCancelled = false + init(delay: TimeInterval, action: @escaping @MainActor () -> Void) { + self.delay = delay + self.action = action + } + } + var jobs: [Job] = [] + func schedule(after delay: TimeInterval, action: @escaping @MainActor () -> Void) -> () -> Void { + let job = Job(delay: delay, action: action) + jobs.append(job) + return { job.isCancelled = true } + } + /// Deliberately fire cancelled jobs too, modelling already queued callbacks. + func fire(_ index: Int) { jobs[index].action() } +} + +@MainActor +private final class Fixture { + final class Output { + var updates: [[DisplayInfo]] = [] + var preserved: Set = [] + var onUpdate: (() -> Void)? + } + let suiteName = "DisplayConfigurationTests.\(UUID().uuidString)" + let defaults: UserDefaults + let provider = MockDisplayProvider() + let scheduler = ManualDisplayScheduler() + let center = NotificationCenter() + let output = Output() + let displays = DisplayService() + let portals: PortalService + let layouts: DisplayLayoutService + var coordinator: DisplayConfigurationCoordinator! + + let threeDisplays = [ + DisplayInfo(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), isMain: true, name: "Main"), + DisplayInfo(id: 2, frame: CGRect(x: 1920, y: 0, width: 1920, height: 1080), isMain: false, name: "Landscape"), + DisplayInfo(id: 3, frame: CGRect(x: 3840, y: 0, width: 1080, height: 1920), isMain: false, name: "Portrait") + ] + var twoDisplays: [DisplayInfo] { Array(threeDisplays.prefix(2)) } + lazy var portalA = makePortal(name: "Three displays", destination: threeDisplays[2]) + lazy var portalB = makePortal(name: "Two displays", destination: threeDisplays[1]) + lazy var layoutA = makeLayout(name: "A", displays: threeDisplays, portal: portalA) + lazy var layoutB = makeLayout(name: "B", displays: twoDisplays, portal: portalB) + + init(useProductionScheduler: Bool = false) { + defaults = UserDefaults(suiteName: suiteName)! + portals = PortalService(defaults: defaults) + let portals = self.portals + layouts = DisplayLayoutService(defaults: defaults, applyPortals: { + portals.portals = $0 + portals.save() + }) + let output = self.output + coordinator = DisplayConfigurationCoordinator( + provider: provider, layouts: layouts, portals: portals, displayService: displays, + preservedSignatures: { output.preserved }, + updateHotkeys: { output.updates.append($0); output.onUpdate?() }, + notificationCenter: center, scheduler: useProductionScheduler ? nil : scheduler + ) + provider.mockDisplays = threeDisplays + } + + func installSavedLayouts() { layouts.layouts = [layoutA, layoutB] } + func notifyChange() { center.post(name: NSApplication.didChangeScreenParametersNotification, object: nil) } + func changeDisplays(to displays: [DisplayInfo]) { + provider.mockDisplays = displays + notifyChange() + scheduler.fire(scheduler.jobs.count - 1) + } + func cleanUp() { + coordinator?.stop() + coordinator = nil + defaults.removePersistentDomain(forName: suiteName) + } + func makePortal(name: String, destination: DisplayInfo) -> PortalPair { + PortalPair(name: name, + lineA: PortalLine(displayLayoutKey: threeDisplays[0].layoutKey, edge: .right, startOffset: 0, endOffset: 100), + lineB: PortalLine(displayLayoutKey: destination.layoutKey, edge: .left, startOffset: 0, endOffset: 100)) + } + private func makeLayout(name: String, displays: [DisplayInfo], portal: PortalPair) -> DisplayLayout { + var layout = DisplayLayout(name: name, displays: displays) + layout.portals = [portal] + return layout + } +} diff --git a/docs/portal-bug-fix-plan.md b/docs/portal-bug-fix-plan.md index 0f37ad7..18fd58d 100644 --- a/docs/portal-bug-fix-plan.md +++ b/docs/portal-bug-fix-plan.md @@ -1,6 +1,6 @@ # 포털 사용에 영향을 주는 두 버그 수정 계획 -- 상태: 검토 의견을 반영하여 구현 진행. 검증 결과는 문서 끝에 기록. +- 상태: 구현 및 자동 검증 완료. 실제 모니터 연결·해제 확인은 미실시. 결과는 문서 끝에 기록. - 작성일: 2026-09-08 - 검토 기준: `b88f507` 및 현재 작업 트리의 Swift 소스 - 사용 환경: 맥북 내장 화면 + 가로 모니터 + 세로 모니터. 포털 위주로 사용하며 단축키와 창 복원은 사용하지 않음. @@ -220,6 +220,30 @@ if let matchedLayout = layouts.first(where: { $0.matches(displays: displays) }) - P3: 단순화 수용. 3회 경로가 실행 불가능하다는 판단은 하지 않는다. 순수 디바운스로 바꾸고 관련 테스트도 목적 중심으로 대체한다. - P4: 생명주기 방어 수용. 창 닫힘과 뷰 즉시 소멸을 동일시하거나 메모리 안전성 문제로 단정하지 않는다. 예약 작업에서 UI completion 자체를 없앤다. - P5: 조사 범위 축소는 수용하나 전역 전용 또는 불일치 데이터의 부재는 확정하지 않는다. 로컬 복구 사본으로 기존 데이터를 보존한다. +- 구현 중 P5를 추가 검증한 결과, 기존 `PortalService.migrateIfNeeded()`는 전역 포털만 변환하며 배치 내부 포털에는 적용되지 않는다. 구형 배치의 포털이 `legacyDisplayID`를 보관한 상태에서 실제 화면 ID가 바뀌면 새 적용 경로에서 커서 목적지 계산이 실패하는 테스트를 재현했다. 매칭된 배치의 저장된 `DisplaySnapshot.displayID`와 좌표로 선분을 변환하고 같은 배치에 저장한다. 포털과 선분 UUID는 유지하며, 대응하는 저장 화면 정보가 없는 선분은 추측하여 변환하지 않는다. - A의 반환 방식은 ID 재조회로 통일하고, 크래시 회귀 테스트에는 불필요한 목록 길이 단언을 넣지 않는다. 예정 커밋: (1) 검토 반영 계획서, (2) 버그 A 및 회귀 테스트, (3) 버그 B·설정 씬 일관성·디바운스 및 회귀 테스트. 추가 수정이 필요하면 독립된 후속 커밋으로 기록한다. 사용자 요청에 따라 로컬 커밋까지 수행하며 원격 push나 PR 생성은 이 작업의 완료 조건이 아니다. + +## 9. 구현 결과 및 검증 기록 + +브랜치: `fix/portal-layout-transitions` + +- 검토 반영 계획서는 `f3805e1`, 버그 A 수정은 `9405b00`에 기록했다. 버그 B의 관련 변경은 별도 커밋으로 묶는다. +- A: 정리 후 목표 ID로 다시 조회하여 반환한다. 수정 전 ‘뒤쪽의 잠긴 배치를 잘못 반환’하는 테스트 실패를 확인했다. 수정 후 크래시 조건을 포함한 회귀 테스트 3개가 통과했다. +- B: `AppDelegate`가 조정 서비스를 소유하며 시작 시 적용을 입력 감시보다 먼저 수행한다. 화면 알림은 한 곳에서 디바운스하고 실행 시 다시 조회한다. UI는 더 이상 예약 completion을 제공하지 않는다. +- 설정 씬의 삭제·토글은 배치 저장소를 수정한다. 복수 삭제는 ID 집합으로 처리한다. 미사용 `syncFromPortalService()`와 기존 배치 안정화 상태·타이머를 제거했다. +- 포털과 화면 좌표를 이벤트 콜백 상태에 한 번의 잠금으로 반영한다. 화면 전환에서 이벤트 탭을 재생성하거나 켜지 않는다. +- `portalPairsBeforeLayoutSync` 키에 최초 적용 전 전역 포털 데이터의 로컬 사본을 보관한다. 기존 사본은 후속 적용·재시작에서 덮어쓰지 않는다. 이 사본은 자동으로 복원되거나 원본 배치에 병합되지 않는다. +- 기존 배치 안의 구형 선분은 저장된 화면 ID와 좌표로 변환한다. 실제 화면 ID가 바뀐 입력에서 변환 전 목적지 계산 실패와 변환 후 성공을 확인했으며, 포털·선분 UUID 및 저장 후 재로딩도 검증했다. + +검증 결과: + +- `swift test`: **181개 통과, 실패 0개**. 기존 165개에 A 회귀 테스트 3개와 조정 서비스 통합 테스트 16개를 추가하고, 제거한 알림 횟수 기반 안정화 테스트 3개를 대체했다. +- `swift build -c release`: 성공. +- `git diff --check`: 통과. +- 조정 서비스 테스트는 별도 설정 suite와 화면 제공자를 사용한다. 실제 커서를 움직이거나 전역 이벤트 탭을 시작하지 않고 포털 콜백의 목적지 계산까지 확인했다. 실제 타이머 구현의 0.5초 지연 적용도 별도 테스트했다. +- 물리적인 모니터 연결·해제, 앱 창 닫기·재열기 UI 조작, 사용자 마우스 이동과 배터리·CPU 측정은 수행하지 않았다. ‘뷰 없이 동작’은 조정 서비스 테스트로 검증한 것이며 실제 UI 확인을 대신했다고 주장하지 않는다. +- 순수 디바운스이므로 알림이 계속 들어오는 동안 적용을 미루며, 마지막 알림 후 0.5초 뒤 유효 화면을 적용한다. 화면 조회가 빈 결과이면 기존 상태를 보존하고 다음 알림 또는 명시적 새로고침을 기다린다. + +검토용으로 남아 있는 실제 사용 확인 항목은 §5의 수동 시나리오다. 단축키 설정 손실, 재실행 시 포털 전체 활성화 상태 유지, 창 복원 관련 별도 문제는 이번 수정 범위에 포함하지 않았다. From 44de494d2027c08cf0d8950fa96d36df254d79d2 Mon Sep 17 00:00:00 2001 From: Lee JaeHyeon <87473022+JaeHyeon-KAIST@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:30:25 +0900 Subject: [PATCH 4/5] build: add local app packaging without Developer ID credentials --- README.md | 12 +++++++ tools/package-local.sh | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100755 tools/package-local.sh diff --git a/README.md b/README.md index c063b88..dd7e16d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,18 @@ swift build -c release swift run ``` +To create a double-clickable app for the Mac you are building on: + +```bash +bash tools/package-local.sh +``` + +This creates `dist/local/MousePortal.app` with its icon and bundled resources. +Move it to `/Applications` and grant Accessibility permission before using portals. +The app is ad-hoc signed for local use, without Developer ID credentials or Apple notarization. +The script preserves an existing app; pass another output directory to build a new copy, +for example `bash tools/package-local.sh dist/local-new`. + ## Usage Guide ### First-Time Setup diff --git a/tools/package-local.sh b/tools/package-local.sh new file mode 100755 index 0000000..e6fa1ff --- /dev/null +++ b/tools/package-local.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build a native-architecture .app for this Mac without Developer ID credentials. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="${1:-$REPO_ROOT/dist/local}" +APP_NAME="MousePortal" +BUNDLE_ID="uy.aix.MousePortal" +APP_PATH="$OUTPUT_DIR/$APP_NAME.app" + +if [[ -e "$APP_PATH" ]]; then + echo "Already exists: $APP_PATH" >&2 + echo "Pass a different output directory to keep the existing app." >&2 + exit 1 +fi + +cd "$REPO_ROOT" +swift build -c release +BIN_PATH="$(swift build -c release --show-bin-path)" +RESOURCE_BUNDLE="$BIN_PATH/${APP_NAME}_${APP_NAME}.bundle" +VERSION="$(awk -F'"' '/static let current/ { print $2; exit }' MousePortal/AppVersion.swift)" +BUILD_NUMBER="$(awk -F'"' '/static let build/ { print $2; exit }' MousePortal/AppVersion.swift)" + +if [[ ! -d "$RESOURCE_BUNDLE" || -z "$VERSION" || -z "$BUILD_NUMBER" ]]; then + echo "Missing resource bundle or app version." >&2 + exit 1 +fi + +STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/MousePortal.local.XXXXXX")" +trap 'rm -rf "$STAGING_DIR"' EXIT +STAGED_APP="$STAGING_DIR/$APP_NAME.app" +mkdir -p "$STAGED_APP/Contents/MacOS" "$STAGED_APP/Contents/Resources" +install -m 755 "$BIN_PATH/$APP_NAME" "$STAGED_APP/Contents/MacOS/$APP_NAME" +ditto "$RESOURCE_BUNDLE" "$STAGED_APP/Contents/Resources/$(basename "$RESOURCE_BUNDLE")" + +PLIST="$STAGED_APP/Contents/Info.plist" +plutil -create xml1 "$PLIST" +plutil -insert CFBundleDevelopmentRegion -string en "$PLIST" +plutil -insert CFBundleDisplayName -string "$APP_NAME" "$PLIST" +plutil -insert CFBundleExecutable -string "$APP_NAME" "$PLIST" +plutil -insert CFBundleIconFile -string AppIcon "$PLIST" +plutil -insert CFBundleIdentifier -string "$BUNDLE_ID" "$PLIST" +plutil -insert CFBundleInfoDictionaryVersion -string 6.0 "$PLIST" +plutil -insert CFBundleName -string "$APP_NAME" "$PLIST" +plutil -insert CFBundlePackageType -string APPL "$PLIST" +plutil -insert CFBundleShortVersionString -string "$VERSION" "$PLIST" +plutil -insert CFBundleVersion -string "$BUILD_NUMBER" "$PLIST" +plutil -insert LSMinimumSystemVersion -string 13.0 "$PLIST" +plutil -insert LSApplicationCategoryType -string public.app-category.utilities "$PLIST" +plutil -insert NSHighResolutionCapable -bool YES "$PLIST" +plutil -insert NSSupportsAutomaticGraphicsSwitching -bool YES "$PLIST" +plutil -insert NSHumanReadableCopyright -string '© 2026 AiX.uy' "$PLIST" +plutil -insert MousePortalSourceRevision -string "$(git rev-parse HEAD)" "$PLIST" +plutil -lint "$PLIST" + +ICON_SOURCE="$REPO_ROOT/MousePortal/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png" +ICONSET="$STAGING_DIR/AppIcon.iconset" +mkdir -p "$ICONSET" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + doubled=$((size * 2)) + sips -z "$doubled" "$doubled" "$ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$STAGED_APP/Contents/Resources/AppIcon.icns" + +# Ad-hoc signing is for local use; no certificate or notarization upload is used. +codesign --force --sign - --options runtime "$STAGED_APP" +codesign --verify --deep --strict --verbose=2 "$STAGED_APP" +mkdir -p "$OUTPUT_DIR" +ditto "$STAGED_APP" "$APP_PATH" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +echo "Created: $APP_PATH" +echo "Move the app to Applications before granting Accessibility permission." From 6acdf12a0b6204cb2bec26e33590bdc0455a3673 Mon Sep 17 00:00:00 2001 From: Lee JaeHyeon <87473022+JaeHyeon-KAIST@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:53:23 +0900 Subject: [PATCH 5/5] docs: record resource baseline and long-term follow-up plan --- docs/resource-follow-up.md | 81 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/resource-follow-up.md diff --git a/docs/resource-follow-up.md b/docs/resource-follow-up.md new file mode 100644 index 0000000..25ec08c --- /dev/null +++ b/docs/resource-follow-up.md @@ -0,0 +1,81 @@ +# 장기 사용 후 리소스 재점검 계획 + +## 목적과 현재 상태 + +MousePortal을 실제 모니터 구성에서 며칠 사용한 뒤, 백그라운드 리소스와 포털 동작을 다시 검사한다. 현재는 단기 대기 상태 측정만 완료했으며, 장시간 안정성이나 배터리 영향을 검증한 것은 아니다. + +- 최초 측정: 2026-09-08 20:46–20:47 KST. +- 브랜치: `fix/portal-layout-transitions`. +- 측정 당시 저장소 HEAD: `fa61989` (앱 기능 수정 커밋: `30c57eb`). +- 사용할 앱: `/Applications/MousePortal.app`. +- 중복 실행된 `dist/local/MousePortal.app` 프로세스는 사용자 요청에 따라 2026-09-08 종료했다. 앱 파일이나 설정은 삭제하지 않았다. +- 이 문서는 후속 검사 계획이다. 자동 수집기나 예약 작업은 설치하지 않았다. + +## 최초 측정 기록 + +| 항목 | 설치본 `/Applications` | 빌드본 `dist/local` | +| --- | --- | --- | +| 당시 PID (다음 검사 때 재조회 필수) | 51734 | 28153 | +| 측정 시작 시 실행 경과 | 약 8분 | 약 32분 | +| CPU | 약 23초 반복 측정에서 표시상 0.0% | 동일 | +| 물리 메모리 부담 (`vmmap` footprint) | 36.4MB | 48.8MB | +| 실행 이후 최대 footprint | 156.7MB | 180.0MB | +| 스레드 수 | 4 | 4 | +| 관찰 구간 메모리 증가 | 관찰되지 않음 | 관찰되지 않음 | +| 조회 시 열린 인터넷 소켓 (`lsof -i`) | 없음 | 없음 | +| 시스템 입력 감시 목록 | 활성 이벤트 탭 1개 | 해당 PID의 이벤트 탭 없음 | + +CPU 0.0%는 표시 정밀도 기준이며 CPU를 전혀 쓰지 않는다는 뜻은 아니다. footprint와 `ps`의 RSS는 다른 지표이므로 서로 직접 비교하지 않는다. 최대 footprint만으로 누수라고 판단하지 않는다. 열린 소켓이 없다는 결과는 과거 통신이나 짧은 연결까지 없었다는 증명이 아니다. + +입력 이벤트 탭의 평균 지연 통계가 약 85ms로 조회됐다. 이후 약 10초간 반복 조회에서도 값이 변하지 않아, 현재 입력 부하를 반영하는지 확인되지 않았다. 실제 포털 이동 지연으로 단정하지 말고, 다음 검사에서 마우스를 움직이며 다시 측정한다. `CGGetEventTapList` 조회는 최소·최대 지연 통계를 재설정하므로 측정 방법도 기록한다. + +## 며칠간 사용 방법 + +- 설치본 하나만 실행하고, 창은 닫아도 되지만 포털을 쓰는 동안 앱을 Quit하지 않는다. +- 평소처럼 포털을 사용한다. 자연스럽게 발생하는 모니터 연결·해제, 잠자기·깨우기, 설정 창 열기·닫기도 포함한다. +- 앱 종료·재실행, 맥 재부팅, 앱 업데이트가 있으면 대략적인 시각을 기록한다. 재시작 전후는 별도 측정 구간으로 취급한다. +- 커서 끊김·잘못된 이동·포털 미작동·팬 소음·발열·배터리 급감이 느껴지면 시각과 직전 행동을 기록한다. 원인은 측정 전까지 MousePortal로 단정하지 않는다. +- 권장 재점검 시점: 3–7일 사용 후. 그 전에 이상이 생기면 즉시 검사한다. 문제 확인을 위해 설정을 지우거나 앱을 재설치할 필요는 없다. + +## 후속 검사 절차 + +1. 실행 경로, 현재 PID, 앱 버전과 실행 경과 시간을 다시 확인한다. 중복 프로세스가 없는지 확인하고 이전 PID를 그대로 사용하지 않는다. +2. 대기 상태에서 CPU·메모리·스레드 수를 1–2분 관찰한다. 메모리는 최초 기록과 같은 footprint 지표로 비교한다. +3. 마우스를 계속 움직이고 포털을 양방향으로 여러 번 통과하는 동안 1–2분 재측정한다. 대기 상태와 입력 처리 중 수치를 구분한다. +4. 앱 창 열기·닫기와 모니터 전환 전후의 값을 비교한다. 작업을 마친 뒤 대기로 돌아왔을 때 메모리가 계속 증가하는지 확인한다. 단순 캐시 증가나 일시적인 최고값과 지속 증가를 구분한다. +5. 잠자기 복귀·모니터 재연결 후 포털이 정상 작동하는지, 삭제·비활성화한 포털이 되살아나지 않는지 확인한다. +6. 네트워크 소켓을 다시 조회한다. 발견되면 대상과 발생 조건을 조사하되, 소켓 부재만으로 통신이 전혀 없다고 결론 내리지 않는다. +7. 입력 이벤트 탭 활성 상태와 지연 통계를 실제 입력 중 확인한다. 이상이 재현되면 짧은 스택 샘플이나 Instruments 분석으로 원인을 좁힌다. +8. 필요하면 에너지 영향과 wakeup을 별도 측정한다. CPU 사용량만으로 배터리 소비량을 확정하지 않는다. + +읽기 전용 기본 명령 예시 (확인한 설치본 PID로 ``를 바꿔 실행): + +```sh +pgrep -fl MousePortal +ps -p -o pid,etime,%cpu,rss,time,state,command +top -l 31 -s 2 -pid -stats pid,command,cpu,mem,threads,ports,pageins,time +vmmap -summary +lsof -nP -a -p -i +``` + +`top`의 첫 CPU 표본은 구간 비교에서 제외한다. 후속 구간 표본과 `vmmap` 결과를 중심으로 비교한다. 진단 중 앱 종료, 설정 변경, 코드 수정이 필요하면 별도 작업으로 다룬다. + +## 판단 기준과 한계 + +- 대기 중 CPU가 반복적으로 높거나, 같은 작업을 반복할수록 대기 시 메모리·스레드·포트 수가 계속 증가하면 추가 조사한다. +- 입력 처리 중 순간적인 CPU 상승만으로 결함이라고 판단하지 않는다. 지속 시간, 커서 체감 지연, 대기 복귀 여부를 함께 본다. +- 며칠 뒤 단일 측정은 그 시점의 상태일 뿐이다. 누적 사용 중 최대 부하나 과거의 일시적 문제를 복원할 수 없다. 누수 의심 시 여러 시점의 비교 또는 별도 프로파일링이 필요하다. +- 현재 결론은 **짧은 대기 측정에서 리소스 과다 사용 징후가 없었다**는 범위로 한정한다. + +## 후속 결과 기록 양식 + +- 검사 일시 / 앱 버전: +- 실행 경과 / 중간 재실행·재부팅 여부: +- 모니터 구성 / 최근 연결·해제·잠자기 여부: +- 대기 CPU / 입력 중 CPU: +- footprint 현재값 / 최대값 / 반복 작업 후 추세: +- 스레드·포트 수 변화: +- 네트워크 조회 결과: +- 실제 입력 중 이벤트 탭 지연 / 체감 증상: +- 재현 절차와 발생 시각: +- 결론 / 추가 조사 필요 항목: