feat: rebuild iOS sample apps to match Android demo refresh#53
Closed
bobbyg603 wants to merge 32 commits into
Closed
feat: rebuild iOS sample apps to match Android demo refresh#53bobbyg603 wants to merge 32 commits into
bobbyg603 wants to merge 32 commits into
Conversation
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>
… 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>
…at/ios-samples-revamp
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>
…at/ios-samples-revamp
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>
…at/ios-samples-revamp
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>
…at/ios-samples-revamp
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>
…at/ios-samples-revamp
…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>
…at/ios-samples-revamp
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>
…at/ios-samples-revamp
3 tasks
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
Actions (parity across apps):
Recent Activity persistence:
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
Not touched
Test plan
🤖 Generated with Claude Code