Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
6c632f6
Add App Store portal billing regression test
azooz2003-bit Jul 10, 2026
0a8fa0f
Prepare iOS App Store review package
azooz2003-bit Jul 10, 2026
115e634
Address App Store review findings
azooz2003-bit Jul 10, 2026
1700d36
Harden App Review instructions and retention policy
azooz2003-bit Jul 10, 2026
4a90b8d
Document App Store privacy blockers
azooz2003-bit Jul 10, 2026
eef235c
Align review docs with trusted pairing
azooz2003-bit Jul 10, 2026
74e61c3
Add iOS privacy manifest
azooz2003-bit Jul 10, 2026
8e60eba
Tighten iOS reviewer setup checklist
azooz2003-bit Jul 10, 2026
fdc9b58
Harden iOS privacy review controls
azooz2003-bit Jul 10, 2026
8004ea8
Fix iOS settings convention lint
azooz2003-bit Jul 10, 2026
0e7800c
Align privacy policy with telemetry opt-in
azooz2003-bit Jul 10, 2026
92a4dcc
Merge remote-tracking branch 'origin/main' into task-ios-appstore-rev…
azooz2003-bit Jul 11, 2026
bf7ffce
Close iOS App Store readiness blockers
azooz2003-bit Jul 11, 2026
e0f4a7a
Test crash reporting mid-session opt-in
azooz2003-bit Jul 11, 2026
f55fe97
Start crash reporting after telemetry opt-in
azooz2003-bit Jul 11, 2026
5db7943
Test crash transport revocation safety
azooz2003-bit Jul 11, 2026
404fcf0
Close remaining iOS privacy gaps
azooz2003-bit Jul 11, 2026
f261208
Test deletion-safe analytics and policy routing
azooz2003-bit Jul 11, 2026
a30dc26
Block deleted analytics identities
azooz2003-bit Jul 11, 2026
27e6527
Isolate analytics route test dependencies
azooz2003-bit Jul 11, 2026
306d474
Test account deletion and consent ordering
azooz2003-bit Jul 11, 2026
eb0b9f4
Harden consent and account deletion ordering
azooz2003-bit Jul 11, 2026
10cc0aa
Test analytics properties across opt-in
azooz2003-bit Jul 11, 2026
83271a2
Preserve analytics context before opt-in
azooz2003-bit Jul 11, 2026
479f7a3
Test telemetry deletion boundaries
azooz2003-bit Jul 11, 2026
9e49463
Close telemetry deletion review gaps
azooz2003-bit Jul 11, 2026
23382c2
Synchronize analytics upload cancellation
azooz2003-bit Jul 11, 2026
3a65478
Drop consent-canceled analytics uploads
azooz2003-bit Jul 11, 2026
c13dd6e
Test consent revocation completion boundaries
azooz2003-bit Jul 11, 2026
260d324
Close consent revocation privacy races
azooz2003-bit Jul 11, 2026
1a8f1d9
Test analytics deletion serialization
azooz2003-bit Jul 11, 2026
041d3c2
Serialize analytics with account deletion
azooz2003-bit Jul 11, 2026
ea483cf
Make analytics lock proof signal-driven
azooz2003-bit Jul 11, 2026
df86c0f
Test analytics forwards release database transactions
azooz2003-bit Jul 11, 2026
beb8193
Bound analytics deletion coordination
azooz2003-bit Jul 11, 2026
7deb687
Align telemetry concurrency with policy
azooz2003-bit Jul 11, 2026
7dff4f5
Split telemetry policy helpers
azooz2003-bit Jul 11, 2026
8314191
Document crash watcher safety
azooz2003-bit Jul 11, 2026
21a2eee
Test deletion cleanup ordering and lease pruning
azooz2003-bit Jul 11, 2026
d4b7770
Order analytics deletion before account cleanup
azooz2003-bit Jul 11, 2026
5a380f8
Test consent generations and localized policy integrity
azooz2003-bit Jul 11, 2026
e222c5c
Serialize consent generations and repair policy translations
azooz2003-bit Jul 12, 2026
8ffa182
Satisfy consent gate convention lint
azooz2003-bit Jul 12, 2026
a3ed2d5
Test analytics deletion and revocation races
azooz2003-bit Jul 12, 2026
58a0bbe
Close analytics deletion and revocation races
azooz2003-bit Jul 12, 2026
6e55515
Serialize telemetry revocation boundaries
azooz2003-bit Jul 12, 2026
f1c751c
Merge remote-tracking branch 'origin/main' into task-ios-appstore-rev…
Jul 13, 2026
eee6c37
Stage App Store version metadata and screenshots
Jul 13, 2026
0c6eb3f
Align final App Store validation
Jul 13, 2026
a5999f2
Test App Store analytics deletion isolation
Jul 13, 2026
432512f
Prevent analytics from blocking account deletion
Jul 13, 2026
9f3d8d5
Default official validation to current screenshots
Jul 13, 2026
02168b8
Test anonymous analytics identity isolation
Jul 13, 2026
de492a5
Namespace anonymous analytics identities
Jul 13, 2026
6a33738
Test production crash reporting exclusion
Jul 13, 2026
469fbe1
Disable crash reporting in App Store lane
Jul 13, 2026
e37c495
Test anonymous analytics property stripping
Jul 13, 2026
c99ed1a
Remove account data from anonymous analytics
Jul 13, 2026
bcb89c0
Test deletion with no analytics person
Jul 13, 2026
2b65cd6
Accept already-deleted analytics identities
Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/ios-app-store.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,54 @@ jobs:
APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64 }}
run: ./.github/scripts/install-app-store-provisioning-profile.sh

