Skip to content

Retain iOS app and network log history - #10082

Merged
azooz2003-bit merged 4 commits into
mainfrom
feat-ios-log-retention
Aug 13, 2026
Merged

azooz2003-bit merged 4 commits into
mainfrom
feat-ios-log-retention

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Persist the active app and network logs across launches, rotate them into uniquely named archives at the size limit, and enforce bounded count and byte retention.

Share every retained generation from Settings so diagnostics include history instead of only the current file. Legacy .1 rotations are migrated without losing their contents.

Tests: swift test --package-path Packages/Shared/CMUXMobileCore --filter AppLogTests


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Retains iOS app and network log history across launches and includes it in diagnostics. Previously we rotated to .1 and Settings shared only the active file; now we reopen the active generation, rotate to timestamped archives, bound retention by count and total bytes, and share all generations.

  • CMUXMobileCore: AppLog adds timestamped archive rotation, bounded retention (defaults: 5 MB per file, 3 archives, 12 MB total), and reopens the active generation on launch. Rotation failures append to the current file instead of truncating. Legacy <name>.1 files migrate into the archive namespace without data loss. Retained generations are discovered safely and ordered by embedded generation stamp (with modification-date fallback).
  • CMUXMobileCore: New APIs AppLog.appLogFileURLs, AppLog.networkLogFileURLs, and logFileURLs(for:) expose the active file plus archives (including any unmigrated legacy .1). The initializer adds optional maxArchiveCount, maxRetainedBytes, and now parameters; existing call sites do not need changes.
  • CmuxMobileShellUI: Settings shares all retained generations via ShareLink(items:), loading URL lists off the main thread so exported diagnostics include recent history.
  • Tests cover rotation, cross-launch reopen, legacy migration, archive ordering, failure fallback, and retention bounds.

Written for commit ab43b7d. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Log files now support timestamped archives with configurable size, archive-count, and storage limits.
    • Existing logs are preserved across app launches, including migration of legacy archives.
    • Diagnostics sharing now includes all available app and network log archives.
  • Bug Fixes

    • Improved recovery when log rotation or archive creation fails.
    • Prevented existing archived log data from being overwritten.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

AppLog now supports bounded timestamped archives for app and network logs. It preserves generations across launches, migrates legacy .1 files, recovers failed rotations, prunes old archives, and exposes all log URLs for diagnostics sharing.

Changes

Log archive lifecycle

Layer / File(s) Summary
Archive limits and generation discovery
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift
Adds archive limits, archive URL APIs, timestamped generation discovery, and legacy .1 migration.
Rotation, reopening, and pruning
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift
Preserves existing data during rotation, creates unique archives, recovers from failures, and enforces archive count and byte limits.
Startup wiring, tests, and log sharing
Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/AppLogTests.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
Injects archive settings and time, validates archive behavior, and shares all available app and network log files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: 🟡 Moderate · up to 18529

This PR preserves and shares historical logs, but the current implementation can delete or omit retained generations in some filesystem states and can repeatedly scan log files while the Settings screen renders. Merge should wait for these bounded retention and performance issues to be addressed.

Sequence Diagram(s)

sequenceDiagram
  participant SettingsView
  participant AppLog
  participant LogFile
  SettingsView->>AppLog: request appLogFileURLs or networkLogFileURLs
  AppLog->>LogFile: discover active and archived URLs
  LogFile-->>AppLog: return available generation URLs
  AppLog-->>SettingsView: return log URL collection
  SettingsView->>SettingsView: create ShareLink for all URLs
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors)

