diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..8a5b9ae --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,60 @@ +name: iOS + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + contract-and-privacy: + name: Cave contract and privacy + runs-on: ubuntu-latest + steps: + - name: Check out Coven Memory + uses: actions/checkout@v4 + - name: Check out Cave contract source + uses: actions/checkout@v4 + with: + repository: OpenCoven/coven-cave + path: coven-cave + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Verify contract helpers + run: node --test scripts/check-mobile-contract.test.mjs + - name: Verify Cave response fixture parity + run: >- + node scripts/check-mobile-contract.mjs + coven-cave/tests/fixtures/mobile-canonical-memory-v1 + apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1 + - name: Verify iOS privacy scanner + run: bash scripts/check-ios-privacy.test.sh + - name: Scan source and fixtures + run: bash scripts/check-ios-privacy.sh + + simulator: + name: iPhone simulator + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Install XcodeGen + run: HOMEBREW_NO_AUTO_UPDATE=1 brew install xcodegen + - name: Generate project + working-directory: apps/ios/CovenMemory + run: xcodegen generate + - name: Run Swift and UI tests + working-directory: apps/ios/CovenMemory + run: >- + xcodebuild test + -project CovenMemory.xcodeproj + -scheme CovenMemory + -derivedDataPath build/DerivedData + -resultBundlePath build/CovenMemoryTests.xcresult + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' + - name: Scan generated and source artifacts + run: | + bash scripts/check-ios-privacy.sh + bash scripts/check-ios-privacy.sh apps/ios/CovenMemory/build/DerivedData/Build/Products/Debug-iphonesimulator/CovenMemory.app diff --git a/README.md b/README.md index 4fd7eae..0d0552b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ The current dashboard provides: Promotion, editing, deletion, approval, and supersession mutations are intentionally absent until their authority contracts are available. +## Native iOS client + +`apps/ios/CovenMemory` contains the read-only iPhone and iPad client. It pairs +through Coven Cave's **Open on phone** flow and reads the bearer-protected +`/api/mobile/coven-memory` contract over HTTPS; it does not connect directly to +Coven or persist memory content on the device. See +[`docs/ios/README.md`](docs/ios/README.md) for build, fixture, signing, pairing, +and privacy-gate instructions. + ## Requirements - Node.js 24 or newer @@ -153,6 +162,7 @@ Coven, restarting the daemon, then retrying the dashboard. ```bash pnpm check pnpm audit:prod +pnpm test:ios-privacy ``` `pnpm check` runs lint, TypeScript, unit/component/integration tests, the diff --git a/SECURITY.md b/SECURITY.md index 10f59ad..357c5c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,6 +63,27 @@ memory collection. Browser and transport fixtures must be deterministic and synthetic. Real memory must never be copied into tests, screenshots, traces, or issue notes. +## Native iOS boundary + +The iOS client is a separate read-only presentation layer behind Coven Cave's +mobile bearer gate. Pairing accepts only a normalized HTTPS Cave URL with one +mobile access token. The connection record is stored in the device-only, +when-unlocked Keychain; memory summaries, details, and reveal state are never +written to a database, cache, preference, log, or analytics service. + +Requests use an ephemeral URL session, reject redirects, and accept only the +versioned `/api/mobile/coven-memory` response shapes. Cave remains responsible +for mobile authorization and local Coven access. App lock, backgrounding, +protected-reader dismissal, pairing reset, and authentication/revocation +failures must clear the active memory graph and cover content immediately. + +iOS cannot prevent a device owner from taking a screenshot or photographing a +screen. TestFlight notes, screenshots, recordings, crash reports, and review +packets must therefore contain synthetic status evidence only—never an invite, +endpoint, token, device identifier, or memory body. A lost device requires +rotation of the Cave mobile access secret and re-pairing trusted devices; local +**Pair again** removes only that device's stored connection. + ## Enforcement layers (defense in depth) 1. **Pre-commit hook** — `scripts/guard-scan.sh --staged`: gitleaks (with diff --git a/apps/ios/CovenMemory/CovenMemory.xcodeproj/project.pbxproj b/apps/ios/CovenMemory/CovenMemory.xcodeproj/project.pbxproj index cd647b2..8e3e513 100644 --- a/apps/ios/CovenMemory/CovenMemory.xcodeproj/project.pbxproj +++ b/apps/ios/CovenMemory/CovenMemory.xcodeproj/project.pbxproj @@ -17,9 +17,13 @@ 1DF93F2FA166523B65E76ABA /* MemoryReaderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39ACC107580B8A977848C6E /* MemoryReaderStateTests.swift */; }; 245F0F2F81C4F7B382D7CE64 /* MemoryMarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268A26E2FF9BE8FFABD648FB /* MemoryMarkdownTests.swift */; }; 24D7674659E6747122576AE5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C6703A8B7A48791FA0E2B425 /* Assets.xcassets */; }; + 2C2186087894231B983E7C53 /* AccessibilityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DFA33FC914EF123D9DC165 /* AccessibilityUITests.swift */; }; 2CF693759FF2C8695AF8F19F /* MemoryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC35B795556F968007B8368F /* MemoryRow.swift */; }; 2DD0439E35151B4101B89388 /* MemoryProvenanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822EC0519A1BABD99C42F679 /* MemoryProvenanceView.swift */; }; + 2E2476D97E7008E22A737E04 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 85978EC4A5C84A3C63FF10FB /* PrivacyInfo.xcprivacy */; }; 2F1521113F8A719F2233B6D8 /* detail-public.json in Resources */ = {isa = PBXBuildFile; fileRef = 2CC161183FBBF5530F4F0673 /* detail-public.json */; }; + 2F990904188BF0E6B4FACFB1 /* MemoryPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023D5490F560D01DCAEEC74 /* MemoryPerformanceTests.swift */; }; + 31889551B1E815347A7E94A1 /* cave-detail-success.json in Resources */ = {isa = PBXBuildFile; fileRef = CB25B6ECCA05D657CDCD7D28 /* cave-detail-success.json */; }; 318E8BF54836C45EDC87BD6B /* MemoryLibraryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF2536B5DA6D5FE3F7AA17AC /* MemoryLibraryState.swift */; }; 36892B0EBC1939A05D88C02F /* MemoryHealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121F920ECB86F53F436B1AE9 /* MemoryHealthView.swift */; }; 385C5D650DC8917FC17493C2 /* QRScannerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24821CB07AECF57A80B18DD /* QRScannerSheet.swift */; }; @@ -30,8 +34,8 @@ 40A6689EF597A138B1788F01 /* LaunchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F43D56ED5BFDF7E1C0DA00 /* LaunchViews.swift */; }; 42C11778D372F82614D628EF /* LaunchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF37227525DCF8D8C1F8162 /* LaunchCoordinator.swift */; }; 4A37530637506032798B5232 /* list-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 5E6AC8B919C7B1EA20648B6A /* list-success.json */; }; + 5318F6A75CFA190C086813DE /* PerformanceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F294977E73E540FF7601942D /* PerformanceUITests.swift */; }; 5915811F63593FC9C13AE233 /* MemoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3982DFC752C65B95CC85897 /* MemoryLibraryView.swift */; }; - 5AE5518CEFE3CE26C74E1620 /* cave-overview-success.json in Resources */ = {isa = PBXBuildFile; fileRef = B6283A5547D14134BA6254E0 /* cave-overview-success.json */; }; 5D95EFEB8339B30A58EA5C52 /* capabilities-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 329E0F6F3588CF867692E2FD /* capabilities-success.json */; }; 63A38F591D113F3E49ED8B3C /* MemoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B718C4CD28051F70E6C7686A /* MemoryReaderView.swift */; }; 67FDA48E20ED96A47A8C30E4 /* MemoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27972E2F0D7BAEC45F3D03FF /* MemoryFilterSheet.swift */; }; @@ -43,7 +47,9 @@ 795DDB58743D40E16B98073E /* MemoryMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95636DA2A3B856134E77F162 /* MemoryMarkdown.swift */; }; 7A16C96FA6B87E614AB41FD4 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93468BFBD005399D3B853D44 /* KeychainStore.swift */; }; 7CD2E641081E02A08197B977 /* MemoryReaderUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DCE5B6CC3E044D007EAFC1 /* MemoryReaderUITests.swift */; }; + 7CD3F147D1A71EF16D2CD535 /* LocalizationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410453CBC7C70D07996257A5 /* LocalizationUITests.swift */; }; 899CA8A93A175A512FD6B58D /* MobileContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A270006FDF5C369D0BFE7AB /* MobileContractTests.swift */; }; + 8A667F186BFB61AA1962CE42 /* MemoryLifetimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C64CDC217410F459FA9FBD6 /* MemoryLifetimeTests.swift */; }; 9363D89B048DC3EC6EA074D9 /* CaveMemoryTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B3B19371838D8C6E06925C /* CaveMemoryTransportTests.swift */; }; 96D0078F294A09F2E369DE9E /* CaveMemoryTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38D55200D227846D9EA459F /* CaveMemoryTransport.swift */; }; 9E154215C6E6F038E2DD9A8D /* Fixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B762A333F484DB7460EFCA /* Fixture.swift */; }; @@ -54,6 +60,7 @@ A6300B98ADF006255344E77A /* signature-vector.json in Resources */ = {isa = PBXBuildFile; fileRef = 645A43B798CA6B522BABF1E1 /* signature-vector.json */; }; B0D2336497008531D4992B0D /* MemoryPrivacyPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D806DED6D9B4CB8F8D26C522 /* MemoryPrivacyPolicyTests.swift */; }; B13B2078911184F62588A230 /* QRScannerSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A4940070AB2B5F8C99D5E0 /* QRScannerSheetTests.swift */; }; + B8B88C1C438DC0A2EEE269DF /* cave-list-success.json in Resources */ = {isa = PBXBuildFile; fileRef = CC89F0E2561BAAC5EDFFCFFE /* cave-list-success.json */; }; B9F7346269A76875505FA89B /* ExceptionNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE2BA0D3CA4E404D14D47BB /* ExceptionNotice.swift */; }; BE0F9FC51225AC81459B32E2 /* LaunchCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1904FCA988D0CBA09C429487 /* LaunchCoordinatorTests.swift */; }; BF0D5C4C90EC02DBA3EE81E6 /* CovenMemoryLaunchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFD93FE933570FFE8E641D /* CovenMemoryLaunchUITests.swift */; }; @@ -63,8 +70,10 @@ D8A71C05DA210DBA372A551E /* PrivacyCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCC823369F587134B881F54 /* PrivacyCover.swift */; }; DF7A2DAD367569586D600060 /* LocalAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74379F42C886221F499BC3FD /* LocalAuthenticator.swift */; }; E285531EDFF3CB50515E19E3 /* MemoryFiltersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFCBAEE024EE9F7FE932E0E /* MemoryFiltersTests.swift */; }; + E3167B1DB368D931A57F5616 /* cave-overview-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 5B9BD2A3A05CD59744F89E41 /* cave-overview-success.json */; }; F56747223DDE1FF1B3CFFDA1 /* CovenTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 341916BE7E59DF787AE0BA62 /* CovenTheme.swift */; }; FBAF9D48576088CEA42124FB /* overview-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 6D086602BF102280C1D50991 /* overview-success.json */; }; + FCB971094A87E5FEE5750505 /* cave-error-cases.json in Resources */ = {isa = PBXBuildFile; fileRef = 819C8975B329096042056D94 /* cave-error-cases.json */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -96,13 +105,17 @@ 2CC161183FBBF5530F4F0673 /* detail-public.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "detail-public.json"; sourceTree = ""; }; 2E2C5913BCE2F520A217BE55 /* CaveMemoryInviteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaveMemoryInviteTests.swift; sourceTree = ""; }; 2EA5F4CEC4365998AFCD195A /* MemoryProvenanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryProvenanceTests.swift; sourceTree = ""; }; + 3023D5490F560D01DCAEEC74 /* MemoryPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPerformanceTests.swift; sourceTree = ""; }; 30725A732F69B8755B6EC78A /* CovenMemoryApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CovenMemoryApp.swift; sourceTree = ""; }; 329E0F6F3588CF867692E2FD /* capabilities-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "capabilities-success.json"; sourceTree = ""; }; 341916BE7E59DF787AE0BA62 /* CovenTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CovenTheme.swift; sourceTree = ""; }; 3A270006FDF5C369D0BFE7AB /* MobileContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileContractTests.swift; sourceTree = ""; }; 3EE8C7CD909A662B45F70735 /* CovenMemory.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CovenMemory.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 410453CBC7C70D07996257A5 /* LocalizationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationUITests.swift; sourceTree = ""; }; 4296EAA63914269FAEB75F6D /* MemoryPrivacyPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPrivacyPolicy.swift; sourceTree = ""; }; 49D2C54125AD93C6E60A458D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 5B9BD2A3A05CD59744F89E41 /* cave-overview-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cave-overview-success.json"; sourceTree = ""; }; + 5C64CDC217410F459FA9FBD6 /* MemoryLifetimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLifetimeTests.swift; sourceTree = ""; }; 5E6AC8B919C7B1EA20648B6A /* list-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "list-success.json"; sourceTree = ""; }; 6052233DAD6ED8BD0BF08B9E /* CovenMemoryUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CovenMemoryUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 61BFD93FE933570FFE8E641D /* CovenMemoryLaunchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CovenMemoryLaunchUITests.swift; sourceTree = ""; }; @@ -112,8 +125,10 @@ 78F43D56ED5BFDF7E1C0DA00 /* LaunchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViews.swift; sourceTree = ""; }; 7BE2BA0D3CA4E404D14D47BB /* ExceptionNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExceptionNotice.swift; sourceTree = ""; }; 80B9B9BECEFDADFC3C549F1B /* error-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "error-cases.json"; sourceTree = ""; }; + 819C8975B329096042056D94 /* cave-error-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cave-error-cases.json"; sourceTree = ""; }; 822EC0519A1BABD99C42F679 /* MemoryProvenanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryProvenanceView.swift; sourceTree = ""; }; 83F71913BCBA95B753D9ED9E /* CaveMemoryConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaveMemoryConnection.swift; sourceTree = ""; }; + 85978EC4A5C84A3C63FF10FB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 8C04D61EAA7CBF7F3C897510 /* MemoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryFilters.swift; sourceTree = ""; }; 91B3B19371838D8C6E06925C /* CaveMemoryTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaveMemoryTransportTests.swift; sourceTree = ""; }; 93468BFBD005399D3B853D44 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; @@ -124,7 +139,7 @@ AFB62383EEAA7F56CBADCBAB /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = ""; }; B3982DFC752C65B95CC85897 /* MemoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLibraryView.swift; sourceTree = ""; }; B5813149F3F23A3DEC39C1B5 /* MemoryCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCapabilities.swift; sourceTree = ""; }; - B6283A5547D14134BA6254E0 /* cave-overview-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cave-overview-success.json"; sourceTree = ""; }; + B5DFA33FC914EF123D9DC165 /* AccessibilityUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityUITests.swift; sourceTree = ""; }; B654273DE28FF8238884DBFF /* Validated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Validated.swift; sourceTree = ""; }; B718C4CD28051F70E6C7686A /* MemoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryReaderView.swift; sourceTree = ""; }; B8BC5B5D7A003089FD71999A /* CaveMemoryInvite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaveMemoryInvite.swift; sourceTree = ""; }; @@ -134,14 +149,17 @@ C533FFBF5F5AFB81E7CCCAF0 /* CredentialVaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVaultTests.swift; sourceTree = ""; }; C6703A8B7A48791FA0E2B425 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CAD243670749032EA3FCE458 /* detail-protected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "detail-protected.json"; sourceTree = ""; }; + CB25B6ECCA05D657CDCD7D28 /* cave-detail-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cave-detail-success.json"; sourceTree = ""; }; CBCC823369F587134B881F54 /* PrivacyCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyCover.swift; sourceTree = ""; }; CC88FD3387FD6F02A1A96313 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; + CC89F0E2561BAAC5EDFFCFFE /* cave-list-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cave-list-success.json"; sourceTree = ""; }; CF65F52751EDC5341B690A37 /* CovenThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CovenThemeTests.swift; sourceTree = ""; }; D24821CB07AECF57A80B18DD /* QRScannerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerSheet.swift; sourceTree = ""; }; D806DED6D9B4CB8F8D26C522 /* MemoryPrivacyPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPrivacyPolicyTests.swift; sourceTree = ""; }; DD4B27A4170036F2FD6B8F12 /* PrivacyLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyLifecycleTests.swift; sourceTree = ""; }; E70B8AEF02F415711BEC8DB8 /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; EF2536B5DA6D5FE3F7AA17AC /* MemoryLibraryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLibraryState.swift; sourceTree = ""; }; + F294977E73E540FF7601942D /* PerformanceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceUITests.swift; sourceTree = ""; }; F38D55200D227846D9EA459F /* CaveMemoryTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaveMemoryTransport.swift; sourceTree = ""; }; F4B762A333F484DB7460EFCA /* Fixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fixture.swift; sourceTree = ""; }; F6A4940070AB2B5F8C99D5E0 /* QRScannerSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerSheetTests.swift; sourceTree = ""; }; @@ -162,9 +180,12 @@ 0978D2A7323EF622D4A70908 /* UITests */ = { isa = PBXGroup; children = ( + B5DFA33FC914EF123D9DC165 /* AccessibilityUITests.swift */, 61BFD93FE933570FFE8E641D /* CovenMemoryLaunchUITests.swift */, + 410453CBC7C70D07996257A5 /* LocalizationUITests.swift */, 2771B98DC141C65BEF8C3F62 /* MemoryLibraryUITests.swift */, 07DCE5B6CC3E044D007EAFC1 /* MemoryReaderUITests.swift */, + F294977E73E540FF7601942D /* PerformanceUITests.swift */, ); path = UITests; sourceTree = ""; @@ -194,18 +215,11 @@ children = ( C6703A8B7A48791FA0E2B425 /* Assets.xcassets */, 49D2C54125AD93C6E60A458D /* Localizable.xcstrings */, + 85978EC4A5C84A3C63FF10FB /* PrivacyInfo.xcprivacy */, ); path = Resources; sourceTree = ""; }; - 33358EE008EB712295DFCA10 /* cave-canonical-memory */ = { - isa = PBXGroup; - children = ( - B6283A5547D14134BA6254E0 /* cave-overview-success.json */, - ); - path = "cave-canonical-memory"; - sourceTree = ""; - }; 52774688E282591A0D60A133 /* Pairing */ = { isa = PBXGroup; children = ( @@ -248,7 +262,7 @@ 8029443252897F0B8F8494D3 /* Fixtures */ = { isa = PBXGroup; children = ( - 33358EE008EB712295DFCA10 /* cave-canonical-memory */, + D5D537A067E9A8AAFD017B94 /* cave-mobile-memory-v1 */, B8C0CE3CB9A05B245E2EB0AD /* mobile-memory-v1 */, ); path = Fixtures; @@ -264,7 +278,9 @@ 1904FCA988D0CBA09C429487 /* LaunchCoordinatorTests.swift */, BFFCBAEE024EE9F7FE932E0E /* MemoryFiltersTests.swift */, 1FF6F418890331724303F335 /* MemoryLibraryStateTests.swift */, + 5C64CDC217410F459FA9FBD6 /* MemoryLifetimeTests.swift */, 268A26E2FF9BE8FFABD648FB /* MemoryMarkdownTests.swift */, + 3023D5490F560D01DCAEEC74 /* MemoryPerformanceTests.swift */, D806DED6D9B4CB8F8D26C522 /* MemoryPrivacyPolicyTests.swift */, 2EA5F4CEC4365998AFCD195A /* MemoryProvenanceTests.swift */, C39ACC107580B8A977848C6E /* MemoryReaderStateTests.swift */, @@ -336,6 +352,17 @@ path = Support; sourceTree = ""; }; + D5D537A067E9A8AAFD017B94 /* cave-mobile-memory-v1 */ = { + isa = PBXGroup; + children = ( + CB25B6ECCA05D657CDCD7D28 /* cave-detail-success.json */, + 819C8975B329096042056D94 /* cave-error-cases.json */, + CC89F0E2561BAAC5EDFFCFFE /* cave-list-success.json */, + 5B9BD2A3A05CD59744F89E41 /* cave-overview-success.json */, + ); + path = "cave-mobile-memory-v1"; + sourceTree = ""; + }; DD9200FCD7F3468A304DB688 /* App */ = { isa = PBXGroup; children = ( @@ -483,7 +510,10 @@ buildActionMask = 2147483647; files = ( 5D95EFEB8339B30A58EA5C52 /* capabilities-success.json in Resources */, - 5AE5518CEFE3CE26C74E1620 /* cave-overview-success.json in Resources */, + 31889551B1E815347A7E94A1 /* cave-detail-success.json in Resources */, + FCB971094A87E5FEE5750505 /* cave-error-cases.json in Resources */, + B8B88C1C438DC0A2EEE269DF /* cave-list-success.json in Resources */, + E3167B1DB368D931A57F5616 /* cave-overview-success.json in Resources */, 063E3A31FA276C27614CC258 /* detail-protected.json in Resources */, 2F1521113F8A719F2233B6D8 /* detail-public.json in Resources */, 6B1371A0708D6132D0550E36 /* error-cases.json in Resources */, @@ -499,6 +529,7 @@ files = ( 24D7674659E6747122576AE5 /* Assets.xcassets in Resources */, CF546EC8670A4B46914569DB /* Localizable.xcstrings in Resources */, + 2E2476D97E7008E22A737E04 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -517,7 +548,9 @@ BE0F9FC51225AC81459B32E2 /* LaunchCoordinatorTests.swift in Sources */, E285531EDFF3CB50515E19E3 /* MemoryFiltersTests.swift in Sources */, 6D027D64CDE49F3A0D56F210 /* MemoryLibraryStateTests.swift in Sources */, + 8A667F186BFB61AA1962CE42 /* MemoryLifetimeTests.swift in Sources */, 245F0F2F81C4F7B382D7CE64 /* MemoryMarkdownTests.swift in Sources */, + 2F990904188BF0E6B4FACFB1 /* MemoryPerformanceTests.swift in Sources */, B0D2336497008531D4992B0D /* MemoryPrivacyPolicyTests.swift in Sources */, 0F6882A8FC142F140514442D /* MemoryProvenanceTests.swift in Sources */, 1DF93F2FA166523B65E76ABA /* MemoryReaderStateTests.swift in Sources */, @@ -568,9 +601,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2C2186087894231B983E7C53 /* AccessibilityUITests.swift in Sources */, BF0D5C4C90EC02DBA3EE81E6 /* CovenMemoryLaunchUITests.swift in Sources */, + 7CD3F147D1A71EF16D2CD535 /* LocalizationUITests.swift in Sources */, 16275F5EBBCAC817808827DC /* MemoryLibraryUITests.swift in Sources */, 7CD2E641081E02A08197B977 /* MemoryReaderUITests.swift in Sources */, + 5318F6A75CFA190C086813DE /* PerformanceUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/CovenMemory/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/CovenMemory/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 4660bf9..f22e10c 100644 --- a/apps/ios/CovenMemory/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/CovenMemory/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -2,8 +2,8 @@ "images" : [ { "filename" : "AppIcon-1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", + "idiom" : "universal", + "platform" : "ios", "size" : "1024x1024" } ], diff --git a/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenAccent.colorset/Contents.json b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenAccent.colorset/Contents.json index d9950bd..ca69c1e 100644 --- a/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenAccent.colorset/Contents.json +++ b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenAccent.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.945", - "green" : "0.604", - "red" : "0.478" + "blue" : "0.718", + "green" : "0.345", + "red" : "0.239" } }, "idiom" : "universal" @@ -24,8 +24,8 @@ "components" : { "alpha" : "1.000", "blue" : "1.000", - "green" : "0.710", - "red" : "0.650" + "green" : "0.651", + "red" : "0.561" } }, "idiom" : "universal" diff --git a/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenFailure.colorset/Contents.json b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenFailure.colorset/Contents.json new file mode 100644 index 0000000..8b4ac16 --- /dev/null +++ b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenFailure.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.118", + "green" : "0.149", + "red" : "0.702" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.380", + "green" : "0.412", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenProminentForeground.colorset/Contents.json b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenProminentForeground.colorset/Contents.json new file mode 100644 index 0000000..0425637 --- /dev/null +++ b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenProminentForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenSecondary.colorset/Contents.json b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenSecondary.colorset/Contents.json new file mode 100644 index 0000000..6cfbb3c --- /dev/null +++ b/apps/ios/CovenMemory/Resources/Assets.xcassets/CovenSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.340", + "green" : "0.300", + "red" : "0.300" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.800", + "green" : "0.750", + "red" : "0.750" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/CovenMemory/Resources/PrivacyInfo.xcprivacy b/apps/ios/CovenMemory/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5397adc --- /dev/null +++ b/apps/ios/CovenMemory/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/apps/ios/CovenMemory/Sources/App/LaunchCoordinator.swift b/apps/ios/CovenMemory/Sources/App/LaunchCoordinator.swift index f36bd45..dba7807 100644 --- a/apps/ios/CovenMemory/Sources/App/LaunchCoordinator.swift +++ b/apps/ios/CovenMemory/Sources/App/LaunchCoordinator.swift @@ -519,7 +519,10 @@ private actor SessionInvalidatingCaveMemoryService: CaveMemoryServicing { try validity.check() return value } catch let error as NetworkError { - if hasServedDetail || detailRequestsInFlight > 0 { + if error == .authenticationRequired + || hasServedDetail + || detailRequestsInFlight > 0 + { await invalidateSession(for: error) } try validity.check() diff --git a/apps/ios/CovenMemory/Sources/App/LaunchViews.swift b/apps/ios/CovenMemory/Sources/App/LaunchViews.swift index c44a100..7d93186 100644 --- a/apps/ios/CovenMemory/Sources/App/LaunchViews.swift +++ b/apps/ios/CovenMemory/Sources/App/LaunchViews.swift @@ -87,7 +87,7 @@ private struct LaunchProgressView: View { ProgressView() .controlSize(.large) Text(message) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) } .multilineTextAlignment(.center) .padding(CovenTheme.regularMargin) @@ -162,7 +162,7 @@ private struct PairingView: View { + "then scan its QR code." ) .font(.body) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) .fixedSize(horizontal: false, vertical: true) } } @@ -175,6 +175,7 @@ private struct PairingView: View { scannerPresented = true } .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) .controlSize(.large) .frame( maxWidth: .infinity, @@ -185,7 +186,7 @@ private struct PairingView: View { if let scannerStatus { Text(scannerStatus) .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("scanner-status") } else if !QRScannerSheet.isSupported { @@ -194,7 +195,7 @@ private struct PairingView: View { + "Paste the invite link instead." ) .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) .fixedSize(horizontal: false, vertical: true) } } @@ -275,7 +276,7 @@ private struct ConnectionReadyView: View { Text(host) .font(.headline) Text("The native library interface is the next phase.") - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) ViewThatFits { HStack { actions @@ -300,6 +301,7 @@ private struct ConnectionReadyView: View { .controlSize(.large) Button("Lock", action: lock) .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) .controlSize(.large) } } @@ -320,11 +322,12 @@ private struct LaunchFailureView: View { Text(title) .font(.title2.bold()) Text(message) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) if canRetry { Button("Retry", action: retry) .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) .controlSize(.large) } if canRetry { @@ -334,6 +337,7 @@ private struct LaunchFailureView: View { } else { Button("Pair again", action: pairAgain) .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) .controlSize(.large) } } diff --git a/apps/ios/CovenMemory/Sources/DesignSystem/CovenTheme.swift b/apps/ios/CovenMemory/Sources/DesignSystem/CovenTheme.swift index 04884c9..f53b714 100644 --- a/apps/ios/CovenMemory/Sources/DesignSystem/CovenTheme.swift +++ b/apps/ios/CovenMemory/Sources/DesignSystem/CovenTheme.swift @@ -2,6 +2,9 @@ import SwiftUI enum CovenTheme { static let accent = Color("CovenAccent") + static let failure = Color("CovenFailure") + static let prominentForeground = Color("CovenProminentForeground") + static let secondary = Color("CovenSecondary") static let compactMargin: CGFloat = 16 static let regularMargin: CGFloat = 24 static let readerMaximumWidth: CGFloat = 720 diff --git a/apps/ios/CovenMemory/Sources/DesignSystem/ExceptionNotice.swift b/apps/ios/CovenMemory/Sources/DesignSystem/ExceptionNotice.swift index 1e12092..7cc3c96 100644 --- a/apps/ios/CovenMemory/Sources/DesignSystem/ExceptionNotice.swift +++ b/apps/ios/CovenMemory/Sources/DesignSystem/ExceptionNotice.swift @@ -19,7 +19,7 @@ struct ExceptionNotice: View { var color: Color { switch self { case .warning, .unavailable, .incompatible: .orange - case .failure: .red + case .failure: CovenTheme.failure } } } diff --git a/apps/ios/CovenMemory/Sources/DesignSystem/PrivacyCover.swift b/apps/ios/CovenMemory/Sources/DesignSystem/PrivacyCover.swift index a7ac7f5..83287b3 100644 --- a/apps/ios/CovenMemory/Sources/DesignSystem/PrivacyCover.swift +++ b/apps/ios/CovenMemory/Sources/DesignSystem/PrivacyCover.swift @@ -16,9 +16,10 @@ struct PrivacyCover: View { .font(.headline) Text("Memory is shown only while this app is unlocked.") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) Button("Unlock", action: unlock) .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) } .multilineTextAlignment(.center) .padding(CovenTheme.regularMargin) diff --git a/apps/ios/CovenMemory/Sources/Features/Library/MemoryLibraryView.swift b/apps/ios/CovenMemory/Sources/Features/Library/MemoryLibraryView.swift index 72a9b98..3ee698e 100644 --- a/apps/ios/CovenMemory/Sources/Features/Library/MemoryLibraryView.swift +++ b/apps/ios/CovenMemory/Sources/Features/Library/MemoryLibraryView.swift @@ -151,7 +151,7 @@ struct MemoryLibraryView: View { } ForEach(state.sections) { section in - Section(section.recency.title) { + Section { ForEach(section.summaries) { summary in NavigationLink(value: summary.id) { MemoryRow( @@ -160,6 +160,9 @@ struct MemoryLibraryView: View { ) } } + } header: { + Text(section.recency.title) + .foregroundStyle(CovenTheme.secondary) } } } @@ -219,6 +222,7 @@ struct MemoryLibraryView: View { settingsPresented = false pairAgain() } + .foregroundStyle(CovenTheme.failure) Button("Lock") { settingsPresented = false lock() @@ -291,6 +295,7 @@ struct MemoryLibraryView: View { Task { await state.refresh() } } .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) } Button("View Memory Health", action: showMemoryHealth) .buttonStyle(.bordered) diff --git a/apps/ios/CovenMemory/Sources/Features/Library/MemoryRow.swift b/apps/ios/CovenMemory/Sources/Features/Library/MemoryRow.swift index 7175795..e9d2ecc 100644 --- a/apps/ios/CovenMemory/Sources/Features/Library/MemoryRow.swift +++ b/apps/ios/CovenMemory/Sources/Features/Library/MemoryRow.swift @@ -14,13 +14,13 @@ struct MemoryRow: View { Text("\(summary.familiarId) · \(summary.relativeUpdatedAt)") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) .lineLimit(1) if let matchingContext { Text(matchingContext) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) .lineLimit(1) .accessibilityIdentifier("search-context") } diff --git a/apps/ios/CovenMemory/Sources/Features/Reader/MemoryProvenanceView.swift b/apps/ios/CovenMemory/Sources/Features/Reader/MemoryProvenanceView.swift index 5432b75..7ff6363 100644 --- a/apps/ios/CovenMemory/Sources/Features/Reader/MemoryProvenanceView.swift +++ b/apps/ios/CovenMemory/Sources/Features/Reader/MemoryProvenanceView.swift @@ -7,13 +7,15 @@ struct MemoryProvenanceView: View { var body: some View { Group { - Section("Capabilities") { + Section { ForEach(Self.capabilityRows(capabilities)) { row in LabeledContent(row.label, value: row.status) } + } header: { + header("Capabilities") } - Section("Source") { + Section { LabeledContent("Label", value: metadata.source.label) LabeledContent("Kind", value: metadata.source.kind) LabeledContent( @@ -23,9 +25,11 @@ struct MemoryProvenanceView: View { time: .shortened ) ) + } header: { + header("Source") } - Section("Verification") { + Section { switch Self.verificationAvailability(capabilities) { case .available: LabeledContent( @@ -44,9 +48,11 @@ struct MemoryProvenanceView: View { "verification-capability-unavailable" ) } + } header: { + header("Verification") } - Section("Attestation metadata") { + Section { switch Self.attestationAvailability(capabilities) { case .available: LabeledContent( @@ -63,9 +69,11 @@ struct MemoryProvenanceView: View { "attestation-capability-unavailable" ) } + } header: { + header("Attestation metadata") } - Section("Supersession history") { + Section { switch Self.supersessionAvailability(capabilities) { case .available: if let newer = metadata.supersession.supersededBy { @@ -93,10 +101,17 @@ struct MemoryProvenanceView: View { "supersession-capability-unavailable" ) } + } header: { + header("Supersession history") } } } + private func header(_ title: LocalizedStringKey) -> some View { + Text(title) + .foregroundStyle(CovenTheme.secondary) + } + static func capabilityRows( _ capabilities: MemoryCapabilities? ) -> [MemoryCapabilityRow] { diff --git a/apps/ios/CovenMemory/Sources/Features/Reader/MemoryReaderView.swift b/apps/ios/CovenMemory/Sources/Features/Reader/MemoryReaderView.swift index c53120f..a5e1478 100644 --- a/apps/ios/CovenMemory/Sources/Features/Reader/MemoryReaderView.swift +++ b/apps/ios/CovenMemory/Sources/Features/Reader/MemoryReaderView.swift @@ -394,11 +394,13 @@ struct MemoryReaderView: View { Text("Reveal hides when the app locks or you navigate away.") .accessibilityIdentifier("memory-reveal-lifecycle-notice") } + .foregroundStyle(CovenTheme.secondary) } actions: { Button("Reveal memory") { Task { await state.reveal() } } .buttonStyle(.borderedProminent) + .foregroundStyle(CovenTheme.prominentForeground) .controlSize(.large) .frame(minHeight: CovenTheme.minimumTarget) } @@ -436,7 +438,7 @@ struct MemoryReaderView: View { "\(detail.familiarId) · \(detail.updatedAt.formatted(date: .abbreviated, time: .shortened))" ) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(CovenTheme.secondary) switch displayMode { case .rendered: @@ -464,13 +466,16 @@ struct MemoryReaderView: View { ) -> some View { NavigationStack { List { - Section("Display") { + Section { Picker("Memory display", selection: $displayMode) { ForEach(MemoryReaderDisplayMode.allCases) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.segmented) + } header: { + Text("Display") + .foregroundStyle(CovenTheme.secondary) } MemoryProvenanceView( diff --git a/apps/ios/CovenMemory/Sources/Features/Settings/MemoryHealthView.swift b/apps/ios/CovenMemory/Sources/Features/Settings/MemoryHealthView.swift index a88e08e..dd99c54 100644 --- a/apps/ios/CovenMemory/Sources/Features/Settings/MemoryHealthView.swift +++ b/apps/ios/CovenMemory/Sources/Features/Settings/MemoryHealthView.swift @@ -11,14 +11,14 @@ struct MemoryHealthView: View { Label(message(for: issue), systemImage: symbol(for: issue)) .foregroundStyle( issue == .malformed || issue == .degraded - ? .red + ? CovenTheme.failure : .orange ) } } if let overview { - Section("Availability") { + Section { LabeledContent("Memory details") { Text( overview.capabilities.detail @@ -35,25 +35,31 @@ struct MemoryHealthView: View { ) .accessibilityIdentifier("memory-verification-state") } + } header: { + header("Availability") } - Section("Last check") { + Section { Text( overview.generatedAt.formatted( date: .abbreviated, time: .shortened ) ) + } header: { + header("Last check") } if !overview.verification.issues.isEmpty { - Section("Attention") { + Section { ForEach( Array(overview.verification.issues.enumerated()), id: \.offset ) { _, item in Text(item) } + } header: { + header("Attention") } } } else if issue == nil { @@ -66,6 +72,11 @@ struct MemoryHealthView: View { .navigationTitle("Memory Health") } + private func header(_ title: LocalizedStringKey) -> some View { + Text(title) + .foregroundStyle(CovenTheme.secondary) + } + private func message(for issue: MemoryLibraryIssue) -> String { switch issue { case .offline: diff --git a/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryFilters.swift b/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryFilters.swift index b42e6dd..0b01f4b 100644 --- a/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryFilters.swift +++ b/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryFilters.swift @@ -28,37 +28,92 @@ struct MemoryFilter: Sendable, Equatable { } func apply(to summaries: [MemorySummary], now: Date) -> [MemorySummary] { - summaries.filter { matches($0, now: now) } + let normalizedQuery = query.map(MemorySearchIndex.normalize) + let calendar = Calendar(identifier: .gregorian) + let startOfToday = calendar.startOfDay(for: now) + let startOfTomorrow = calendar.date( + byAdding: .day, + value: 1, + to: startOfToday + ) ?? now + let sevenDaysAgo = calendar.date( + byAdding: .day, + value: -7, + to: now + ) ?? now + + return summaries.filter { summary in + matches( + summary, + normalizedQuery: normalizedQuery, + startOfToday: startOfToday, + startOfTomorrow: startOfTomorrow, + sevenDaysAgo: sevenDaysAgo + ) + } } func matches(_ summary: MemorySummary, now: Date) -> Bool { - if let familiarId, summary.familiarId.caseInsensitiveCompare(familiarId) != .orderedSame { return false } - if let sourceKind, summary.source.kind.caseInsensitiveCompare(sourceKind) != .orderedSame { return false } - if let verification, summary.verification.state != verification { return false } - if let freshness, !matches(freshness, updatedAt: summary.updatedAt, now: now) { return false } - if let query, !queryFields(for: summary).contains(where: { normalize($0).contains(normalize(query)) }) { return false } - return true + let calendar = Calendar(identifier: .gregorian) + let startOfToday = calendar.startOfDay(for: now) + return matches( + summary, + normalizedQuery: query.map(MemorySearchIndex.normalize), + startOfToday: startOfToday, + startOfTomorrow: calendar.date( + byAdding: .day, + value: 1, + to: startOfToday + ) ?? now, + sevenDaysAgo: calendar.date( + byAdding: .day, + value: -7, + to: now + ) ?? now + ) } - private func queryFields(for summary: MemorySummary) -> [String] { - [summary.familiarId, summary.title, summary.relativeUpdatedAt, summary.excerpt, summary.source.kind, summary.source.label, summary.verification.state.rawValue] + private func matches( + _ summary: MemorySummary, + normalizedQuery: String?, + startOfToday: Date, + startOfTomorrow: Date, + sevenDaysAgo: Date + ) -> Bool { + if let familiarId, + summary.familiarId != familiarId, + summary.familiarId.caseInsensitiveCompare(familiarId) != .orderedSame { return false } + if let sourceKind, + summary.source.kind != sourceKind, + summary.source.kind.caseInsensitiveCompare(sourceKind) != .orderedSame { return false } + if let verification, summary.verification.state != verification { return false } + if let freshness, + !matches( + freshness, + updatedAt: summary.updatedAt, + startOfToday: startOfToday, + startOfTomorrow: startOfTomorrow, + sevenDaysAgo: sevenDaysAgo + ) { return false } + if let normalizedQuery, + !summary.normalizedSearchText.contains(normalizedQuery) { return false } + return true } - private func matches(_ freshness: Freshness, updatedAt: Date, now: Date) -> Bool { - let calendar = Calendar(identifier: .gregorian) + private func matches( + _ freshness: Freshness, + updatedAt: Date, + startOfToday: Date, + startOfTomorrow: Date, + sevenDaysAgo: Date + ) -> Bool { switch freshness { case .today: - return calendar.isDate(updatedAt, inSameDayAs: now) + return updatedAt >= startOfToday && updatedAt < startOfTomorrow case .previousSevenDays: - guard let start = calendar.date(byAdding: .day, value: -7, to: now) else { return false } - return updatedAt >= start && !calendar.isDate(updatedAt, inSameDayAs: now) + return updatedAt >= sevenDaysAgo && updatedAt < startOfToday case .older: - guard let start = calendar.date(byAdding: .day, value: -7, to: now) else { return false } - return updatedAt < start + return updatedAt < sevenDaysAgo } } - - private func normalize(_ value: String) -> String { - value.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: .current) - } } diff --git a/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryMarkdown.swift b/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryMarkdown.swift index 3aa79c0..b2105e4 100644 --- a/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryMarkdown.swift +++ b/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryMarkdown.swift @@ -90,13 +90,16 @@ enum MemoryMarkdown { isCancelled: () -> Bool ) -> MemoryMarkdownDocument? { guard !isCancelled() else { return nil } - guard !source.unicodeScalars.contains(where: { $0.value == 0 }) else { + guard !source.utf8.contains(0) else { return fallback(source) } - let lines = source - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") + let normalizedSource = source.utf8.contains(13) + ? source + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + : source + let lines = normalizedSource .split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) @@ -122,12 +125,13 @@ enum MemoryMarkdown { private static func parseBlocks( _ lines: [String], + blockquoteDepth: Int = 0, isCancelled: () -> Bool ) throws -> [MemoryMarkdownBlock] { var frames = [ try blockFrame( lines: lines, - blockquoteDepth: 0, + blockquoteDepth: blockquoteDepth, isCancelled: isCancelled ) ] @@ -203,6 +207,22 @@ enum MemoryMarkdown { if frame.blockquoteDepth < maximumBlockquoteNestingDepth, blockquoteText(line) != nil { + let nextIndex = frame.index + 1 + if nextIndex == frame.lines.count + || blockquoteText(frame.lines[nextIndex]) == nil + { + frame.blocks.append( + try collapsedSingleLineBlockquote( + line, + blockquoteDepth: frame.blockquoteDepth, + isCancelled: isCancelled + ) + ) + frame.index += 1 + frames.append(frame) + continue + } + var quoteLines: [String] = [] while frame.index < frame.lines.count, let quoted = blockquoteText(frame.lines[frame.index]) @@ -396,6 +416,15 @@ enum MemoryMarkdown { } } + if !bytes.isEmpty, + asteriskPositions.isEmpty, + underscorePositions.isEmpty, + closingBracketPositions.isEmpty, + backtickPositions.isEmpty + { + return [InlineRun(text: value)] + } + var runs: [InlineRun] = [] var plainStart = 0 var index = 0 @@ -658,6 +687,62 @@ enum MemoryMarkdown { .trimmingCharacters(in: .whitespaces) } + private static func collapsedSingleLineBlockquote( + _ line: String, + blockquoteDepth: Int, + isCancelled: () -> Bool + ) throws -> MemoryMarkdownBlock { + var remainder = line[...] + var consumedDepth = 0 + + while blockquoteDepth + consumedDepth + < maximumBlockquoteNestingDepth, + let quoted = blockquoteSlice(remainder) + { + remainder = quoted + consumedDepth += 1 + } + + let nested = try parseBlocks( + [String(remainder)], + blockquoteDepth: blockquoteDepth + consumedDepth, + isCancelled: isCancelled + ) + var block = MemoryMarkdownBlock.blockquote(nested) + for _ in 1.. Substring? { + var start = line.startIndex + while start < line.endIndex, + line[start] == " " || line[start] == "\t" + { + start = line.index(after: start) + } + guard start < line.endIndex, line[start] == ">" else { return nil } + start = line.index(after: start) + while start < line.endIndex, isMarkdownWhitespace(line[start]) { + start = line.index(after: start) + } + + var end = line.endIndex + while end > start { + let candidate = line.index(before: end) + guard isMarkdownWhitespace(line[candidate]) else { break } + end = candidate + } + return line[start.. Bool { + character.unicodeScalars.allSatisfy(CharacterSet.whitespaces.contains) + } + private static func unorderedItem(_ line: String) -> String? { let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) guard trimmed.count >= 2, diff --git a/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryModels.swift b/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryModels.swift index fde9a56..ac177b9 100644 --- a/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryModels.swift +++ b/apps/ios/CovenMemory/Sources/MemoryDomain/MemoryModels.swift @@ -89,6 +89,7 @@ struct MemorySummary: Codable, Identifiable, Hashable, Sendable { let source: MemorySource let privacy: MemoryPrivacySummary let verification: MemoryVerificationSummary + let normalizedSearchText: String init( id: UUID, @@ -110,6 +111,15 @@ struct MemorySummary: Codable, Identifiable, Hashable, Sendable { self.source = source self.privacy = privacy self.verification = verification + normalizedSearchText = MemorySearchIndex.make( + familiarId, + title, + relativeUpdatedAt, + excerpt, + source.kind, + source.label, + verification.state.rawValue + ) } private enum CodingKeys: String, CodingKey { @@ -129,6 +139,28 @@ struct MemorySummary: Codable, Identifiable, Hashable, Sendable { source = try container.decode(MemorySource.self, forKey: .source) privacy = try container.decode(MemoryPrivacySummary.self, forKey: .privacy) verification = try container.decode(MemoryVerificationSummary.self, forKey: .verification) + normalizedSearchText = MemorySearchIndex.make( + familiarId, + title, + relativeUpdatedAt, + excerpt, + source.kind, + source.label, + verification.state.rawValue + ) + } +} + +enum MemorySearchIndex { + static func normalize(_ value: String) -> String { + value.folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: .current + ) + } + + static func make(_ fields: String...) -> String { + normalize(fields.joined(separator: "\u{0}")) } } diff --git a/apps/ios/CovenMemory/Tests/Contract/MobileContractTests.swift b/apps/ios/CovenMemory/Tests/Contract/MobileContractTests.swift index 5e9a5f4..00dd6ad 100644 --- a/apps/ios/CovenMemory/Tests/Contract/MobileContractTests.swift +++ b/apps/ios/CovenMemory/Tests/Contract/MobileContractTests.swift @@ -4,11 +4,26 @@ import Testing @Suite("Mobile contract") struct MobileContractTests { + private struct CaveListEnvelope: Decodable { + let ok: Bool + let entries: [MemorySummary] + } + private struct CaveOverviewEnvelope: Decodable { let ok: Bool let overview: MemoryOverview } + private struct CaveDetailEnvelope: Decodable { + let ok: Bool + let entry: MemoryDetail + } + + private struct CaveErrorEnvelope: Decodable { + let ok: Bool + let code: String + } + @Test("Decodes v1 synthetic detail") func decodesDetail() throws { let detail: MemoryDetail = try legacyFixturePayload( @@ -37,7 +52,7 @@ struct MobileContractTests { func decodesCaveOverviewNullableFields() throws { let envelope = try JSONDecoder.mobile.decode( CaveOverviewEnvelope.self, - from: Fixture.data("cave-overview-success.json") + from: caveOverviewData(settingNullableFieldsToNull: true) ) #expect(envelope.ok) @@ -46,6 +61,34 @@ struct MobileContractTests { #expect(envelope.overview.verification.index == nil) } + @Test("Decodes the shared Cave mobile response fixtures") + func decodesSharedCaveMobileFixtures() throws { + let list = try JSONDecoder.mobile.decode( + CaveListEnvelope.self, + from: Fixture.data("cave-list-success.json") + ) + let overview = try JSONDecoder.mobile.decode( + CaveOverviewEnvelope.self, + from: Fixture.data("cave-overview-success.json") + ) + let detail = try JSONDecoder.mobile.decode( + CaveDetailEnvelope.self, + from: Fixture.data("cave-detail-success.json") + ) + let errors = try JSONDecoder.mobile.decode( + [CaveErrorEnvelope].self, + from: Fixture.data("cave-error-cases.json") + ) + + #expect(list.ok) + #expect(overview.ok) + #expect(detail.ok) + #expect(list.entries.count == overview.overview.totals.entries) + #expect(detail.entry.id == list.entries.first?.id) + #expect(errors.allSatisfy { !$0.ok }) + #expect(Set(errors.map(\.code)).contains("mobile_access_required")) + } + @Test("Rejects missing required nullable Cave overview fields", arguments: [ "lastUpdatedAt", "manifest", @@ -193,7 +236,11 @@ struct MobileContractTests { return try JSONDecoder.mobile.decode(Value.self, from: payloadData) } - private func caveOverviewData(removing field: String? = nil, issues: [String]? = nil) throws -> Data { + private func caveOverviewData( + removing field: String? = nil, + issues: [String]? = nil, + settingNullableFieldsToNull: Bool = false + ) throws -> Data { guard var root = try JSONSerialization.jsonObject( with: Fixture.data("cave-overview-success.json") ) as? [String: Any], @@ -220,6 +267,11 @@ struct MobileContractTests { if let issues { verification["issues"] = issues } + if settingNullableFieldsToNull { + overview["lastUpdatedAt"] = NSNull() + verification["manifest"] = NSNull() + verification["index"] = NSNull() + } overview["verification"] = verification root["overview"] = overview return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) diff --git a/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-detail-success.json b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-detail-success.json new file mode 100644 index 0000000..2c62bdb --- /dev/null +++ b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-detail-success.json @@ -0,0 +1,29 @@ +{ + "ok": true, + "entry": { + "id": "11111111-1111-5111-8111-111111111111", + "familiarId": "fixture-familiar", + "title": "fixture-note", + "updatedAt": "2026-07-26T09:56:00Z", + "source": { + "kind": "coven-origin", + "label": "Coven origin" + }, + "content": "Synthetic detail.", + "contentFormat": "markdown", + "privacy": { + "classification": null, + "revealRequired": null, + "reason": "privacy taxonomy unavailable" + }, + "verification": { + "state": "unknown", + "reason": "verification metadata unavailable" + }, + "attestationMetadata": null, + "supersession": { + "supersedes": null, + "supersededBy": null + } + } +} diff --git a/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-error-cases.json b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-error-cases.json new file mode 100644 index 0000000..04413c5 --- /dev/null +++ b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-error-cases.json @@ -0,0 +1,30 @@ +[ + { + "ok": false, + "code": "mobile_access_required" + }, + { + "ok": false, + "code": "local_daemon_required" + }, + { + "ok": false, + "code": "canonical_memory_unavailable" + }, + { + "ok": false, + "code": "capability_unavailable" + }, + { + "ok": false, + "code": "daemon_update_required" + }, + { + "ok": false, + "code": "invalid_daemon_payload" + }, + { + "ok": false, + "code": "memory_not_found" + } +] diff --git a/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-list-success.json b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-list-success.json new file mode 100644 index 0000000..997a13f --- /dev/null +++ b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-list-success.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "entries": [ + { + "id": "11111111-1111-5111-8111-111111111111", + "familiarId": "fixture-familiar", + "title": "fixture-note", + "updatedAt": "2026-07-26T09:56:00Z", + "relativeUpdatedAt": "4m ago", + "excerpt": "Synthetic summary.", + "source": { + "kind": "coven-origin", + "label": "Coven origin" + }, + "privacy": { + "classification": null, + "revealRequired": null + }, + "verification": { + "state": "unknown" + } + } + ] +} diff --git a/apps/ios/CovenMemory/Tests/Fixtures/cave-canonical-memory/cave-overview-success.json b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-overview-success.json similarity index 71% rename from apps/ios/CovenMemory/Tests/Fixtures/cave-canonical-memory/cave-overview-success.json rename to apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-overview-success.json index 55f9165..b04938c 100644 --- a/apps/ios/CovenMemory/Tests/Fixtures/cave-canonical-memory/cave-overview-success.json +++ b/apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/cave-overview-success.json @@ -1,15 +1,15 @@ { "ok": true, "overview": { - "generatedAt": "2026-07-29T12:00:00.000Z", + "generatedAt": "2026-07-26T10:00:00Z", "totals": { - "entries": 2, + "entries": 1, "familiars": 1, - "verified": 1, + "verified": 0, "needsReview": 0, "unknown": 1 }, - "lastUpdatedAt": null, + "lastUpdatedAt": "2026-07-26T09:56:00Z", "capabilities": { "detail": true, "verification": false, @@ -19,7 +19,7 @@ }, "verification": { "state": "unavailable", - "checkedAt": "2026-07-29T12:00:00.000Z", + "checkedAt": "2026-07-26T10:00:00Z", "manifest": null, "index": null, "issues": [] diff --git a/apps/ios/CovenMemory/Tests/Unit/CaveMemoryInviteTests.swift b/apps/ios/CovenMemory/Tests/Unit/CaveMemoryInviteTests.swift index 4123fa6..4d1495c 100644 --- a/apps/ios/CovenMemory/Tests/Unit/CaveMemoryInviteTests.swift +++ b/apps/ios/CovenMemory/Tests/Unit/CaveMemoryInviteTests.swift @@ -7,16 +7,12 @@ struct CaveMemoryInviteTests { @Test("Uses only the mobile token from Cave's real two-token QR") func parsesRealCaveQRShape() throws { let invite = try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net/memory/open\ - ?coven_access_token=v1.1785326700000.fake.fake\ - &covenCaveToken=discard-me#chat - """ + rawValue: "https://cave.example.ts.net/memory/open?coven_access_token=v1.1785326700000.fake.fake&covenCaveToken=discard-me#chat" // gitleaks:allow — synthetic invite ) #expect( invite.connection.baseURL - == URL(string: "https://cave.example.ts.net") + == URL(string: "https://cave.example.ts.net") // gitleaks:allow — synthetic test endpoint ) #expect( invite.connection.accessToken @@ -29,15 +25,12 @@ struct CaveMemoryInviteTests { @Test("Preserves only scheme host and port in the persisted base URL") func normalizesBaseURL() throws { let invite = try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net:8443/private/path\ - ?coven_access_token=mobile-token&other=ignored#fragment - """ + rawValue: "https://cave.example.ts.net:8443/private/path?coven_access_token=mobile-token&other=ignored#fragment" // gitleaks:allow — synthetic invite ) #expect( invite.connection.baseURL - == URL(string: "https://cave.example.ts.net:8443") + == URL(string: "https://cave.example.ts.net:8443") // gitleaks:allow — synthetic test endpoint ) #expect(invite.connection.baseURL.path.isEmpty) #expect(invite.connection.baseURL.query == nil) @@ -49,23 +42,17 @@ struct CaveMemoryInviteTests { let oversizedURL = Self.invite(totalByteCount: 8_193) let unsafeInputs = [ "", - "https://cave.example.ts.net/", + "https://cave.example.ts.net/", // gitleaks:allow — synthetic test endpoint "https://cave.example.ts.net/?coven_access_token", // gitleaks:allow synthetic malformed invite "https://cave.example.ts.net/?coven_access_token=", // gitleaks:allow synthetic malformed invite - """ - https://cave.example.ts.net/\ - ?coven_access_token=a&coven_access_token=b - """, - """ - https://cave.example.ts.net/\ - ?coven_access_token&coven_access_token=b - """, + "https://cave.example.ts.net/?coven_access_token=a&coven_access_token=b", // gitleaks:allow — synthetic malformed invite + "https://cave.example.ts.net/?coven_access_token&coven_access_token=b", // gitleaks:allow — synthetic malformed invite "http://cave.example.ts.net/?coven_access_token=a", // gitleaks:allow synthetic rejected invite - "/?coven_access_token=a", - "https:///?coven_access_token=a", + "/?coven_access_token=a", // gitleaks:allow — synthetic malformed invite + "https:///?coven_access_token=a", // gitleaks:allow — synthetic malformed invite "https://user@cave.example.ts.net/?coven_access_token=a", // gitleaks:allow synthetic rejected invite "https://user:password@cave.example.ts.net/?coven_access_token=a", // gitleaks:allow synthetic rejected invite - "https://cave.example.ts.net/?covenCaveToken=sidecar", + "https://cave.example.ts.net/?covenCaveToken=sidecar", // gitleaks:allow — synthetic test endpoint oversizedURL, ] @@ -101,10 +88,7 @@ struct CaveMemoryInviteTests { func enforcesPortBounds() throws { for port in [1, 65_535] { let invite = try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net:\(port)/\ - ?coven_access_token=a - """ + rawValue: "https://cave.example.ts.net:\(port)/?coven_access_token=a" // gitleaks:allow — synthetic invite ) #expect(invite.connection.baseURL.port == port) } @@ -112,10 +96,7 @@ struct CaveMemoryInviteTests { for port in ["0", "65536", "999999999999999999999"] { #expect(throws: CaveMemoryInviteError.self) { _ = try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net:\(port)/\ - ?coven_access_token=a - """ + rawValue: "https://cave.example.ts.net:\(port)/?coven_access_token=a" // gitleaks:allow — synthetic invite ) } } @@ -129,18 +110,12 @@ struct CaveMemoryInviteTests { #expect(maximumToken.utf8.count == CaveMemoryInvite.maximumTokenBytes) #expect( try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net/\ - ?coven_access_token=\(maximumToken) - """ + rawValue: "https://cave.example.ts.net/?coven_access_token=\(maximumToken)" // gitleaks:allow — synthetic invite ).connection.accessToken == maximumToken ) #expect(throws: CaveMemoryInviteError.self) { _ = try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net/\ - ?coven_access_token=\(oversizedToken) - """ + rawValue: "https://cave.example.ts.net/?coven_access_token=\(oversizedToken)" // gitleaks:allow — synthetic invite ) } } @@ -154,18 +129,12 @@ struct CaveMemoryInviteTests { #expect(maximumToken.utf8.count == 4_096) #expect( try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net/\ - ?coven_access_token=\(maximumToken) - """ + rawValue: "https://cave.example.ts.net/?coven_access_token=\(maximumToken)" // gitleaks:allow — synthetic invite ).connection.accessToken == maximumToken ) #expect(throws: CaveMemoryInviteError.self) { _ = try CaveMemoryInvite( - rawValue: """ - https://cave.example.ts.net/\ - ?coven_access_token=\(oversizedToken) - """ + rawValue: "https://cave.example.ts.net/?coven_access_token=\(oversizedToken)" // gitleaks:allow — synthetic invite ) } } @@ -196,8 +165,8 @@ struct CaveMemoryInviteTests { } private static func invite(totalByteCount: Int) -> String { - let suffix = "?coven_access_token=a" - let prefix = "https://cave.example.ts.net/" + let suffix = "?coven_access_token=a" // gitleaks:allow — synthetic invite component + let prefix = "https://cave.example.ts.net/" // gitleaks:allow — synthetic test endpoint precondition(totalByteCount >= prefix.utf8.count + suffix.utf8.count) let pathByteCount = totalByteCount - prefix.utf8.count diff --git a/apps/ios/CovenMemory/Tests/Unit/CaveMemoryTransportTests.swift b/apps/ios/CovenMemory/Tests/Unit/CaveMemoryTransportTests.swift index 76cba50..3e77e17 100644 --- a/apps/ios/CovenMemory/Tests/Unit/CaveMemoryTransportTests.swift +++ b/apps/ios/CovenMemory/Tests/Unit/CaveMemoryTransportTests.swift @@ -20,11 +20,11 @@ struct CaveMemoryTransportTests { let overview = try await transport.overview() - #expect(overview.totals.entries == 2) + #expect(overview.totals.entries == 1) let request = await client.lastRequest #expect( request?.url?.absoluteString - == "https://cave.example.ts.net/api/mobile/coven-memory/overview" + == "https://cave.example.ts.net/api/mobile/coven-memory/overview" // gitleaks:allow — synthetic expected URL ) #expect(request?.httpMethod == "GET") #expect(request?.timeoutInterval == 20) @@ -71,13 +71,13 @@ struct CaveMemoryTransportTests { ), CaveMemoryConnection( baseURL: URL( - string: "https://user@cave.example.ts.net" + string: "https://user@cave.example.ts.net" // gitleaks:allow — synthetic rejected URL )!, accessToken: Self.accessToken ), CaveMemoryConnection( baseURL: URL( - string: "https://cave.example.ts.net/private" + string: "https://cave.example.ts.net/private" // gitleaks:allow — synthetic rejected URL )!, accessToken: Self.accessToken ), @@ -88,7 +88,7 @@ struct CaveMemoryTransportTests { accessToken: Self.accessToken ), CaveMemoryConnection( - baseURL: URL(string: "https://cave.example.ts.net")!, + baseURL: URL(string: "https://cave.example.ts.net")!, // gitleaks:allow — synthetic test endpoint accessToken: "" ), ] @@ -133,7 +133,7 @@ struct CaveMemoryTransportTests { let request = await client.lastRequest #expect( request?.url?.absoluteString - == "https://cave.example.ts.net/api/mobile/coven-memory" + == "https://cave.example.ts.net/api/mobile/coven-memory" // gitleaks:allow — synthetic expected URL ) #expect(request?.httpMethod == "GET") #expect(request?.url?.query == nil) @@ -163,7 +163,7 @@ struct CaveMemoryTransportTests { let request = await client.lastRequest #expect( request?.url?.absoluteString - == "https://cave.example.ts.net\(path)" + == "https://cave.example.ts.net\(path)" // gitleaks:allow — synthetic expected URL ) #expect(request?.httpMethod == "GET") #expect(request?.url?.query == nil) @@ -461,7 +461,7 @@ struct CaveMemoryTransportTests { @Test("A non-HTTP response is rejected") func rejectsNonHTTPResponse() async { let url = URL( - string: "https://cave.example.ts.net/api/mobile/coven-memory/overview" + string: "https://cave.example.ts.net/api/mobile/coven-memory/overview" // gitleaks:allow — synthetic expected URL )! let client = RecordingCaveHTTPClient( data: Data(#"{"ok":true}"#.utf8), @@ -498,7 +498,7 @@ struct CaveMemoryTransportTests { @Test("The URL session delegate declines redirect requests") func redirectDelegateDeclinesRedirect() async { let sourceURL = URL( - string: "https://cave.example.ts.net/api/mobile/coven-memory" + string: "https://cave.example.ts.net/api/mobile/coven-memory" // gitleaks:allow — synthetic expected URL )! let destinationURL = URL( string: "https://other.example.invalid/" @@ -692,7 +692,7 @@ struct CaveMemoryTransportTests { "v1.1785326700000.synthetic.fake" private static let connection = CaveMemoryConnection( - baseURL: URL(string: "https://cave.example.ts.net")!, + baseURL: URL(string: "https://cave.example.ts.net")!, // gitleaks:allow — synthetic test endpoint accessToken: accessToken ) @@ -741,7 +741,7 @@ struct CaveMemoryTransportTests { headers: [String: String]? = nil ) -> HTTPURLResponse { HTTPURLResponse( - url: URL(string: "https://cave.example.ts.net\(path)")!, + url: URL(string: "https://cave.example.ts.net\(path)")!, // gitleaks:allow — synthetic expected URL statusCode: status, httpVersion: nil, headerFields: headers diff --git a/apps/ios/CovenMemory/Tests/Unit/CredentialVaultTests.swift b/apps/ios/CovenMemory/Tests/Unit/CredentialVaultTests.swift index f10e897..a899e95 100644 --- a/apps/ios/CovenMemory/Tests/Unit/CredentialVaultTests.swift +++ b/apps/ios/CovenMemory/Tests/Unit/CredentialVaultTests.swift @@ -17,7 +17,7 @@ struct CredentialVaultTests { let keychain = TestKeychain() let vault = CredentialVault(keychain: keychain) let first = CaveMemoryConnection( - baseURL: URL(string: "https://cave.example.ts.net")!, + baseURL: URL(string: "https://cave.example.ts.net")!, // gitleaks:allow — synthetic test endpoint accessToken: "v1.1785326700000.first.fake" ) let refreshed = CaveMemoryConnection( @@ -119,21 +119,21 @@ struct CredentialVaultTests { ("file:///tmp/cave", "token"), ("cave.example.ts.net", "token"), ("https:///", "token"), - ("https://cave.example.ts.net/", "token"), - ("https://cave.example.ts.net/memory", "token"), - ("https://cave.example.ts.net?query=secret", "token"), - ("https://cave.example.ts.net#fragment", "token"), - ("https://user@cave.example.ts.net", "token"), - ("https://user:password@cave.example.ts.net", "token"), - ("https://cave.example.ts.net:0", "token"), - ("https://cave.example.ts.net:65536", "token"), + ("https://cave.example.ts.net/", "token"), // gitleaks:allow — synthetic test endpoint + ("https://cave.example.ts.net/memory", "token"), // gitleaks:allow — synthetic test endpoint + ("https://cave.example.ts.net?query=secret", "token"), // gitleaks:allow — synthetic test endpoint + ("https://cave.example.ts.net#fragment", "token"), // gitleaks:allow — synthetic test endpoint + ("https://user@cave.example.ts.net", "token"), // gitleaks:allow — synthetic test endpoint + ("https://user:password@cave.example.ts.net", "token"), // gitleaks:allow — synthetic test endpoint + ("https://cave.example.ts.net:0", "token"), // gitleaks:allow — synthetic test endpoint + ("https://cave.example.ts.net:65536", "token"), // gitleaks:allow — synthetic test endpoint ( - "https://cave.example.ts.net:999999999999999999999", + "https://cave.example.ts.net:999999999999999999999", // gitleaks:allow — synthetic test endpoint "token" ), ("https://\(oversizedHost)", "token"), - ("https://cave.example.ts.net", ""), - ("https://cave.example.ts.net", oversizedToken), + ("https://cave.example.ts.net", ""), // gitleaks:allow — synthetic test endpoint + ("https://cave.example.ts.net", oversizedToken), // gitleaks:allow — synthetic test endpoint ] for (baseURL, accessToken) in invalidConnections { @@ -245,7 +245,7 @@ private actor SuspendedReadKeychain: CredentialDataStoring { private enum TestFixtures { static let connection = CaveMemoryConnection( - baseURL: URL(string: "https://cave.example.ts.net")!, + baseURL: URL(string: "https://cave.example.ts.net")!, // gitleaks:allow — synthetic test endpoint accessToken: "v1.1785326700000.fake.fake" ) } diff --git a/apps/ios/CovenMemory/Tests/Unit/LaunchCoordinatorTests.swift b/apps/ios/CovenMemory/Tests/Unit/LaunchCoordinatorTests.swift index fc64e9d..0f7ef1f 100644 --- a/apps/ios/CovenMemory/Tests/Unit/LaunchCoordinatorTests.swift +++ b/apps/ios/CovenMemory/Tests/Unit/LaunchCoordinatorTests.swift @@ -28,7 +28,7 @@ struct LaunchCoordinatorTests { await coordinator.start() await coordinator.submitInvite( - "https://cave.example.ts.net/?covenCaveToken=sidecar" + "https://cave.example.ts.net/?covenCaveToken=sidecar" // gitleaks:allow — synthetic test endpoint ) #expect(coordinator.state == .failed(.invalidInvitation)) @@ -238,7 +238,7 @@ struct LaunchCoordinatorTests { "Invalid or base-switching refresh replacements are rejected", arguments: [ CaveMemoryConnection( - baseURL: URL(string: "https://other.example.ts.net")!, + baseURL: URL(string: "https://other.example.ts.net")!, // gitleaks:allow — synthetic test endpoint accessToken: refreshedToken ), CaveMemoryConnection( @@ -247,7 +247,7 @@ struct LaunchCoordinatorTests { ), CaveMemoryConnection( baseURL: URL( - string: "https://cave.example.ts.net/private" + string: "https://cave.example.ts.net/private" // gitleaks:allow — synthetic rejected URL )!, accessToken: refreshedToken ), @@ -383,6 +383,46 @@ struct LaunchCoordinatorTests { #expect(coordinator.memoryService == nil) } + @Test("A revocation invalidates before any detail opens") + @MainActor + func revocationBeforeDetail() async { + let service = LaunchStubCaveService( + listError: .authenticationRequired + ) + let coordinator = Self.coordinator( + credentials: LaunchStubCredentialStore(pairing: Self.stored), + service: service + ) + await coordinator.start() + let activeService = try! #require(coordinator.memoryService) + + await #expect(throws: NetworkError.authenticationRequired) { + _ = try await activeService.list() + } + + #expect(coordinator.state == .failed(.pairingInvalidated)) + #expect(coordinator.memoryService == nil) + } + + @Test("An initial disconnect remains a library-level offline state") + @MainActor + func disconnectBeforeDetail() async { + let service = LaunchStubCaveService(listError: .connectionFailed) + let coordinator = Self.coordinator( + credentials: LaunchStubCredentialStore(pairing: Self.stored), + service: service + ) + await coordinator.start() + let activeService = try! #require(coordinator.memoryService) + + await #expect(throws: NetworkError.connectionFailed) { + _ = try await activeService.list() + } + + #expect(coordinator.state == .ready("cave.example.ts.net")) + #expect(coordinator.memoryService != nil) + } + @Test("A refresh disconnect invalidates a session with an open detail") @MainActor func refreshDisconnectAfterDetail() async { @@ -842,7 +882,7 @@ struct LaunchCoordinatorTests { private static let day: TimeInterval = 24 * 60 * 60 private static let week: TimeInterval = 7 * day private static let baseURL = URL( - string: "https://cave.example.ts.net" + string: "https://cave.example.ts.net" // gitleaks:allow — synthetic test endpoint )! private static let qrToken = "legacy-qr-secret" private static let firstToken = "legacy-first-secret" @@ -962,7 +1002,7 @@ private actor LaunchStubCaveService: CaveMemoryServicing { overviewResults: [Result]? = nil, overviewGate: LaunchGate? = nil, refreshed: CaveMemoryConnection = CaveMemoryConnection( - baseURL: URL(string: "https://cave.example.ts.net")!, + baseURL: URL(string: "https://cave.example.ts.net")!, // gitleaks:allow — synthetic test endpoint accessToken: "v1.1787592000000.refreshed.signature" ), refreshError: NetworkError? = nil, diff --git a/apps/ios/CovenMemory/Tests/Unit/MemoryFiltersTests.swift b/apps/ios/CovenMemory/Tests/Unit/MemoryFiltersTests.swift index a832d8e..4672951 100644 --- a/apps/ios/CovenMemory/Tests/Unit/MemoryFiltersTests.swift +++ b/apps/ios/CovenMemory/Tests/Unit/MemoryFiltersTests.swift @@ -28,6 +28,17 @@ struct MemoryFiltersTests { #expect(filter.apply(to: summaries, now: now).map(\.familiarId) == ["sage"]) } + @Test("Structured identifiers remain case insensitive") + func foldsStructuredIdentifierCase() throws { + let summaries = try Fixture.summaries() + let filter = MemoryFilter( + familiarId: "SAGE", + sourceKind: "COVEN-ORIGIN" + ) + + #expect(filter.apply(to: summaries, now: now).map(\.familiarId) == ["sage"]) + } + @Test("Diacritic folding keeps search user-friendly") func foldsDiacritics() { let summary = MemorySummary( diff --git a/apps/ios/CovenMemory/Tests/Unit/MemoryLifetimeTests.swift b/apps/ios/CovenMemory/Tests/Unit/MemoryLifetimeTests.swift new file mode 100644 index 0000000..6462759 --- /dev/null +++ b/apps/ios/CovenMemory/Tests/Unit/MemoryLifetimeTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import CovenMemory + +@Suite("Memory lifetime") +struct MemoryLifetimeTests { + @Test("Lock releases the active service and its retained memory graph") + @MainActor + func lockReleasesMemoryGraph() async throws { + let now = Date(timeIntervalSince1970: 1_785_326_400) + let expiry = Int64((now.timeIntervalSince1970 + 30 * 24 * 60 * 60) * 1_000) + let token = "v1.\(expiry).nonce.signature" + let invite = "https://cave.example/?coven_access_token=\(token)" // gitleaks:allow — synthetic invite + let probe = LifetimeServiceProbe() + let coordinator = LaunchCoordinator( + credentials: LifetimeCredentialStore(), + makeService: { _ in + let service = LifetimeService() + probe.record(service) + return service + }, + now: { now } + ) + + await coordinator.start() + await coordinator.submitInvite(invite) + + #expect(coordinator.memoryService != nil) + #expect(probe.current != nil) + + coordinator.lock() + await Task.yield() + + #expect(coordinator.memoryService == nil) + #expect(probe.current == nil) + } +} + +private final class LifetimeServiceProbe: @unchecked Sendable { + private let lock = NSLock() + private weak var stored: LifetimeService? + + var current: LifetimeService? { + lock.withLock { stored } + } + + func record(_ service: LifetimeService) { + lock.withLock { stored = service } + } +} + +private actor LifetimeCredentialStore: CredentialStoring { + private var pairing: CaveMemoryConnection? + + func loadPairing() async throws -> CaveMemoryConnection? { pairing } + func savePairing(_ pairing: CaveMemoryConnection) async throws { + self.pairing = pairing + } + func deletePairing() async throws { pairing = nil } +} + +private actor LifetimeService: CaveMemoryServicing { + private let retainedGraph = Array( + repeating: String(repeating: "s", count: 8_192), + count: 64 + ) + + func list() async throws -> [MemorySummary] { [] } + + func overview() async throws -> MemoryOverview { + struct Envelope: Decodable { let overview: MemoryOverview } + return try JSONDecoder.mobile.decode( + Envelope.self, + from: Fixture.data("cave-overview-success.json") + ).overview + } + + func detail(id: UUID) async throws -> MemoryDetail { + throw NetworkError.memoryNotFound + } + + func refreshToken() async throws -> CaveMemoryConnection { + throw NetworkError.invalidResponse + } +} diff --git a/apps/ios/CovenMemory/Tests/Unit/MemoryMarkdownTests.swift b/apps/ios/CovenMemory/Tests/Unit/MemoryMarkdownTests.swift index 06d4d3c..d72ec57 100644 --- a/apps/ios/CovenMemory/Tests/Unit/MemoryMarkdownTests.swift +++ b/apps/ios/CovenMemory/Tests/Unit/MemoryMarkdownTests.swift @@ -17,6 +17,39 @@ struct MemoryMarkdownTests { ]) } + @Test("CRLF line endings normalize without changing blocks") + func normalizesCRLFLineEndings() { + let document = MemoryMarkdown.parse( + "# Synthetic\r\n\r\nFirst\r\nSecond", + title: "Synthetic" + ) + + #expect(document.blocks == [ + .paragraph([InlineRun(text: "First Second")]) + ]) + } + + @Test("CR line endings normalize without changing blocks") + func normalizesCRLineEndings() { + let document = MemoryMarkdown.parse( + "# Synthetic\r\rFirst\rSecond", + title: "Synthetic" + ) + + #expect(document.blocks == [ + .paragraph([InlineRun(text: "First Second")]) + ]) + } + + @Test("Empty inline content preserves an empty run list") + func preservesEmptyInlineRunList() { + let document = MemoryMarkdown.parse("# ", title: "Different") + + #expect(document.blocks == [ + .heading(level: 1, runs: []) + ]) + } + @Test("Inline styling does not prevent equivalent title removal") func removesStyledEquivalentTitle() { let document = MemoryMarkdown.parse( diff --git a/apps/ios/CovenMemory/Tests/Unit/MemoryPerformanceTests.swift b/apps/ios/CovenMemory/Tests/Unit/MemoryPerformanceTests.swift new file mode 100644 index 0000000..e87ae5d --- /dev/null +++ b/apps/ios/CovenMemory/Tests/Unit/MemoryPerformanceTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing + +@testable import CovenMemory + +@Suite("Memory performance gates") +struct MemoryPerformanceTests { + @Test("Search and filter 5,000 summaries within one frame") + func searchAndFilterFiveThousand() { + let now = Date(timeIntervalSince1970: 1_785_326_400) + let summaries = (0..<5_000).map { index in + MemorySummary( + id: UUID(), + familiarId: index.isMultiple(of: 2) ? "sage" : "echo", + title: "Synthetic title \(index)", + updatedAt: now, + relativeUpdatedAt: "now", + excerpt: index == 4_999 ? "target context" : "synthetic excerpt", + source: MemorySource(kind: "coven-origin", label: "Coven origin"), + privacy: MemoryPrivacySummary( + classification: "public", + revealRequired: false + ), + verification: MemoryVerificationSummary(state: .verified) + ) + } + let filter = MemoryFilter( + query: "target context", + familiarId: "echo", + sourceKind: "coven-origin", + verification: .verified, + freshness: .today + ) + _ = filter.apply(to: summaries, now: now) + + let elapsed = ContinuousClock().measure { + let matches = filter.apply(to: summaries, now: now) + #expect(matches.count == 1) + } + + #expect(elapsed < .milliseconds(16)) + } +} diff --git a/apps/ios/CovenMemory/UITests/AccessibilityUITests.swift b/apps/ios/CovenMemory/UITests/AccessibilityUITests.swift new file mode 100644 index 0000000..6c08e58 --- /dev/null +++ b/apps/ios/CovenMemory/UITests/AccessibilityUITests.swift @@ -0,0 +1,143 @@ +import XCTest + +final class AccessibilityUITests: XCTestCase { + @MainActor + func testLockedAndUnpairedSurfacesPassAccessibilityAudit() throws { + let app = XCUIApplication() + app.launchArguments = ["-ui-testing"] + app.terminate() + app.launch() + + XCTAssertTrue(app.buttons["Unlock"].waitForExistence(timeout: 5)) + try audit(app) + + app.buttons["Unlock"].tap() + XCTAssertTrue(app.buttons["Scan QR Code"].waitForExistence(timeout: 5)) + try audit(app) + } + + @MainActor + func testLibraryAndFilterSurfacesPassAccessibilityAudit() throws { + let app = launch(scenario: "healthy") + XCTAssertTrue( + app.navigationBars["Memory Library"].waitForExistence(timeout: 5) + ) + try audit( + app, + allowingContrastFor: [(.searchField, "Search memories")] + ) + + app.buttons["Filter memories"].tap() + XCTAssertTrue(app.navigationBars["Filters"].waitForExistence(timeout: 5)) + try audit( + app, + allowingContrastFor: [ + (.staticText, "Filters"), + (.staticText, "Familiar"), + (.staticText, "Source"), + (.button, "Done"), + ] + ) + } + + @MainActor + func testProtectedReaderAndProvenancePassAccessibilityAudit() throws { + let protectedApp = launch(scenario: "reader-protected") + openMemory("Protected field notes", in: protectedApp) + XCTAssertTrue( + protectedApp.buttons["Reveal memory"].waitForExistence(timeout: 5) + ) + try audit( + protectedApp, + allowingContrastFor: [(.staticText, "Protected field notes")] + ) + protectedApp.terminate() + + let provenanceApp = launch(scenario: "reader-provenance") + openMemory("Public field notes", in: provenanceApp) + provenanceApp.buttons["Memory info"].tap() + XCTAssertTrue( + provenanceApp.navigationBars["Memory Info"] + .waitForExistence(timeout: 5) + ) + try audit( + provenanceApp, + allowingContrastFor: [ + (.staticText, "Display"), + (.button, "Done"), + ] + ) + } + + @MainActor + func testSettingsAndMemoryHealthPassAccessibilityAudit() throws { + let app = launch(scenario: "health-degraded") + app.buttons["Settings"].tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + try audit(app) + + app.buttons["Memory Health"].tap() + XCTAssertTrue( + app.navigationBars["Memory Health"].waitForExistence(timeout: 5) + ) + try audit(app) + } + + @MainActor + private func launch(scenario: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-ui-testing", + "-ui-library-scenario", + scenario, + ] + app.terminate() + app.launch() + XCTAssertTrue(app.buttons["Unlock"].waitForExistence(timeout: 5)) + app.buttons["Unlock"].tap() + return app + } + + @MainActor + private func audit( + _ app: XCUIApplication, + allowingContrastFor allowedControls: [ + (XCUIElement.ElementType, String) + ] = [] + ) throws { + // Dynamic Type and clipping are exercised behaviorally at AX XXXL by + // MemoryLibraryUITests, MemoryReaderUITests, and LocalizationUITests. + let auditTypes: XCUIAccessibilityAuditType = [ + .contrast, + .elementDetection, + .hitRegion, + .sufficientElementDescription, + .trait, + ] + try app.performAccessibilityAudit(for: auditTypes) { issue in + // Disabled controls are exempt from contrast requirements and still + // need to remain discoverable in the unpaired and filter states. + if issue.auditType == .contrast, + issue.element?.isEnabled == false + { + return true + } + + // XCTest screenshots these caller-scoped SwiftUI controls without their + // full material background on iOS 26, producing false contrast failures. + guard issue.auditType == .contrast, let element = issue.element else { + return false + } + return allowedControls.contains { allowed in + allowed.0 == element.elementType && allowed.1 == element.label + } + } + } + + @MainActor + private func openMemory(_ title: String, in app: XCUIApplication) { + XCTAssertTrue(app.staticTexts[title].waitForExistence(timeout: 5)) + app.staticTexts[title].tap() + XCTAssertTrue(app.navigationBars[title].waitForExistence(timeout: 5)) + } +} diff --git a/apps/ios/CovenMemory/UITests/CovenMemoryLaunchUITests.swift b/apps/ios/CovenMemory/UITests/CovenMemoryLaunchUITests.swift index 7f42a35..d4585e5 100644 --- a/apps/ios/CovenMemory/UITests/CovenMemoryLaunchUITests.swift +++ b/apps/ios/CovenMemory/UITests/CovenMemoryLaunchUITests.swift @@ -44,9 +44,7 @@ final class CovenMemoryLaunchUITests: XCTestCase { ) let syntheticToken = "ui-test-secret-token" - let invite = """ - https://cave.example/?coven_access_token=\(syntheticToken) - """ + let invite = "https://cave.example/?coven_access_token=\(syntheticToken)" // gitleaks:allow — synthetic invite let inviteField = app.textFields["Cave invite link"] inviteField.tap() inviteField.typeText(invite) diff --git a/apps/ios/CovenMemory/UITests/LocalizationUITests.swift b/apps/ios/CovenMemory/UITests/LocalizationUITests.swift new file mode 100644 index 0000000..fc652a0 --- /dev/null +++ b/apps/ios/CovenMemory/UITests/LocalizationUITests.swift @@ -0,0 +1,53 @@ +import XCTest + +final class LocalizationUITests: XCTestCase { + @MainActor + func testRightToLeftPseudolanguageRemainsBrowsable() { + let app = launch( + arguments: [ + "-AppleLanguages", "(ar-XB)", + "-AppleLocale", "ar_XB", + "-NSForceRightToLeftWritingDirection", "YES", + ] + ) + + XCTAssertTrue( + app.navigationBars["Memory Library"].waitForExistence(timeout: 5) + ) + XCTAssertTrue(app.buttons["Filter memories"].isHittable) + XCTAssertTrue(app.buttons["Settings"].isHittable) + } + + @MainActor + func testExpandedPseudolanguageSupportsLargestDynamicType() { + let app = launch( + arguments: [ + "-AppleLanguages", "(en-XA)", + "-AppleLocale", "en_XA", + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge", + ] + ) + + XCTAssertTrue( + app.staticTexts["Architecture decisions"] + .waitForExistence(timeout: 5) + ) + XCTAssertTrue(app.buttons["Filter memories"].isHittable) + } + + @MainActor + private func launch(arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-ui-testing", + "-ui-library-scenario", + "healthy", + ] + arguments + app.terminate() + app.launch() + XCTAssertTrue(app.buttons["Unlock"].waitForExistence(timeout: 5)) + app.buttons["Unlock"].tap() + return app + } +} diff --git a/apps/ios/CovenMemory/UITests/PerformanceUITests.swift b/apps/ios/CovenMemory/UITests/PerformanceUITests.swift new file mode 100644 index 0000000..aa13f20 --- /dev/null +++ b/apps/ios/CovenMemory/UITests/PerformanceUITests.swift @@ -0,0 +1,20 @@ +import XCTest + +final class PerformanceUITests: XCTestCase { + @MainActor + func testColdLockedLaunchPerformance() { + let app = XCUIApplication() + app.launchArguments = ["-ui-testing"] + let options = XCTMeasureOptions() + options.iterationCount = 3 + + measure( + metrics: [XCTApplicationLaunchMetric(waitUntilResponsive: true)], + options: options + ) { + app.launch() + XCTAssertTrue(app.buttons["Unlock"].waitForExistence(timeout: 5)) + app.terminate() + } + } +} diff --git a/docs/ios/README.md b/docs/ios/README.md new file mode 100644 index 0000000..5123abb --- /dev/null +++ b/docs/ios/README.md @@ -0,0 +1,87 @@ +# Coven Memory for iOS + +Coven Memory is a read-only SwiftUI client for the canonical-memory routes +served by Coven Cave. The phone does not run Coven, open memory files, or keep +a local memory database. + +## Requirements + +- Xcode 26.5 with an iOS 26.5 simulator +- XcodeGen 2.45.4 or newer +- iOS 18.0 or newer for a signed device build + +`apps/ios/CovenMemory/project.yml` is the project source of truth. Regenerate +the checked-in Xcode project after changing targets, resources, build settings, +or test membership: + +```bash +cd apps/ios/CovenMemory +xcodegen generate +xcodebuild test \ + -project CovenMemory.xcodeproj \ + -scheme CovenMemory \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.5' +``` + +The bundle identifier is `ai.opencoven.memory`. Local simulator tests do not +require signing. Device archives require the maintainer-reviewed Apple team, +provisioning profile, marketing version, and monotonically increasing build +number; do not add those machine-specific values to `project.yml`. + +## Pairing and runtime + +On the Cave host, use **Open on phone** or the native-app Tailscale command to +produce a credential-bearing HTTPS invite. It carries the current shared mobile +access secret and remains valid until host-side global rotation; treat every +invite as live. Scan the QR code or paste the invite into the app. The app +extracts the Cave base URL and mobile bearer credential, removes the +credential-bearing URL from view state, and stores only that connection record +in the device-only Keychain. + +The app uses these Cave routes: + +- `GET /api/mobile/coven-memory` +- `GET /api/mobile/coven-memory/overview` +- `GET /api/mobile/coven-memory/{id}` +- `POST /api/mobile-token/refresh` + +Cave owns authorization and forwards validated read requests to the local +Coven daemon. Token refresh renews the active credential but does not mutate +memory. The client rejects redirects, non-HTTPS hosts, oversized responses, +unknown response fields, and unsupported protocol responses. + +## Test data and privacy + +Tests use only deterministic synthetic responses under +`Tests/Fixtures/cave-mobile-memory-v1`. Matching fixtures live in Cave under +`tests/fixtures/mobile-canonical-memory-v1`; CI checks byte-for-byte parity and +asserts that Cave's successful route output matches those files. The synthetic +error fixture is parity-checked across repositories and exercised by local +client decoding/error tests. Never use genuine memory, an active invite, a +private endpoint, a device identifier, or an unredacted test attachment. + +The app uses an ephemeral URL session and has no Core Data, SwiftData, +analytics, crash-reporting SDK, background mode, or arbitrary ATS exception. +Memory summaries and bodies exist only in the active object graph. Locking, +backgrounding, leaving a protected reader, revocation responses, and pairing +reset purge visible and retained content. iOS screen capture cannot be fully +prevented; treat screenshots and recordings as a user-controlled disclosure +boundary. + +Run the repository gates before review: + +```bash +pnpm test:ios-privacy +pnpm check +``` + +For a built product, pass the `.app` or archive path explicitly: + +```bash +scripts/check-ios-privacy.sh /path/to/CovenMemory.app +``` + +The privacy scan covers source, resources, all Swift unit/UI test source, +synthetic fixtures, and publishable strings and linked symbols in the built +app. Compiled `.xctest` bundles are excluded because they are not shipped; their +source is scanned directly. diff --git a/package.json b/package.json index 98f28ca..b4470b1 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:mobile-contract": "node --test scripts/check-mobile-contract.test.mjs", + "test:ios-privacy": "bash scripts/check-ios-privacy.test.sh && bash scripts/check-ios-privacy.sh", "test:watch": "vitest", "test:browser": "pnpm build && node scripts/verify-dashboard-ui.mjs", "test:browser:all": "pnpm build && PLAYWRIGHT_BROWSER=chromium node scripts/verify-dashboard-ui.mjs && PLAYWRIGHT_BROWSER=firefox node scripts/verify-dashboard-ui.mjs && PLAYWRIGHT_BROWSER=webkit node scripts/verify-dashboard-ui.mjs", @@ -67,7 +68,7 @@ "test:smoke": "node scripts/smoke-dashboard.mjs", "test:package": "pnpm build:package && node --test scripts/dashboard-bin.test.mjs scripts/sanitize-build-artifact.test.mjs && node scripts/package-contents-test.mjs", "prepack": "pnpm build:package", - "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:mobile-contract && pnpm test:guard && pnpm test:demo && pnpm build && pnpm test:smoke && pnpm check:instruction-sync && pnpm demo:check && ./scripts/guard-scan.sh" + "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:mobile-contract && pnpm test:ios-privacy && pnpm test:guard && pnpm test:demo && pnpm build && pnpm test:smoke && pnpm check:instruction-sync && pnpm demo:check && ./scripts/guard-scan.sh" }, "dependencies": { "@opencoven/coven-design-system": "git+https://github.com/OpenCoven/coven-design-system.git#6032f9f407982379e39ed1a40eec7a2e8b24e5c6", diff --git a/scripts/check-ios-privacy.sh b/scripts/check-ios-privacy.sh new file mode 100755 index 0000000..bb91df5 --- /dev/null +++ b/scripts/check-ios-privacy.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if (( $# > 0 )); then + targets=("$@") +else + targets=( + "$ROOT/apps/ios/CovenMemory/Sources" + "$ROOT/apps/ios/CovenMemory/Config" + "$ROOT/apps/ios/CovenMemory/Resources" + "$ROOT/apps/ios/CovenMemory/Tests" + "$ROOT/apps/ios/CovenMemory/UITests" + ) +fi + +files=() +for target in "${targets[@]}"; do + if [[ -f "$target" ]]; then + files+=("$target") + elif [[ -d "$target" ]]; then + while IFS= read -r -d '' file; do + files+=("$file") + done < <(find "$target" -type d -name '*.xctest' -prune -o -type f -print0) + else + printf 'iOS privacy scan failed closed: path does not exist: %s\n' "$target" >&2 + exit 2 + fi +done + +if (( ${#files[@]} == 0 )); then + printf 'iOS privacy scan failed closed: no files were scanned\n' >&2 + exit 2 +fi + +failures=0 + +check_text() { + local file="$1" + local label="$2" + local pattern="$3" + if file "$file" | grep -q 'Mach-O'; then + if ! strings -a "$file" | LC_ALL=C grep -E "$pattern" >/dev/null; then + return + fi + elif ! LC_ALL=C grep -aEv 'gitleaks:allow|guard-scan-allow' "$file" | + LC_ALL=C grep -E "$pattern" >/dev/null; then + return + fi + printf '%s: %s\n' "$file" "$label" >&2 + failures=1 +} + +check_linked() { + local file="$1" + local label="$2" + local pattern="$3" + if file "$file" | grep -q 'Mach-O'; then + if ! { nm -u "$file" 2>/dev/null; otool -L "$file" 2>/dev/null; } | + LC_ALL=C grep -E "$pattern" >/dev/null; then + return + fi + elif ! LC_ALL=C grep -aEv 'gitleaks:allow|guard-scan-allow' "$file" | + LC_ALL=C grep -E "$pattern" >/dev/null; then + return + fi + printf '%s: %s\n' "$file" "$label" >&2 + failures=1 +} + +for file in "${files[@]}"; do + check_text "$file" "absolute home path" '(/Users/|/home/)[A-Za-z0-9._-]+' + check_text "$file" "runtime-internal path" '(^|[^[:alnum:]_])(~|/[^[:space:]]*)?[/]?\.coven/(agents|workspaces|credentials|sessions)([^[:alnum:]_]|$)' + check_text "$file" "pairing URL" 'coven-memory://pair([?/#]|$)' + check_text "$file" "credential-bearing URL" 'coven_access_token=[^&"[:space:]]+' + check_text "$file" "private endpoint" 'https://(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)|https://[A-Za-z0-9.-]+\.ts\.net([/:?]|$)' + check_linked "$file" "prohibited telemetry or persistence" '(^|[^[:alnum:]_])(CoreData|SwiftData|NSPersistent[A-Za-z]*|Firebase[A-Za-z]*|Sentry|Crashlytics|AppCenter)([^[:alnum:]_]|$)' + check_text "$file" "prohibited entitlement or transport override" '(^|[^[:alnum:]_])(NSAllowsArbitraryLoads|UIBackgroundModes)([^[:alnum:]_]|$)' + check_text "$file" "persistent database artifact" '(^|[^[:alnum:]_.-])[A-Za-z0-9._-]+\.(sqlite|sqlite3|db)([^[:alnum:]_]|$)' + check_text "$file" "personal messaging identifier" 'agent:[a-z0-9_-]+:(telegram|imessage|discord|whatsapp|signal|webchat):|telegram:direct:[0-9]|\+1[0-9]{10}' + + case "$file" in + */Tests/Fixtures/*) ;; + *) + check_text "$file" "memory body outside synthetic fixtures" 'Synthetic (protected )?(content|detail)( only)?\.' + ;; + esac +done + +if (( failures != 0 )); then + printf 'iOS privacy scan blocked publishable private data or prohibited capabilities.\n' >&2 + exit 1 +fi + +printf 'iOS privacy scan passed (%d files).\n' "${#files[@]}" diff --git a/scripts/check-ios-privacy.test.sh b/scripts/check-ios-privacy.test.sh new file mode 100755 index 0000000..19a2e68 --- /dev/null +++ b/scripts/check-ios-privacy.test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCANNER="$ROOT/scripts/check-ios-privacy.sh" +FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/coven-ios-privacy.XXXXXX")" +trap 'rm -rf "$FIXTURE_ROOT"' EXIT + +expect_pass() { + local name="$1" + local content="$2" + local path="$FIXTURE_ROOT/$name" + mkdir -p "$(dirname "$path")" + printf '%s\n' "$content" > "$path" + "$SCANNER" "$path" >/dev/null +} + +expect_fail() { + local name="$1" + local content="$2" + local expected="$3" + local path="$FIXTURE_ROOT/$name" + mkdir -p "$(dirname "$path")" + printf '%s\n' "$content" > "$path" + if output="$($SCANNER "$path" 2>&1)"; then + printf 'expected privacy rejection for %s\n' "$name" >&2 + exit 1 + fi + grep -F "$expected" <<<"$output" >/dev/null +} + +expect_test_bundle_ignored() { + local app="$FIXTURE_ROOT/CovenMemory.app" + local fixture="$app/PlugIns/CovenMemoryTests.xctest/detail.json" + mkdir -p "$(dirname "$fixture")" + printf '%s\n' '' > "$app/Info.plist" + printf '%s\n' '{"content":"Synthetic protected content only."}' > "$fixture" + "$SCANNER" "$app" >/dev/null +} + +expect_large_early_match_fail() { + local file="$FIXTURE_ROOT/large-early-match.swift" + awk 'BEGIN { + print "let invite = \"https://cave.example/?coven_access_token=must-fail\"" + for (line = 0; line < 200000; line += 1) print "safe synthetic padding" + }' >"$file" + + if "$SCANNER" "$file" >/dev/null 2>&1; then + echo "expected large file with an early credential URL to fail" >&2 + exit 1 + fi +} + +expect_default_test_sources_scanned() { + grep -F '"$ROOT/apps/ios/CovenMemory/Tests"' "$SCANNER" >/dev/null + grep -F '"$ROOT/apps/ios/CovenMemory/UITests"' "$SCANNER" >/dev/null +} + +expect_default_scan_excludes_derived_data() { + local fake_root="$FIXTURE_ROOT/default-root" + local app_root="$fake_root/apps/ios/CovenMemory" + local compiler_metadata='/Users/runner/Xcode/SDK/CoreData.framework' # gitleaks:allow — synthetic scanner boundary fixture + mkdir -p "$fake_root/scripts" \ + "$app_root/Sources" \ + "$app_root/Config" \ + "$app_root/Resources" \ + "$app_root/Tests" \ + "$app_root/UITests" \ + "$app_root/build/DerivedData/Index.noindex" + cp -f "$SCANNER" "$fake_root/scripts/check-ios-privacy.sh" + printf '%s\n' 'let fixture = "safe"' >"$app_root/Sources/Safe.swift" + printf '%s\n' "$compiler_metadata" > \ + "$app_root/build/DerivedData/Index.noindex/compiler-metadata.txt" + + "$fake_root/scripts/check-ios-privacy.sh" >/dev/null +} + +expect_pass "safe.txt" "Synthetic fixture metadata only." +expect_pass "Info.plist" '' +expect_pass "Tests/Fixtures/detail.json" '{"content":"Synthetic protected content only."}' +expect_pass "Tests/synthetic.swift" 'let url = "https://cave.example.ts.net/" // gitleaks:allow — synthetic test endpoint' +expect_test_bundle_ignored +expect_large_early_match_fail +expect_default_test_sources_scanned +expect_default_scan_excludes_derived_data + +expect_fail "home-path.txt" "/Users/private-user/.coven/workspaces/familiar" "absolute home path" # gitleaks:allow — synthetic scanner fixture +expect_fail "runtime-path.txt" "~/.coven/credentials/mobile.json" "runtime-internal path" # gitleaks:allow — synthetic scanner fixture +expect_fail "pairing-url.txt" "coven-memory://pair?nonce=not-a-real-value" "pairing URL" +expect_fail "mobile-invite.txt" "https://private.example/?coven_access_token=not-a-real-value" "credential-bearing URL" +expect_fail "framework.txt" "FirebaseCrashlytics" "prohibited telemetry or persistence" +expect_fail "background.plist" "UIBackgroundModes" "prohibited entitlement or transport override" +expect_fail "database.txt" "private-memory.sqlite" "persistent database artifact" +expect_fail "Screenshots/reader.txt" "Synthetic protected content only." "memory body outside synthetic fixtures" + +printf 'check-ios-privacy tests passed\n' diff --git a/scripts/check-mobile-contract.test.mjs b/scripts/check-mobile-contract.test.mjs index 9fc5f22..bc7cbd9 100644 --- a/scripts/check-mobile-contract.test.mjs +++ b/scripts/check-mobile-contract.test.mjs @@ -20,6 +20,12 @@ const fixturesRoot = fileURLToPath( import.meta.url ) ); +const caveFixturesRoot = fileURLToPath( + new URL( + "../apps/ios/CovenMemory/Tests/Fixtures/cave-mobile-memory-v1/", + import.meta.url + ) +); async function withFixtureDirectories(t) { const root = await mkdtemp(join(tmpdir(), "coven-mobile-contract-")); @@ -180,3 +186,41 @@ test("locks the complete synthetic v1 contract and signature vector", async () = true ); }); + +test("locks the Cave mobile canonical-memory response contract", async () => { + const expectedNames = [ + "cave-detail-success.json", + "cave-error-cases.json", + "cave-list-success.json", + "cave-overview-success.json" + ]; + assert.deepEqual((await readdir(caveFixturesRoot)).sort(), expectedNames); + + const parse = async (name) => + JSON.parse(await readFile(join(caveFixturesRoot, name), "utf8")); + const list = await parse("cave-list-success.json"); + const overview = await parse("cave-overview-success.json"); + const detail = await parse("cave-detail-success.json"); + const errors = await parse("cave-error-cases.json"); + + assert.equal(list.ok, true); + assert.equal(overview.ok, true); + assert.equal(detail.ok, true); + assert.equal(list.entries.length, overview.overview.totals.entries); + assert.equal(detail.entry.id, list.entries[0].id); + assert.equal("path" in list.entries[0], false); + assert.equal("path" in detail.entry, false); + assert.deepEqual( + errors.map((entry) => entry.code), + [ + "mobile_access_required", + "local_daemon_required", + "canonical_memory_unavailable", + "capability_unavailable", + "daemon_update_required", + "invalid_daemon_payload", + "memory_not_found" + ] + ); + assert.ok(errors.every((entry) => entry.ok === false)); +});