- name: Prepare App Store version and metadata
run: |
set -euo pipefail
VERSION="$(sed -nE 's/^[[:space:]]*CMUX_IOS_APPSTORE_MARKETING_VERSION[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' ios/Config/Shared.xcconfig | tail -n 1)"
VERSION_ID="$(
asc versions list --app "$ASC_APP_ID" --version "$VERSION" --platform IOS --output json |
python3 -c 'import json,sys; body=json.load(sys.stdin); data=body.get("data") if isinstance(body,dict) else body; data=data if isinstance(data,list) else ([data] if isinstance(data,dict) else []); print(data[0].get("id", "") if data else "")'
)"
if [ -z "$VERSION_ID" ]; then
asc versions create \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--platform IOS \
--copyright "2026 Manaflow, Inc." \
--release-type MANUAL
else
asc versions update \
--version-id "$VERSION_ID" \
--copyright "2026 Manaflow, Inc." \
--release-type MANUAL
fi
asc metadata validate --dir ios/AppStoreReview/metadata
asc metadata apply \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--platform IOS \
--dir ios/AppStoreReview/metadata
asc screenshots validate \
--path ios/AppStoreReview/screenshots/en-US/iphone \
--device-type IPHONE_69
asc screenshots upload \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--platform IOS \
--path ios/AppStoreReview/screenshots \
--device-type IPHONE_69 \
--replace
Comment thread
cursor[bot] marked this conversation as resolved.
asc screenshots validate \
--path ios/AppStoreReview/screenshots/en-US/ipad \
--device-type IPAD_PRO_3GEN_129
asc screenshots upload \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--platform IOS \
--path ios/AppStoreReview/screenshots \
--device-type IPAD_PRO_3GEN_129 \
--replace