Check name Status Explanation Resolution
Cmux Expensive Synchronous Load ❌ Error The diff adds synchronous contentsOfDirectory plus metadata/stat sorting in AppLog archive discovery, and MobileSettingsDiagnosticsSection.body calls it for both logs on the SwiftUI render path. Use a cached archive-URL accessor or perform directory discovery in a detached task/background service, then publish the small URL result to the main actor.
Cmux Algorithmic Complexity ❌ Error AppLog.swift:120-142 scans all Application Support entries, then filters and sorts archives; MobileSettingsView.swift:814/830 repeats this O(D + A log A) scan for both targets on SwiftUI body evalu... Cache one bounded archive snapshot or use a dedicated indexed archive directory and reuse it for both log targets; measure behavior with about 1000 files.
✅ Passed checks (23 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed The diff keeps log state inside the existing AppLog actor, adds a @Sendable clock, and accesses new URLs from a SwiftUI View; no prohibited isolation mistake is introduced.
Cmux Swift Blocking Runtime ✅ Passed The production diff adds archive file I/O and timestamp generation only; it adds no semaphore, blocking wait, sleep, delayed dispatch, polling loop, main sync, or manual lock.
Cmux Browser Automation Off-Main ✅ Passed The commit changes only AppLog.swift and MobileSettingsView.swift; no browser socket, WebKit, worker-router, or policy files or symbols changed.
Cmux Cache Substitution Correctness ✅ Passed The changed log and Settings paths use fresh on-disk URL discovery and file checks; no cached or opportunistic value replaces an authoritative read.
Cmux No Hacky Sleeps ✅ Passed The diff changes only AppLog.swift and MobileSettingsView.swift; the rule excludes Swift, and no added sleep, timer, polling, or fixed-delay code appears.
Cmux Swift Concurrency ✅ Passed The full PR diff adds no Dispatch, Combine, completion-handler, or fire-and-forget Task pattern in production code; new async usage is limited to XCTest-style async tests.
Cmux Swift @Concurrent ✅ Passed The diff adds no @concurrent, nonisolated async, or async declarations. File I/O remains in AppLog actor-isolated methods, and Settings calls synchronous URL discovery.
Cmux Swift Package Boundaries ✅ Passed AppLog persistence logic is in the existing CMUXMobileCore SwiftPM target with CMUXMobileCoreTests; the other change is UI-only sharing glue in the CmuxMobileShellUI package.
Cmux Swiftpm Lockfiles ✅ Passed The commit changes only AppLog.swift and MobileSettingsView.swift; no Package.swift, Package.resolved, Xcode project, .gitignore, workflow, or dependency files changed.
Cmux Swift Logging ✅ Passed AppLog.swift adds no print, debugPrint, dump, NSLog, or Logger; it extends the existing FileHandle sink with archives, while structured diagnostics remain privacy-safe and text mirroring remains ga...
Cmux User-Facing Error Privacy ✅ Passed The diff adds archive handling and generic “Share App Log”/“Share Network Log” actions, but adds no user-facing error, alert, or prohibited diagnostic detail.
Cmux Full Internationalization ✅ Passed The diff adds log/archive behavior and changes only ShareLink inputs; existing Settings labels remain L10n.string calls, with no new user-facing text or catalog changes.
Cmux Swiftui State Layout ✅ Passed The SwiftUI diff only changes existing diagnostics ShareLink rows to use URL arrays; it adds no state, geometry, lazy-row store reference, or render-time mutation.
Cmux Architecture Rethink ✅ Passed The diff adds actor-owned log rotation and archive state with clear invariants; it adds no production sleeps, polling, locks, observers, side channels, or split lifecycle owners. Test polling preda...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The diff changes AppLog file rotation and SwiftUI ShareLink views only; it adds no NSWindow, NSPanel, Window, WindowGroup, controller, identifier, or close-shortcut routing.
Cmux Source Artifacts ✅ Passed The commit changes only two intentional Swift source files under Packages; no logs, screenshots, caches, build output, temp folders, or artifact directories appear in the diff.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The diff adds no DEBUG/test-only member or test-named accessor; new log URL APIs have a real Settings caller, and existing flushForTesting was not changed.
Cmux No Ambient Global State ✅ Passed The diff adds only AppLog static constants/computed accessors and helpers on an existing constructable actor; it adds no file-scope funcs/vars, static-only namespace, or singleton.
Title check ✅ Passed The title clearly summarizes the primary change: retaining iOS app and network log history.
Description check ✅ Passed The description explains the changes, motivation, and test command, so it is mostly complete despite omitted template sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-log-retention

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift`:
- Around line 814-816: In MobileSettingsView, add `@State` properties for appURLs
and networkURLs, populate both once in a section-level .task by reading
AppLog.appLogFileURLs and AppLog.networkLogFileURLs off the main actor, and
replace the inline accessor calls at MobileSettingsView.swift:814-816 and
MobileSettingsView.swift:830-832 with the stored state values.

In `@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift`:
- Around line 306-313: Update AppLog.append(_:) to reuse the single encoded Data
value for both rotation sizing and output. Add or adapt a private write method
that accepts Data, then have append pass its existing data to it instead of
calling the String-based write(_:) path, while preserving newline and rotation
behavior.
- Around line 133-142: Update the archive sorting in logFileURLs(for:) to use
the parsed stamp from makeArchiveURL as the primary descending key, adding the
private archiveStamp(of:prefix:) helper alongside the archive helpers. Use
contentModificationDateKey only when either archive name cannot be parsed, and
preserve a deterministic filename tie-breaker; ensure unparseable names do not
silently outrank or replace valid stamped archives.
- Around line 128-132: Update the archive candidate filter in archiveURLs to
compare the candidate’s filename suffix with the source fileURL’s relevant
suffix rather than comparing parsed pathExtension values. Preserve the existing
prefix and file-existence checks, and ensure extension-less fileURL values match
archives produced by makeArchiveURL so pruning and diagnostic exports discover
them.

In `@Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/AppLogTests.swift`:
- Around line 180-206: Update boundsTimestampedArchiveCount to set
maxRetainedBytes below the archive-count-bounded total so pruneArchives
exercises retained-byte pruning. Replace the fragile combined totalBytes
assertion with separate assertions for the archive count and retained-byte
limit, preserving the expectation that archives remain present.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 34627409-caf4-4ad4-9ea3-25fcb414159c

📥 Commits

Reviewing files that changed from the base of the PR and between eeff5ce and 1852946.

📒 Files selected for processing (3)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/AppLogTests.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift

Comment thread Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift Outdated
Comment on lines +128 to +132
.filter { candidate in
candidate.lastPathComponent.hasPrefix(prefix)
&& candidate.pathExtension == fileURL.pathExtension
&& fileManager.fileExists(atPath: candidate.path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Archive discovery fails for a log URL without a path extension.

makeArchiveURL produces "<stem>.archive-<stamp>-<uuid>" when fileURL.pathExtension is empty. The pathExtension of that name is "archive-<stamp>-<uuid>", so candidate.pathExtension == fileURL.pathExtension is never true. archiveURLs then returns an empty array for such a location. Two consequences follow: pruneArchives returns early at Line 321 and retention limits are never enforced, and logFileURLs(for:) omits every archive from a diagnostic export. logFileURLs(for:) is public and accepts a caller-supplied URL, so this is reachable outside the default .log filenames.

Compare the suffix instead of the parsed extension.

🐛 Proposed fix for extension-less locations
+        let suffix = fileURL.pathExtension.isEmpty ? "" : ".\(fileURL.pathExtension)"
         return names
             .filter { candidate in
-                candidate.lastPathComponent.hasPrefix(prefix)
-                    && candidate.pathExtension == fileURL.pathExtension
-                    && fileManager.fileExists(atPath: candidate.path)
+                let name = candidate.lastPathComponent
+                return name.hasPrefix(prefix)
+                    && name.hasSuffix(suffix)
+                    && fileManager.fileExists(atPath: candidate.path)
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.filter { candidate in
candidate.lastPathComponent.hasPrefix(prefix)
&& candidate.pathExtension == fileURL.pathExtension
&& fileManager.fileExists(atPath: candidate.path)
}
let suffix = fileURL.pathExtension.isEmpty ? "" : ".\(fileURL.pathExtension)"
return names
.filter { candidate in
let name = candidate.lastPathComponent
return name.hasPrefix(prefix)
&& name.hasSuffix(suffix)
&& fileManager.fileExists(atPath: candidate.path)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift` around
lines 128 - 132, Update the archive candidate filter in archiveURLs to compare
the candidate’s filename suffix with the source fileURL’s relevant suffix rather
than comparing parsed pathExtension values. Preserve the existing prefix and
file-existence checks, and ensure extension-less fileURL values match archives
produced by makeArchiveURL so pruning and diagnostic exports discover them.

Comment thread Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift
Comment on lines 306 to 313
mutating func append(_ line: String) {
guard handle != nil else { return }
let data = Data((line + "\n").utf8)
if bytesWritten + data.count > rotationThreshold {
try? handle?.close()
handle = nil
openFreshGeneration(rotatingExisting: true)
guard handle != nil else { return }
_ = rotate()
}
write(line)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Build the line data once per append.

append(_:) encodes line + "\n" to measure the rotation threshold, then write(_:) at Line 349 encodes the same string again. Every logged line pays two allocations and two UTF-8 conversions. Pass the encoded data to a private write that accepts Data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AppLog.swift` around
lines 306 - 313, Update AppLog.append(_:) to reuse the single encoded Data value
for both rotation sizing and output. Add or adapt a private write method that
accepts Data, then have append pass its existing data to it instead of calling
the String-based write(_:) path, while preserving newline and rotation behavior.

Comment on lines +180 to +206
@Test func boundsTimestampedArchiveCount() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let log = AppLog(
appFileURL: appURL,
networkFileURL: nil,
maxFileBytes: 160,
buildStamp: "test",
maxArchiveCount: 2,
maxRetainedBytes: 480
)

for index in 0..<100 {
log.mirrorAppLine("bounded line \(index) 0123456789")
}
try await waitForProcessed(log, 100)

let archives = AppLog.logFileURLs(for: appURL).filter { $0 != appURL }
#expect(archives.count <= 2)
#expect(!archives.isEmpty)
let totalBytes = AppLog.logFileURLs(for: appURL).reduce(0) { result, url in
let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize
return result + (size ?? 0)
}
#expect(totalBytes <= 480)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The retained-byte limit is not actually exercised, and the assertion is fragile.

With maxFileBytes: 160, a generation holds the header (about 55 bytes) plus one line (about 52 bytes, including the 24-character ISO timestamp) and rotates at roughly 159 bytes. maxArchiveCount: 2 caps the archives at about 318 bytes, so the total stays near 477 bytes. The 480-byte ceiling is never reached. Two problems follow: the retained-byte branch of pruneArchives never runs, and #expect(totalBytes <= 480) passes with a margin of a few bytes, so a change to the header text or timestamp format breaks the test for an unrelated reason.

Set maxRetainedBytes below the count-bounded total so the byte prune runs, and assert the archive count separately.

💚 Proposed test change
             maxArchiveCount: 2,
-            maxRetainedBytes: 480
+            // Below the count-bounded total, so the retained-byte prune runs.
+            maxRetainedBytes: 320
         )
@@
-        `#expect`(totalBytes <= 480)
+        `#expect`(totalBytes <= 320 + 160)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Test func boundsTimestampedArchiveCount() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let log = AppLog(
appFileURL: appURL,
networkFileURL: nil,
maxFileBytes: 160,
buildStamp: "test",
maxArchiveCount: 2,
maxRetainedBytes: 480
)
for index in 0..<100 {
log.mirrorAppLine("bounded line \(index) 0123456789")
}
try await waitForProcessed(log, 100)
let archives = AppLog.logFileURLs(for: appURL).filter { $0 != appURL }
#expect(archives.count <= 2)
#expect(!archives.isEmpty)
let totalBytes = AppLog.logFileURLs(for: appURL).reduce(0) { result, url in
let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize
return result + (size ?? 0)
}
#expect(totalBytes <= 480)
}
@Test func boundsTimestampedArchiveCount() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let log = AppLog(
appFileURL: appURL,
networkFileURL: nil,
maxFileBytes: 160,
buildStamp: "test",
maxArchiveCount: 2,
// Below the count-bounded total, so the retained-byte prune runs.
maxRetainedBytes: 320
)
for index in 0..<100 {
log.mirrorAppLine("bounded line \(index) 0123456789")
}
try await waitForProcessed(log, 100)
let archives = AppLog.logFileURLs(for: appURL).filter { $0 != appURL }
#expect(archives.count <= 2)
#expect(!archives.isEmpty)
let totalBytes = AppLog.logFileURLs(for: appURL).reduce(0) { result, url in
let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize
return result + (size ?? 0)
}
#expect(totalBytes <= 320 + 160)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/AppLogTests.swift`
around lines 180 - 206, Update boundsTimestampedArchiveCount to set
maxRetainedBytes below the archive-count-bounded total so pruneArchives
exercises retained-byte pruning. Replace the fragile combined totalBytes
assertion with separate assertions for the archive count and retained-byte
limit, preserving the expectation that archives remain present.

@azooz2003-bit
azooz2003-bit merged commit 8d48103 into main Aug 13, 2026
6 checks passed
@azooz2003-bit
azooz2003-bit deleted the feat-ios-log-retention branch August 13, 2026 04:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant