Skip to content

feat: rebuild iOS sample apps to match Android demo refresh#53

Closed
bobbyg603 wants to merge 32 commits into
feat/hang-detection-v2from
feat/ios-samples-revamp
Closed

feat: rebuild iOS sample apps to match Android demo refresh#53
bobbyg603 wants to merge 32 commits into
feat/hang-detection-v2from
feat/ios-samples-revamp

Conversation

@bobbyg603

Copy link
Copy Markdown
Member

Stacked on top of #52 (hang detection).

Summary

Rebuilds all three iOS sample apps (SwiftUI, UIKit-Swift, UIKit-ObjC) to mirror the new demo UI shipped in bugsplat-android (feat/demo-app-ui-refresh). Same layout, palette, copy, and behavior; adapted to native iOS idioms.

What's in each sample

Screen layout (top → bottom):

  • Top bar: BugSplat wordmark + monospaced SDK version + Connected status pill
  • Title row: "BugSplat SDK · Demo" with a small rounded badge showing the configured database name (`fred` by default)
  • Subtitle
  • "TRIGGER AN EVENT" section with four cards: Crash, Non-Crash Error, User Feedback, Hang — each with a splat icon
  • Recent Activity card with "View dashboard ↗" link to `app.bugsplat.com/v2/dashboard?database=`
  • Footer

Actions (parity across apps):

  • Crash: records entry synchronously, then derefs nil so the entry survives the impending crash
  • Non-Crash Error: SwiftUI / UIKit-Swift log a hardcoded `NSInvalidArgumentException caught`; UIKit-ObjC does a real `@try/@catch` on out-of-bounds and logs the real exception name
  • Feedback: presents the existing alert, posts via `BugSplat.postFeedback`, records a smart-quoted entry on success
  • Hang: 8-second main-thread freeze behind a confirmation alert (matches Android copy)

Recent Activity persistence:

  • Swift apps: `ActivityLog` (UserDefaults JSON, 10-entry cap)
  • ObjC app: `BSPActivityLog` (same shape)
  • Synchronous flush on crash entries so they survive process death
  • Refreshes on `viewDidLoad` / `scenePhase` / `UIApplicationDidBecomeActiveNotification`

App icon: regenerated from `bug.png` composited on white at every required size (iPhone, iPad, marketing) via a small Swift codegen script.

Asset catalog: new `splat_crash`, `splat_error`, `splat_feedback`, `splat_hang`, `bugsplat_wordmark` imagesets in each app.

Commits

  • `7525e3e` — SwiftUI rebuild (reference implementation)
  • `401a7f2` — UIKit-Swift + UIKit-ObjC ports
  • `38c33cb` — pin wordmark width to its aspect ratio so it sits flush-left

Not touched

  • macOS sample apps (the prompt was iOS-only)
  • BugSplatTest-SwiftUI-SPM (consumes the published xcframework, predates this work)
  • BugSplat SDK itself

Test plan

  • All three apps build clean against the workspace
  • All three apps launch in iOS Simulator and render the new layout correctly (screenshots captured during development)
  • Tap each card on a real device and verify recent activity rows appear with correct labels, dots, details, and relative times
  • Trigger crash, relaunch, confirm the persisted crash entry is visible in Recent Activity
  • Submit feedback and confirm the smart-quoted entry appears
  • Tap "View dashboard ↗" and confirm the system browser opens the dashboard URL with the right `database` query param

🤖 Generated with Claude Code

bobbyg603 and others added 17 commits April 18, 2026 10:33
Adds opt-in fatal hang detection. When the main thread is blocked past a
threshold and the app is subsequently terminated without recovering, a
hang report is uploaded on the next launch using the same pipeline as
crash reports.

- BugSplatHangTracker: CFRunLoopObserver + dedicated watchdog thread,
  with debugger / app-active / app-extension guards, wall-clock
  suspension guard, and a single-report-per-window throttle.