- name: Archive, export, and upload production build
id: upload
env:
Expand All @@ -171,6 +219,8 @@ jobs:
--version "$VERSION" \
--build-number "$FINAL_BUILD_NUMBER" \
--wait-build \
--screenshot-device-type IPHONE_69 \
--screenshot-device-type IPAD_PRO_3GEN_129 \
--strict
if [ -n "${INPUT_COPY_METADATA_FROM:-}" ]; then
./ios/scripts/validate-app-store-release.sh \
Expand All @@ -179,6 +229,8 @@ jobs:
--build-number "$FINAL_BUILD_NUMBER" \
--copy-metadata-from "$INPUT_COPY_METADATA_FROM" \
--stage-dry-run \
--screenshot-device-type IPHONE_69 \
--screenshot-device-type IPAD_PRO_3GEN_129 \
--strict
fi
if [ "${INPUT_SUBMIT_FOR_REVIEW:-false}" = "true" ]; then
Expand All @@ -188,6 +240,8 @@ jobs:
--build-number "$FINAL_BUILD_NUMBER" \
--submit \
--confirm-submit \
--screenshot-device-type IPHONE_69 \
--screenshot-device-type IPAD_PRO_3GEN_129 \
--strict
fi

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// Synchronous consent state shared by fire-and-forget callers and the actor.
///
/// A generation makes an event captured before a revoke permanently stale even
/// if consent is enabled again before the actor drains its FIFO. Synchronizing
/// transport state inside the same critical section also closes the inverse
/// race where UserDefaults reads enabled before its notification re-enables the
/// uploader.
final class AnalyticsConsentGenerationGate: Sendable {
private let state: AnalyticsCriticalState<(isEnabled: Bool, generation: UInt64)>

init(isEnabled: Bool) {
state = AnalyticsCriticalState(
initialValue: (isEnabled: isEnabled, generation: 0)
)
}

func snapshot() -> AnalyticsConsentSnapshot {
state.withCriticalRegion {
AnalyticsConsentSnapshot(isEnabled: $0.isEnabled, generation: $0.generation)
}
}

/// Reconciles an observed provider value against the snapshot taken before
/// reading it. If another thread changed consent during that read, the
/// original snapshot is returned so the caller's submission stays stale.
func synchronize(
observedEnabled: Bool,
basedOn base: AnalyticsConsentSnapshot,
publish: @Sendable (AnalyticsConsentSnapshot) -> Void
) -> AnalyticsConsentSnapshot {
state.withCriticalRegion { state in
guard state.generation == base.generation else { return base }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale consent admits post-revoke events

Medium Severity

When synchronize sees a generation mismatch, it returns the caller’s original base snapshot unchanged. If another thread already revoked consent, that stale snapshot can still have isEnabled true even though the fresh UserDefaults read was false. capture then only checks consent.isEnabled and enqueues the event into the AsyncStream after opt-out, which breaks the fail-closed “do not buffer while disabled” gate until the consumer later drops it via allows.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f3d8d5. Configure here.

guard state.isEnabled != observedEnabled else {
return AnalyticsConsentSnapshot(
isEnabled: state.isEnabled,
generation: state.generation
)
}
state.isEnabled = observedEnabled
state.generation &+= 1
let updated = AnalyticsConsentSnapshot(
isEnabled: state.isEnabled,
generation: state.generation
)
publish(updated)
return updated
}
}

func allows(_ snapshot: AnalyticsConsentSnapshot) -> Bool {
state.withCriticalRegion { state in
state.isEnabled && state.generation == snapshot.generation
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ public struct AnalyticsConsentProvider: AnalyticsConsentProviding {
///
/// The iOS app cannot import the macOS-only `CmuxSettings` package, so this reads
/// the same backing key that `CmuxSettings.catalog.app.sendAnonymousTelemetry`
/// writes (`"sendAnonymousTelemetry"`), with the same default of `true`. The
/// value is read on every capture so toggling the Settings switch takes effect
/// immediately without rewiring.
/// writes (`"sendAnonymousTelemetry"`). iOS defaults to telemetry off until the
/// user enables the Settings toggle. The value is read on every capture so
/// toggling the switch takes effect immediately without rewiring.
public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding {
/// The `UserDefaults` key shared with the settings catalog's
/// `app.sendAnonymousTelemetry` entry.
Expand All @@ -61,7 +61,6 @@ public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding {
}

public var isTelemetryEnabled: Bool {
// Absent key defaults to opted-in (true), matching the catalog default.
defaults.object(forKey: Self.telemetryKey) as? Bool ?? true
defaults.object(forKey: Self.telemetryKey) as? Bool ?? false
Comment thread
azooz2003-bit marked this conversation as resolved.
Comment thread
azooz2003-bit marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
internal import CMUXMobileCore
internal import Foundation

// Safety: NotificationCenter owns the callback concurrently, while this type's
// stored token and center are immutable after initialization. The callback only
// synchronizes injected thread-safe consent/uploader seams and yields into a
// thread-safe AsyncStream.
final class AnalyticsConsentRevocationObserver: @unchecked Sendable {
private let notificationCenter: NotificationCenter
private let token: any NSObjectProtocol

init(
notificationCenter: NotificationCenter,
consent: any AnalyticsConsentProviding,
uploader: any AnalyticsUploading,
generationGate: AnalyticsConsentGenerationGate,
onConsentChange: @escaping @Sendable (AnalyticsConsentSnapshot) -> Void
) {
self.notificationCenter = notificationCenter
uploader.setUploadsEnabled(consent.isTelemetryEnabled)
self.token = notificationCenter.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: nil
) { _ in
let base = generationGate.snapshot()
let observedEnabled = consent.isTelemetryEnabled
_ = generationGate.synchronize(
observedEnabled: observedEnabled,
basedOn: base
) { snapshot in
// Publish transport state before the FIFO command. A capture
// racing notification delivery therefore cannot be accepted by
// consent and then dropped by a still-disabled uploader.
uploader.setUploadsEnabled(snapshot.isEnabled)
onConsentChange(snapshot)
}
}
}

deinit {
notificationCenter.removeObserver(token)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
struct AnalyticsConsentSnapshot: Equatable, Sendable {
let isEnabled: Bool
let generation: UInt64
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
internal import os

/// Synchronous state for non-async telemetry entrypoints and revocation hooks.
final class AnalyticsCriticalState<State: Sendable>: Sendable {
// lint:allow lock - synchronous consent and cancellation entrypoints cannot await an actor without reopening revoke races.
private let state: OSAllocatedUnfairLock<State>

init(initialValue: State) {
state = .init(initialState: initialValue)
}

func withCriticalRegion<Result: Sendable>(
_ body: @Sendable (inout State) throws -> sending Result
) rethrows -> sending Result {
try state.withLock(body)
}
}
Loading
Loading