- On detection, captures a live PLCrashReporter report with a synthetic
  "App Hang (Fatal)" exception and persists it (plus metadata +
  bugsplat-hang-* attributes) to the crashes directory.
- On recovery, the persisted report is deleted - non-fatal hangs are not
  reported in v1.
- New BugSplat.enableHangDetection property (default NO).
- Demo button added to all workspace-linked sample apps. SPM sample
  pending next release.
- Tests: 11 unit tests for the tracker, 6 integration tests for
  persistence + recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 4-second hang in the samples let the main thread recover before
most testers could force-quit, which caused the persisted report to be
deleted (non-fatal) and gave the impression the demo did not work.

- Hang loop now runs forever; the only way out is force-quit, which is
  the exact scenario the feature exists to catch.
- Added a confirmation dialog (UIAlertController / NSAlert / SwiftUI
  .alert / console prompt) explaining what is about to happen, so the
  freeze is not mistaken for a broken app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous isApplicationActive check did dispatch_sync onto the main
queue when called from the watchdog thread. That works while main is
healthy but deadlocks the watchdog the moment main is actually hung -
which is precisely the scenario the feature exists to catch. Result: no
hang report ever got persisted for real fatal hangs, only for hangs the
app recovered from.

Track the foreground-active state via UIApplicationDidBecomeActive /
WillResignActive / DidEnterBackground / WillEnterForeground
notifications, update an atomic bool on the main thread, and read that
value lock-free from the watchdog poll. Idempotent observer
installation gated on the iOS/tvOS target.

Verified end-to-end on the iOS simulator: hang is detected, .crash +
.meta land on disk, and on relaunch the persisted report uploads
successfully while the UI loads normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The iOS Simulator's app-switcher swipe-up gesture only backgrounds the
app instead of SIGKILL'ing it like on device, so the hang appears to
persist across "relaunches." Update each sample's hang-confirmation
dialog to tell the tester how to actually terminate the hung process:
\`xcrun simctl terminate\` on iOS simulator, Cmd+Option+Esc or
\`killall -9\` on macOS. No behavior change - copy only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mark the main thread as the crashed thread in fatal-hang reports by
capturing its mach port on -start and passing it to
generateLiveReportWithThread:exception:error: instead of letting
PLCrashReporter default to the hang queue's worker thread.

Also replace the C++ tool's `while (true) {}` hang demo with a sleep
loop so the C++ forward-progress rule cannot let an optimizer elide it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If [metadata writeToFile:] fails, the next-launch scanner would find a
.crash with no userSubmitted=YES flag and could surface a dialog instead
of submitting silently. Delete the .crash on meta-write failure.

Also document the constraints the implementation already enforces:
- enableHangDetection requires -start on the main thread (NSAssert).
- BugSplatHangTrackerDelegate's recovery callback runs on a GCD utility
  queue, not the watchdog thread as previously documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the SwiftUI sample to mirror bugsplat-android's
feat/demo-app-ui-refresh design: top bar with wordmark + version + status
pill, large title with database badge, four event cards (Crash, Non-Crash
Error, Feedback, Hang) with splat icons, recent-activity card backed by
UserDefaults-persisted entries, and View dashboard deeplink.

New files:
- DemoTheme.swift - palette
- DemoComponents.swift - reusable views (event card, activity row, pill)
- ActivityLog.swift - up-to-10 entry log, synchronous flush on crash entries

App icon regenerated from bug.png composited on white at all required sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports the SwiftUI sample's demo-screen design to both UIKit apps using
programmatic Auto Layout. Mirrors the Android refresh: wordmark + version
+ status pill top bar, large title with database badge, four splat-icon
event cards, recent-activity card with UserDefaults persistence
(synchronous flush on crash entries), and View dashboard deeplink.

UIKit-Swift adds DemoTheme.swift, DemoViews.swift, ActivityLog.swift; the
ObjC counterpart adds BSPDemoTheme, BSPDemoViews, BSPActivityLog. Both
asset catalogs gain the five splat/wordmark imagesets and regenerated
AppIcon variants. Stock Main.storyboard "Crash!" buttons removed so they
don't render over the new programmatic UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The UIImageView for the top-bar wordmark was stretching to fill
horizontal space, and scaleAspectFit then centered the image inside
that wide frame - making the logo appear ~25pt right of the leading
edge instead of flush left with the title below.

Pin width = height × intrinsic aspect ratio and set horizontal content
hugging to required, so the view sits at its natural size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuilds the macOS-AppKit-ObjC sample to match the iOS demo refresh,
adapted for desktop:

- Custom window title \"BugSplat • Sample App\" with native traffic lights
- 2x2 tile grid of event cards (vs mobile's stacked column) for the wider
  canvas, each with a hover state and a keyboard-shortcut chip (1-4)
- Cmd+1..4 shortcuts wired via NSEvent local monitor route to crash /
  non-crash / feedback / hang
- \"Splat your keyboard\" gesture - press any 8 keys within 1.2s opens
  the feedback sheet, the desktop analog to shake-to-feedback
- Shared Recent Activity component backed by the same BSPActivityLog
  (UserDefaults JSON, max 10, synchronous flush on crash)
- Footer line uses a system handwriting font (Bradley Hand / Noteworthy)
  for the \"splat your keyboard.\" call-out

Asset catalog gains the splat/wordmark imagesets and macOS-sized AppIcon
variants regenerated from bug.png on white.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NSStackView with a bottom constraint was stretching the recent-activity
card to absorb leftover window space, pushing the wordmark away from the
title bar. Pin the topBar to an explicit height, force the stack to hug
its content vertically, and drop the bottom constraint - the stack now
sits at the top with screenBg filling the area below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sending an ObjC message to nil silently returns 0, so the prior
[nil performSelector:] / [(NSNumber*)nil longValue] approaches did
nothing - the cards looked broken. Replace with a plain C null
pointer dereference that reliably produces SIGSEGV on both iOS and
macOS.

Verified on macOS via diagnostic report: byte-write translation fault
at the new line in triggerCrash:.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 8-second recoverable freeze never produced a hang report - the
fatal-only hang detector deletes the persisted report on recovery.
Switch all four samples (SwiftUI / UIKit-Swift / UIKit-ObjC /
macOS-AppKit-ObjC) to an indefinite freeze that requires force-quit,
which is what produces the upload on next launch.

Implementation: single Thread.sleep(until: .distantFuture) /
sleepUntilDate: distantFuture - no spin loop, quiet CPU while frozen.
Card subtitle updated to "Freeze main thread - force-quit to upload"
and confirmation alerts explain the force-quit requirement per
platform (simctl terminate, swipe-up, killall, etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The keyUp monitor was removing each keyCode from the tracking set as
soon as the key released, so unless the user literally held 8+ keys
simultaneously (often blocked by laptop keyboard ghosting) the set
never accumulated and the feedback sheet never opened.

Drop the keyUp monitor and treat the set as \"distinct keys pressed
within the 1.2s window\" regardless of release. Now a normal mash of
8 keys across a fraction of a second reliably opens feedback.
Verified via System Events sending 8 sequential keystrokes - the
feedback sheet opens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…issions

- macOS sample: gate the keyDown monitor on a feedbackInProgress flag so
  typing in the feedback sheet doesn't re-trigger another splat. Clear
  the pressed-keys set on enter and exit so the gesture starts fresh.
- All four samples: treat an empty (whitespace-only) title the same as
  Cancel. No postFeedback call, no Recent Activity entry. Previously
  hitting Send with blank fields would log \"(no title)\" or
  \"Feedback submitted\" - now only real input counts as a submission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The footer copy says \"Shake the device to send feedback anytime.\" but
the gesture wasn't actually implemented. Wire it up:

- UIKit-ObjC: override motionEnded:withEvent: on the view controller
  (already becomes first responder); on .motionShake open the
  existing feedback dialog.
- UIKit-Swift: same pattern - override canBecomeFirstResponder + call
  becomeFirstResponder in viewDidAppear + override motionEnded(_:with:).
- SwiftUI: add ShakeDetector (UIViewControllerRepresentable) that
  hosts a tiny first-responder view controller whose
  motionEnded(_:with:) calls back into the SwiftUI view. Use as a
  zero-size .background overlay in ContentView.

Verified in iOS Simulator via Device > Shake on all three apps - the
Send Feedback alert opens.

(Earlier attempt swizzled UIWindow.motionEnded but Swift extensions
can't override, and method_exchangeImplementations against an
inherited method silently rewires the base class for every UIResponder
subclass - the SwiftUI app crashed at launch as a result. The
representable approach is constrained to one well-defined first
responder and avoids the swizzle entirely.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small fixes after end-to-end testing:

- Splat gesture: drop threshold from 8 distinct keys / 1.2s to 6 distinct
  keys / 0.5s. MacBook keyboards have a 6-key rollover ceiling so 8
  simultaneous keys physically can't all register; 6 fits and the tighter
  window keeps real typing from triggering it. Footer copy updated to
  match.
- AppDelegate now installs a minimal Edit menu (Cut/Copy/Paste/Select
  All). The storyboard's MainMenu only had App + Window menus, so
  NSTextField didn't get standard Cmd+A/C/V/X - which mattered when stray
  splat keys leaked into the feedback fields.
- Recent Activity list caps visible rows at 5. The log still persists 10,
  but rendering more was growing the card off the bottom of the window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bobbyg603 and others added 12 commits May 18, 2026 11:02
… token

Two issues exposed by Copilot review and a flaky iOS CI failure:

* The watchdog's "device slept" guard fired whenever scheduler jitter
  exceeded the hang threshold. At small thresholds (0.2s in tests) any
  loaded CI machine could oversleep past that limit on a cold thread
  start, causing the watchdog to silently reset and miss the hang.
  Use a separate suspension overshoot of max(threshold * 5, 2s).

* -stop only flipped a shared boolean. A quick stop/start could revive
  the previous watchdog alongside the new one. Replace the boolean with
  a per-instance generation counter captured by each watchdog thread;
  bumping it on -stop or a subsequent -start strands the old thread.

Also tighten BugSplat.h docs: hang detection is suppressed while the
app is inactive, so background-task expirations are not covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apps with workloads that legitimately occupy the main thread for over 2s
(image decoding, large JSON parsing on launch, etc.) need a way to tune
the threshold without forking the library. Add a public NSTimeInterval
property with the existing 2.0s default; the underlying tracker still
clamps values below 0.1s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
previousPollEnd is seeded when the watchdog thread is constructed, not
when it actually wakes for the first time. On loaded CI runners the
initial scheduling of a fresh utility-QoS thread can lag by several
seconds, which the suspension guard then mistakes for device sleep and
resets the processing-start timestamp - causing the tracker to miss a
hang already in progress on the main thread.

Skip the guard on the first iteration only; subsequent iterations have
a real measurement to compare against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test parallelization on the CI simulator host (multiple xctest processes
sharing one VM) can starve the utility-QoS watchdog thread enough that
sub-second thresholds become flaky. Bump the test threshold from 0.2s
to 0.5s and proportionally lengthen the main-thread sleeps and
expectation timeouts. Production defaults remain unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parallel xctest processes share the simulator host and starve the
hang-tracker's utility-QoS watchdog thread enough to make
sub-second timing tests unreliable on GitHub-hosted runners. Run
the iOS suite serially.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…istic tests

The integration tests were flaky on CI because they relied on real wall-clock
timing and live thread scheduling - on a loaded shared simulator host the
utility-QoS watchdog thread could be starved enough to miss hangs or to take
tens of seconds to deliver recovery callbacks. Widening test timing and
disabling parallel tests papered over the symptom without addressing the
design.

Refactor BugSplatHangTracker so the watchdog logic is observable without
real threading:

* Add an internal initializer that accepts a clock block and a recovery
  dispatcher block (defaults preserve production behavior).
* Factor the per-iteration watchdog logic out of watchdogThreadMain into
  -_pollAtTime: so tests can drive the state machine deterministically.
* Expose -_simulateRunLoopActivity: so tests can drive the observer
  callback without installing a real CFRunLoopObserver.

Rewrite BugSplatHangTrackerTests to use a fake clock and a synchronous
recovery dispatcher. Each behavioral test now runs in ~1ms instead of
~1s and tests cover scenarios the integration tests could only approximate:
suspension-guard reset, first-poll skip, throttle window recycle, recovery
across different activity transitions.

Revert the .github/workflows/tests.yml workaround that disabled parallel
testing - no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bobbyg603 and others added 3 commits May 18, 2026 11:54
The previous design tracked four CFRunLoopObserver activities to maintain
a processingStartWallClock timestamp, used a per-instance generation counter
to make stop/start safe, and had a first-poll skip plus a widened wall-clock
suspension floor to absorb scheduling noise. All of that is more machinery
than this detector needs.

Replace it with the standard "ping the main queue and count unanswered
pings" pattern:

* The watchdog dispatch_asyncs a small block to dispatch_get_main_queue()
  on every poll and increments an atomic counter. The block, when serviced,
  resets the counter. If the counter accumulates enough unanswered pings
  to cover `thresholdSeconds`, the main thread is hung.
* Recovery fires on the first pong after a hang was reported.
* Suspension guard is now a single check: if the watchdog's actual sleep
  duration exceeded `threshold * 2`, the device was suspended and the
  counter is reset.

Drops:
- CFRunLoopObserver install/remove and handleRunLoopActivity.
- processingStartWallClock and its kCFRunLoopBeforeWaiting clearing logic.
- watchdogGeneration counter and the per-iteration generation check
  (BugSplat.m never restarts the tracker; a simple isRunning flag is
  sufficient and clearer).
- The first-poll skip and kMinSuspensionOvershootSeconds constant.

The public delegate API and BugSplat.m integration are unchanged. Tests
now drive the simpler state machine via two helpers - one to simulate
an unanswered poll and one to simulate a pong - and each runs in under
1ms with no real-time dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous design tracked four CFRunLoopObserver activities to maintain
a processingStartWallClock timestamp, used a per-instance generation counter
to make stop/start safe, and had a first-poll skip plus a widened wall-clock
suspension floor to absorb scheduling noise. All of that is more machinery
than this detector needs.

Replace it with the standard "ping the main queue and count unanswered
pings" pattern:

* The watchdog dispatch_asyncs a small block to dispatch_get_main_queue()
  on every poll and increments an atomic counter. The block, when serviced,
  resets the counter. If the counter accumulates enough unanswered pings
  to cover `thresholdSeconds`, the main thread is hung.
* Recovery fires on the first pong after a hang was reported.
* Suspension guard is now a single check: if the watchdog's actual sleep
  duration exceeded `threshold * 2`, the device was suspended and the
  counter is reset.

Drops:
- CFRunLoopObserver install/remove and handleRunLoopActivity.
- processingStartWallClock and its kCFRunLoopBeforeWaiting clearing logic.
- watchdogGeneration counter and the per-iteration generation check
  (BugSplat.m never restarts the tracker; a simple isRunning flag is
  sufficient and clearer).
- The first-poll skip and kMinSuspensionOvershootSeconds constant.

The public delegate API and BugSplat.m integration are unchanged. Tests
now drive the simpler state machine via two helpers - one to simulate
an unanswered poll and one to simulate a pong - and each runs in under
1ms with no real-time dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bobbyg603 bobbyg603 changed the base branch from feat/ios-hang-detection to feat/hang-detection-v2 May 18, 2026 15:59
@bobbyg603

Copy link
Copy Markdown
Member Author

Closing in favor of #57 — rebased onto the squashed parent (#56) so the diff is just the sample app changes, not a merge maze.

@bobbyg603 bobbyg603 closed this May 18, 2026
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