From a4528f57b86225695c43464bbfcdbdcc752fa2d5 Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:28:41 +0300 Subject: [PATCH 1/7] Fix App Shortcuts phrase missing applicationName placeholder Siri Shortcut phrases must contain \(.applicationName); a hardcoded "Find Flipper" phrase fails App Shortcuts validation and breaks the build. --- Flipper/AppIntents/FlipperShortcuts.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Flipper/AppIntents/FlipperShortcuts.swift b/Flipper/AppIntents/FlipperShortcuts.swift index cf9d7570..817f44b2 100644 --- a/Flipper/AppIntents/FlipperShortcuts.swift +++ b/Flipper/AppIntents/FlipperShortcuts.swift @@ -18,7 +18,8 @@ struct FlipperShortcuts: AppShortcutsProvider { AppShortcut( intent: PlayAlert(), phrases: [ - "Find Flipper" + "Find \(.applicationName)", + "Find my \(.applicationName)" ] ) } From ee8c649753d2cacc9cc101bf3274781a8c7d1bf7 Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:27:25 +0300 Subject: [PATCH 2/7] [Overlay] Fix hit-testing via declared interaction regions Replace private hit-test traversal (relied on UIHostingController view hierarchy internals that broke on iOS 18 and again on iOS 26) with an explicit interaction mode each overlay declares: fullscreen blocks all touches, regions([CGRect]) passes through everywhere else. Also adds an Options screen to manually verify tap passthrough for notifications, alerts, and popups. --- .../Overlay/OverlayController.swift | 61 +++++++- .../Overlay/OverlayModifier.swift | 7 +- .../Overlay/OverlayWindow.swift | 81 ++--------- .../Views/NotificationView.swift | 28 +++- Flipper/iOS/UI/Options/OptionsView.swift | 6 + .../Options/OverlayTest/OverlayTestView.swift | 134 ++++++++++++++++++ 6 files changed, 240 insertions(+), 77 deletions(-) create mode 100644 Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift index f8e813e9..91408673 100644 --- a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift +++ b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift @@ -1,26 +1,48 @@ import SwiftUI +enum OverlayInteraction { + case fullscreen + case regions([CGRect]) +} + class OverlayController: ObservableObject { - private var overlay: UIWindow? - private var views: [UIView] + private final class Entry { + var view: UIView? + var interaction: OverlayInteraction + + init(interaction: OverlayInteraction) { + self.interaction = interaction + } + } + + private var overlay: OverlayWindow? + private var entries: [Entry] init() { self.overlay = OverlayWindow() - self.views = [] + self.entries = [] } func present( + interaction: OverlayInteraction = .fullscreen, @ViewBuilder content: @escaping () -> Content ) { guard let overlay else { return } + let entry = Entry(interaction: interaction) + let viewController = UIHostingController( rootView: content() .environmentObject(self) + .environment( + \.updateOverlayInteraction, + makeUpdateInteraction(for: entry) + ) ) viewController.view.backgroundColor = .clear - views.append(viewController.view) + entry.view = viewController.view + entries.append(entry) if let rootViewController = overlay.rootViewController { viewController.view.frame = rootViewController.view.frame @@ -29,18 +51,20 @@ class OverlayController: ObservableObject { overlay.isUserInteractionEnabled = true overlay.isHidden = false } + + updateWindowInteraction() } func dismiss() { guard let overlay else { return } - guard !views.isEmpty else { + guard !entries.isEmpty else { return } - views.removeFirst() + entries.removeFirst() - if let first = views.first { + if let first = entries.first?.view { guard let rootViewController = overlay.rootViewController else { @@ -55,5 +79,28 @@ class OverlayController: ObservableObject { overlay.isUserInteractionEnabled = false overlay.rootViewController = nil } + + updateWindowInteraction() + } + + private func makeUpdateInteraction( + for entry: Entry + ) -> (OverlayInteraction) -> Void { + // NOTE: weak entry makes late frame reports from a queued + // overlay update its own entry instead of the visible one + { [weak self, weak entry] interaction in + guard let self, let entry else { return } + entry.interaction = interaction + self.updateWindowInteraction() + } } + + private func updateWindowInteraction() { + overlay?.interaction = entries.first?.interaction + } +} + +extension EnvironmentValues { + @Entry var updateOverlayInteraction: + (OverlayInteraction) -> Void = { _ in } } diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift index 7c98f146..03f676f6 100644 --- a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift +++ b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift @@ -2,15 +2,18 @@ import SwiftUI struct OverlayModifier: ViewModifier { var isPresented: Binding + var interaction: OverlayInteraction @ViewBuilder var overlayContent: () -> OverlayContent @EnvironmentObject private var controller: OverlayController init( isPresented: Binding, + interaction: OverlayInteraction = .fullscreen, @ViewBuilder overlayContent: @escaping () -> OverlayContent ) { self.isPresented = isPresented + self.interaction = interaction self.overlayContent = overlayContent } @@ -20,7 +23,9 @@ struct OverlayModifier: ViewModifier { // change doesn't fire when containing view was dismissed .onChange(of: isPresented.wrappedValue) { newValue in if newValue { - controller.present(content: overlayContent) + controller.present( + interaction: interaction, + content: overlayContent) } } } diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift index dab5af58..7dd55724 100644 --- a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift +++ b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift @@ -1,6 +1,12 @@ import SwiftUI class OverlayWindow: UIWindow { + // NOTE: Hit-testing is driven by the interaction mode declared + // by the visible overlay instead of inspecting the private + // UIHostingController view hierarchy which changes between + // iOS releases (broke on iOS 18 and again on iOS 26) + var interaction: OverlayInteraction? + init?(scene: UIScene? = UIApplication.shared.connectedScenes.first) { guard let windowScene = scene as? UIWindowScene else { return nil } super.init(windowScene: windowScene) @@ -12,75 +18,14 @@ class OverlayWindow: UIWindow { fatalError("init(coder:) has not been implemented") } - // NOTE: Solution from philip_trauner - // https://forums.developer.apple.com - // /forums/thread/762292?answerId=803885022#803885022 - private static func _hitTest( - _ point: CGPoint, - with event: UIEvent?, - view: UIView, - depth: Int = 0 - ) -> (view: UIView, depth: Int)? { - var deepest: (view: UIView, depth: Int)? - - for subview in view.subviews.reversed() { - let converted = view.convert(point, to: subview) - - guard subview.isUserInteractionEnabled, - !subview.isHidden, - subview.alpha > 0, - subview.point(inside: converted, with: event) - else { - continue - } - - let result = if let hit = Self._hitTest( - converted, - with: event, - view: subview, - depth: depth + 1 - ) { - hit - } else { - (view: subview, depth: depth) - } - - if case .none = deepest { - deepest = result - } else if let current = deepest, result.depth > current.depth { - deepest = result - } - } - - return deepest - } - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - if #available(iOS 18, *) { - guard let view = rootViewController?.view else { - return false - } - - let hit = Self._hitTest( - point, - with: event, - view: subviews.count > 1 ? self : view - ) - - return hit != nil - } else { - return super.point(inside: point, with: event) - } - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if #available(iOS 18, *) { - return super.hitTest(point, with: event) - } else { - guard let hit = super.hitTest(point, with: event) else { - return .none - } - return rootViewController?.view == hit ? .none : hit + switch interaction { + case .fullscreen: + return true + case .regions(let regions): + return regions.contains { $0.contains(point) } + case .none: + return false } } } diff --git a/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift b/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift index a0134ac8..dbac2e0c 100644 --- a/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift +++ b/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift @@ -7,6 +7,8 @@ struct NotificationView: View { @State private var isPresentedAnimated: Bool = false @EnvironmentObject var controller: OverlayController + @Environment(\.updateOverlayInteraction) + private var updateInteraction var animationDuration: Double { 0.1 } var presentingDuration: Double { 5.0 } @@ -14,8 +16,29 @@ struct NotificationView: View { var body: some View { ZStack(alignment: .bottom) { content + // NOTE: collapses the Spacer inside Banner so the + // measured frame is the visible banner, not the + // whole screen + .fixedSize(horizontal: false, vertical: true) + .background( + GeometryReader { proxy in + Color.clear + .onAppear { + updateInteraction( + .regions([proxy.frame(in: .global)])) + } + .onChange(of: proxy.frame(in: .global)) { frame in + updateInteraction(.regions([frame])) + } + } + ) .padding(.bottom, 50) .opacity(isPresentedAnimated ? 1 : 0) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) } .onChange(of: isPresented) { newValue in guard !newValue else { return } @@ -53,7 +76,10 @@ extension View { isPresented: Binding, @ViewBuilder content: @escaping () -> Content ) -> some View { - self.modifier(OverlayModifier(isPresented: isPresented) { + self.modifier(OverlayModifier( + isPresented: isPresented, + interaction: .regions([]) + ) { NotificationView( isPresented: isPresented, content: content()) diff --git a/Flipper/iOS/UI/Options/OptionsView.swift b/Flipper/iOS/UI/Options/OptionsView.swift index 12ddb6c7..7da6a25b 100644 --- a/Flipper/iOS/UI/Options/OptionsView.swift +++ b/Flipper/iOS/UI/Options/OptionsView.swift @@ -28,6 +28,7 @@ struct OptionsView: View { case fileManager case reportBug case infrared + case overlayTest } var body: some View { @@ -111,6 +112,10 @@ struct OptionsView: View { } .tint(.a1) + NavigationLink(value: Destination.overlayTest) { + Text("Overlay test") + } + #if DEBUG NavigationLink(value: Destination.infrared) { Text("Infrared layouts") @@ -158,6 +163,7 @@ struct OptionsView: View { case .fileManager: FileManagerView() case .reportBug: ReportBugView() case .infrared: InfraredDebugLayout() + case .overlayTest: OverlayTestView() } } } diff --git a/Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift b/Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift new file mode 100644 index 00000000..b7c50b30 --- /dev/null +++ b/Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct OverlayTestView: View { + @Environment(\.dismiss) private var dismiss + + @State private var showAlert = false + @State private var showNotification = false + @State private var showPopup = false + + @State private var tapCount = 0 + + var body: some View { + List { + Section { + Button { + tapCount += 1 + } label: { + HStack { + Text("Tap Counter") + Spacer() + Text("\(tapCount)") + .foregroundColor(.black40) + } + } + } header: { + Text("Passthrough Check") + } footer: { + Text( + "While the notification banner is visible this " + + "button must stay tappable, but taps on the banner " + + "itself must not reach the screen behind it. " + + "While the alert or popup is visible the whole " + + "screen must be blocked." + ) + } + + Section(header: Text("Overlays")) { + Button("Show Alert") { + showAlert = true + } + Button("Show Notification") { + showNotification = true + } + Button("Show Popup") { + showPopup = true + } + Button("Show Notification, then Alert") { + showNotification = true + showAlert = true + } + } + } + .navigationBarBackground(Color.a1) + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + LeadingToolbarItems { + BackButton { + dismiss() + } + } + PrincipalToolbarItems(alignment: .leading) { + Title("Overlay Test") + } + } + .alert(isPresented: $showAlert) { + OverlayTestAlert(isPresented: $showAlert) + } + .notification(isPresented: $showNotification) { + Banner( + image: "Done", + title: "Test Notification", + description: "Taps around me should pass through" + ) + } + .popup(isPresented: $showPopup) { + OverlayTestPopup() + .frame(maxWidth: .infinity) + .padding(.top, 120) + } + } +} + +extension OverlayTestView { + struct OverlayTestAlert: View { + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 4) { + Text("Test Alert") + .font(.system(size: 14, weight: .bold)) + + Text( + "Taps on the dimmed background must not " + + "reach the screen behind this alert." + ) + .font(.system(size: 14, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundColor(.black40) + .padding(.horizontal, 12) + } + .padding(.top, 25) + + AlertButtons( + isPresented: $isPresented, + text: "Got It", + cancel: "Cancel" + ) { + } + } + } + } + + struct OverlayTestPopup: View { + var body: some View { + HStack { + Spacer() + Card { + VStack(spacing: 4) { + Text("Test Popup") + .font(.system(size: 14, weight: .bold)) + + Text("Tap outside to dismiss") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.black40) + } + .padding(12) + } + Spacer() + } + } + } +} From cac4cfee54190fe1c31d1ae86b81e3cb233f4cbd Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:49:31 +0300 Subject: [PATCH 3/7] [UI] Hide overlay test screen from release builds Wrap the Options debug menu entry in #if DEBUG alongside the existing Infrared layouts item so it is compiled out of Release. --- Flipper/iOS/UI/Options/OptionsView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Flipper/iOS/UI/Options/OptionsView.swift b/Flipper/iOS/UI/Options/OptionsView.swift index 7da6a25b..7006fb65 100644 --- a/Flipper/iOS/UI/Options/OptionsView.swift +++ b/Flipper/iOS/UI/Options/OptionsView.swift @@ -112,11 +112,10 @@ struct OptionsView: View { } .tint(.a1) + #if DEBUG NavigationLink(value: Destination.overlayTest) { Text("Overlay test") } - - #if DEBUG NavigationLink(value: Destination.infrared) { Text("Infrared layouts") } From 916dc76b2bf0a94a3eee008f8719521ab954997f Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:10:34 +0300 Subject: [PATCH 4/7] Add Pullfrog PR review workflow and Claude/XcodeBuildMCP config Mirrors the setup from busy-app/iOS: automated PR review via Pullfrog, XcodeBuildMCP wired to the Flipper(iOS) scheme, and a CLAUDE.md (symlinked as AGENTS.md) describing build/test commands and package architecture. --- .github/workflows/pullfrog.yml | 61 ++++++++++++++++++++++++++++++++++ .mcp.json | 12 +++++++ .xcodebuildmcp/config.yaml | 26 +++++++++++++++ AGENTS.md | 1 + CLAUDE.md | 58 ++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 .github/workflows/pullfrog.yml create mode 100644 .mcp.json create mode 100644 .xcodebuildmcp/config.yaml create mode 120000 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 00000000..9e660013 --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,61 @@ +name: Pullfrog Review + +on: + pull_request: + types: [labeled] + +permissions: + id-token: write + contents: read + pull-requests: write + issues: write + actions: read + checks: read + statuses: write + +concurrency: + group: pullfrog-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + if: github.event.pull_request.head.repo.full_name == github.repository && github.event.label.name == 'pullfrog-review' + runs-on: ubuntu-latest + steps: + - name: Remove trigger label + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: 'pullfrog-review', + }); + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Build prompt + id: build_prompt + run: | + { + echo "prompt<> "$GITHUB_OUTPUT" + + - name: Run pullfrog + uses: pullfrog/pullfrog@cff281d72c6a5e38af001e7b38d15cf4dedda768 # v0.1.43 + with: + prompt: ${{ steps.build_prompt.outputs.prompt }} + model: ${{ vars.PULLFROG_MODEL }} + push: disabled + status_checks: enabled + timeout: 30m + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..f610fbc6 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "XcodeBuildMCP": { + "type": "stdio", + "command": "/opt/homebrew/bin/xcodebuildmcp", + "args": [ + "mcp" + ], + "env": {} + } + } +} diff --git a/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml new file mode 100644 index 00000000..5dbe8a61 --- /dev/null +++ b/.xcodebuildmcp/config.yaml @@ -0,0 +1,26 @@ +schemaVersion: 1 + +enabledWorkflows: + - simulator + - simulator-management + - swift-package + - ui-automation + - logging + - coverage + - project-discovery + - xcode-ide + +disableSessionDefaults: false +activeSessionDefaultsProfile: ios +sessionDefaultsProfiles: + ios: + projectPath: "./Flipper/Flipper.xcodeproj" + scheme: "Flipper(iOS)" + configuration: "Debug" + simulatorName: "iPhone 17 Pro" + simulatorPlatform: "iOS Simulator" + useLatestOS: true + +incrementalBuildsEnabled: false +debug: false +sentryDisabled: true diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..bee7cf27 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Flipper-iOS-App is the iOS companion app for the Flipper Zero device family ("Mobile app to rule all the Flipper's family"). It talks to a physical Flipper device over Bluetooth, manages its file archive (NFC/RFID/Infrared/iButton/Sub-GHz keys), and integrates with the Flipper cloud catalog/backend. + +## Build & test commands + +The Xcode project lives at `Flipper/Flipper.xcodeproj`, scheme `Flipper(iOS)`. There is no fastlane/CI build pipeline in this repo — builds and tests are run through Xcode or `xcodebuild` directly. + +```bash +# Build the app +xcodebuild -project Flipper/Flipper.xcodeproj -scheme "Flipper(iOS)" -destination "platform=iOS Simulator,name=iPhone 17 Pro" build + +# Lint (SwiftLint, config at Flipper/.swiftlint.yml) +cd Flipper && ./perform_lint.sh +# or directly: +cd Flipper && ./swiftlint +``` + +Each feature/domain package under `Flipper/Packages/*` is an independent Swift Package with its own `Tests/` directory, so tests can be run per-package without opening the full app: + +```bash +# Run all tests for one package +cd Flipper/Packages/Core && swift test + +# Run a single test (XCTest selector syntax) +cd Flipper/Packages/Core && swift test --filter ArchiveTests/testSomething +``` + +This repo has `.mcp.json` configured for XcodeBuildMCP and `.xcodebuildmcp/config.yaml` with a default session profile (`ios`) pointed at the `Flipper(iOS)` scheme — prefer the XcodeBuildMCP tools over raw `xcodebuild` invocations when available. + +## Code style + +- Line length limit is 80 characters (enforced by both `.editorconfig` and SwiftLint `line_length`). +- SwiftLint disables `todo`, `nesting`, `opening_brace`, `identifier_name`, `cyclomatic_complexity`, `void_function_in_ternary` — don't fight these where they'd normally fire. +- `Packages/Peripheral` and parts of `Packages/Core` (protobuf-generated sources, the `Version` utils) are excluded from lint — don't expect clean lint output there and don't try to "fix" generated code style. + +## Architecture + +The codebase is split between the iOS app target (`Flipper/iOS`, `Flipper/AppIntents`, `Flipper/Shared`, `Flipper/ActivityWidget`, `Flipper/LiveWidget`, `Flipper/KeyPreview`) and a set of local Swift Packages under `Flipper/Packages/` that contain essentially all business logic. The app target is thin — it composes SwiftUI screens (`Flipper/iOS/UI/{Apps,Archive,Device,Hub,Infrared,Main,Options,RemoteControl,TabView,Welcome,...}`) on top of these packages, plus App Intents/Shortcuts support (`Flipper/AppIntents`) and widgets (`ActivityWidget`, `LiveWidget`). + +Package dependency graph (leaf → root): + +- **Macro** — standalone Swift macro plugin (`SwiftSyntax`-based), depended on by `Backend` and `Core` for compile-time code generation. +- **Peripheral** — lowest-level layer: Bluetooth transport and the RPC/protobuf protocol spoken with the physical Flipper device (`Sources/Bluetooth`, `Sources/RPC/{Model,Protobuf,Session}`). No dependency on other local packages. +- **Analytics** — event tracking abstraction with backends for Countly, Clickhouse, and "WantMoar", plus its own protobuf event schema. +- **Activity** — Live Activity / progress UI support, consumed by `Core`. +- **MFKey32v2** — a C library (`CCrapto1`) wrapped in Swift for Mifare Classic key-recovery ("reader attack") functionality. +- **Backend** — networking clients for the Flipper cloud services: `Catalog` (app/firmware catalog) and `Infrared` (IR signal database), built on `Macro`. +- **Core** — the app's domain layer; depends on all of the above (`Macro`, `Analytics`, `Activity`, `Peripheral`, `MFKey32v2`, `Backend`). Owns device pairing (`PairedDevice`), the local file archive and its favorites/manifest handling (`Archive`), sharing/deep-link encoding (`Sharing`), OTA firmware updates (`Update`), region/provisioning data (`Provisioning`), the reader-attack flow (`ReaderAttack`), and the high-level `Flipper` service that apps/UI code talk to. +- **Notifications** — push notifications via Firebase Cloud Messaging; kept separate from `Core` since it pulls in the Firebase SDK. + +When changing device-communication behavior, start in `Peripheral` (transport/protocol) vs. `Core/Sources/Flipper` (device service facade) depending on whether the change is protocol-level or app-facing. When changing anything catalog/IR-database related, that's `Backend`, not `Core`. + +`Core`, `Backend`, `Peripheral`, and `Analytics` all declare `swift-protobuf` dependencies and carry generated `Protobuf` sources — treat files under any `Sources/**/Protobuf` or `Sources/**/events` directory as generated, not hand-written. From 94163dc45415b1e565b7202f1949ae3744db78bd Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:14:23 +0300 Subject: [PATCH 5/7] Add SwiftLint PostToolUse hook for Claude Code Auto-lints .swift files with SwiftLint right after Claude edits them, using the project's Flipper/.swiftlint.yml config, with a fallback to the vendored Flipper/swiftlint binary if the system one isn't installed. --- .claude/settings.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..58df69d6 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // empty' | { read -r file; if [[ \"$file\" == *.swift ]]; then (command -v swiftlint >/dev/null && swiftlint lint --quiet --no-cache --config Flipper/.swiftlint.yml \"$file\") || Flipper/swiftlint lint --quiet --no-cache --config Flipper/.swiftlint.yml \"$file\"; fi; }" + } + ] + } + ] + } +} From fc68eea7b1c654df4861e688f1723d2675270427 Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:27:25 +0300 Subject: [PATCH 6/7] [Overlay] Fix hit-testing via declared interaction regions Replace private hit-test traversal (relied on UIHostingController view hierarchy internals that broke on iOS 18 and again on iOS 26) with an explicit interaction mode each overlay declares: fullscreen blocks all touches, regions([CGRect]) passes through everywhere else. Also adds an Options screen to manually verify tap passthrough for notifications, alerts, and popups. --- .../Overlay/OverlayController.swift | 61 +++++++- .../Overlay/OverlayModifier.swift | 7 +- .../Overlay/OverlayWindow.swift | 81 ++--------- .../Views/NotificationView.swift | 28 +++- Flipper/iOS/UI/Options/OptionsView.swift | 6 + .../Options/OverlayTest/OverlayTestView.swift | 134 ++++++++++++++++++ 6 files changed, 240 insertions(+), 77 deletions(-) create mode 100644 Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift index f8e813e9..91408673 100644 --- a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift +++ b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift @@ -1,26 +1,48 @@ import SwiftUI +enum OverlayInteraction { + case fullscreen + case regions([CGRect]) +} + class OverlayController: ObservableObject { - private var overlay: UIWindow? - private var views: [UIView] + private final class Entry { + var view: UIView? + var interaction: OverlayInteraction + + init(interaction: OverlayInteraction) { + self.interaction = interaction + } + } + + private var overlay: OverlayWindow? + private var entries: [Entry] init() { self.overlay = OverlayWindow() - self.views = [] + self.entries = [] } func present( + interaction: OverlayInteraction = .fullscreen, @ViewBuilder content: @escaping () -> Content ) { guard let overlay else { return } + let entry = Entry(interaction: interaction) + let viewController = UIHostingController( rootView: content() .environmentObject(self) + .environment( + \.updateOverlayInteraction, + makeUpdateInteraction(for: entry) + ) ) viewController.view.backgroundColor = .clear - views.append(viewController.view) + entry.view = viewController.view + entries.append(entry) if let rootViewController = overlay.rootViewController { viewController.view.frame = rootViewController.view.frame @@ -29,18 +51,20 @@ class OverlayController: ObservableObject { overlay.isUserInteractionEnabled = true overlay.isHidden = false } + + updateWindowInteraction() } func dismiss() { guard let overlay else { return } - guard !views.isEmpty else { + guard !entries.isEmpty else { return } - views.removeFirst() + entries.removeFirst() - if let first = views.first { + if let first = entries.first?.view { guard let rootViewController = overlay.rootViewController else { @@ -55,5 +79,28 @@ class OverlayController: ObservableObject { overlay.isUserInteractionEnabled = false overlay.rootViewController = nil } + + updateWindowInteraction() + } + + private func makeUpdateInteraction( + for entry: Entry + ) -> (OverlayInteraction) -> Void { + // NOTE: weak entry makes late frame reports from a queued + // overlay update its own entry instead of the visible one + { [weak self, weak entry] interaction in + guard let self, let entry else { return } + entry.interaction = interaction + self.updateWindowInteraction() + } } + + private func updateWindowInteraction() { + overlay?.interaction = entries.first?.interaction + } +} + +extension EnvironmentValues { + @Entry var updateOverlayInteraction: + (OverlayInteraction) -> Void = { _ in } } diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift index 7c98f146..03f676f6 100644 --- a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift +++ b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayModifier.swift @@ -2,15 +2,18 @@ import SwiftUI struct OverlayModifier: ViewModifier { var isPresented: Binding + var interaction: OverlayInteraction @ViewBuilder var overlayContent: () -> OverlayContent @EnvironmentObject private var controller: OverlayController init( isPresented: Binding, + interaction: OverlayInteraction = .fullscreen, @ViewBuilder overlayContent: @escaping () -> OverlayContent ) { self.isPresented = isPresented + self.interaction = interaction self.overlayContent = overlayContent } @@ -20,7 +23,9 @@ struct OverlayModifier: ViewModifier { // change doesn't fire when containing view was dismissed .onChange(of: isPresented.wrappedValue) { newValue in if newValue { - controller.present(content: overlayContent) + controller.present( + interaction: interaction, + content: overlayContent) } } } diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift index dab5af58..7dd55724 100644 --- a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift +++ b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayWindow.swift @@ -1,6 +1,12 @@ import SwiftUI class OverlayWindow: UIWindow { + // NOTE: Hit-testing is driven by the interaction mode declared + // by the visible overlay instead of inspecting the private + // UIHostingController view hierarchy which changes between + // iOS releases (broke on iOS 18 and again on iOS 26) + var interaction: OverlayInteraction? + init?(scene: UIScene? = UIApplication.shared.connectedScenes.first) { guard let windowScene = scene as? UIWindowScene else { return nil } super.init(windowScene: windowScene) @@ -12,75 +18,14 @@ class OverlayWindow: UIWindow { fatalError("init(coder:) has not been implemented") } - // NOTE: Solution from philip_trauner - // https://forums.developer.apple.com - // /forums/thread/762292?answerId=803885022#803885022 - private static func _hitTest( - _ point: CGPoint, - with event: UIEvent?, - view: UIView, - depth: Int = 0 - ) -> (view: UIView, depth: Int)? { - var deepest: (view: UIView, depth: Int)? - - for subview in view.subviews.reversed() { - let converted = view.convert(point, to: subview) - - guard subview.isUserInteractionEnabled, - !subview.isHidden, - subview.alpha > 0, - subview.point(inside: converted, with: event) - else { - continue - } - - let result = if let hit = Self._hitTest( - converted, - with: event, - view: subview, - depth: depth + 1 - ) { - hit - } else { - (view: subview, depth: depth) - } - - if case .none = deepest { - deepest = result - } else if let current = deepest, result.depth > current.depth { - deepest = result - } - } - - return deepest - } - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - if #available(iOS 18, *) { - guard let view = rootViewController?.view else { - return false - } - - let hit = Self._hitTest( - point, - with: event, - view: subviews.count > 1 ? self : view - ) - - return hit != nil - } else { - return super.point(inside: point, with: event) - } - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if #available(iOS 18, *) { - return super.hitTest(point, with: event) - } else { - guard let hit = super.hitTest(point, with: event) else { - return .none - } - return rootViewController?.view == hit ? .none : hit + switch interaction { + case .fullscreen: + return true + case .regions(let regions): + return regions.contains { $0.contains(point) } + case .none: + return false } } } diff --git a/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift b/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift index a0134ac8..dbac2e0c 100644 --- a/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift +++ b/Flipper/iOS/UI/InAppNotifications/Views/NotificationView.swift @@ -7,6 +7,8 @@ struct NotificationView: View { @State private var isPresentedAnimated: Bool = false @EnvironmentObject var controller: OverlayController + @Environment(\.updateOverlayInteraction) + private var updateInteraction var animationDuration: Double { 0.1 } var presentingDuration: Double { 5.0 } @@ -14,8 +16,29 @@ struct NotificationView: View { var body: some View { ZStack(alignment: .bottom) { content + // NOTE: collapses the Spacer inside Banner so the + // measured frame is the visible banner, not the + // whole screen + .fixedSize(horizontal: false, vertical: true) + .background( + GeometryReader { proxy in + Color.clear + .onAppear { + updateInteraction( + .regions([proxy.frame(in: .global)])) + } + .onChange(of: proxy.frame(in: .global)) { frame in + updateInteraction(.regions([frame])) + } + } + ) .padding(.bottom, 50) .opacity(isPresentedAnimated ? 1 : 0) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) } .onChange(of: isPresented) { newValue in guard !newValue else { return } @@ -53,7 +76,10 @@ extension View { isPresented: Binding, @ViewBuilder content: @escaping () -> Content ) -> some View { - self.modifier(OverlayModifier(isPresented: isPresented) { + self.modifier(OverlayModifier( + isPresented: isPresented, + interaction: .regions([]) + ) { NotificationView( isPresented: isPresented, content: content()) diff --git a/Flipper/iOS/UI/Options/OptionsView.swift b/Flipper/iOS/UI/Options/OptionsView.swift index 12ddb6c7..7da6a25b 100644 --- a/Flipper/iOS/UI/Options/OptionsView.swift +++ b/Flipper/iOS/UI/Options/OptionsView.swift @@ -28,6 +28,7 @@ struct OptionsView: View { case fileManager case reportBug case infrared + case overlayTest } var body: some View { @@ -111,6 +112,10 @@ struct OptionsView: View { } .tint(.a1) + NavigationLink(value: Destination.overlayTest) { + Text("Overlay test") + } + #if DEBUG NavigationLink(value: Destination.infrared) { Text("Infrared layouts") @@ -158,6 +163,7 @@ struct OptionsView: View { case .fileManager: FileManagerView() case .reportBug: ReportBugView() case .infrared: InfraredDebugLayout() + case .overlayTest: OverlayTestView() } } } diff --git a/Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift b/Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift new file mode 100644 index 00000000..b7c50b30 --- /dev/null +++ b/Flipper/iOS/UI/Options/OverlayTest/OverlayTestView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct OverlayTestView: View { + @Environment(\.dismiss) private var dismiss + + @State private var showAlert = false + @State private var showNotification = false + @State private var showPopup = false + + @State private var tapCount = 0 + + var body: some View { + List { + Section { + Button { + tapCount += 1 + } label: { + HStack { + Text("Tap Counter") + Spacer() + Text("\(tapCount)") + .foregroundColor(.black40) + } + } + } header: { + Text("Passthrough Check") + } footer: { + Text( + "While the notification banner is visible this " + + "button must stay tappable, but taps on the banner " + + "itself must not reach the screen behind it. " + + "While the alert or popup is visible the whole " + + "screen must be blocked." + ) + } + + Section(header: Text("Overlays")) { + Button("Show Alert") { + showAlert = true + } + Button("Show Notification") { + showNotification = true + } + Button("Show Popup") { + showPopup = true + } + Button("Show Notification, then Alert") { + showNotification = true + showAlert = true + } + } + } + .navigationBarBackground(Color.a1) + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + LeadingToolbarItems { + BackButton { + dismiss() + } + } + PrincipalToolbarItems(alignment: .leading) { + Title("Overlay Test") + } + } + .alert(isPresented: $showAlert) { + OverlayTestAlert(isPresented: $showAlert) + } + .notification(isPresented: $showNotification) { + Banner( + image: "Done", + title: "Test Notification", + description: "Taps around me should pass through" + ) + } + .popup(isPresented: $showPopup) { + OverlayTestPopup() + .frame(maxWidth: .infinity) + .padding(.top, 120) + } + } +} + +extension OverlayTestView { + struct OverlayTestAlert: View { + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 4) { + Text("Test Alert") + .font(.system(size: 14, weight: .bold)) + + Text( + "Taps on the dimmed background must not " + + "reach the screen behind this alert." + ) + .font(.system(size: 14, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundColor(.black40) + .padding(.horizontal, 12) + } + .padding(.top, 25) + + AlertButtons( + isPresented: $isPresented, + text: "Got It", + cancel: "Cancel" + ) { + } + } + } + } + + struct OverlayTestPopup: View { + var body: some View { + HStack { + Spacer() + Card { + VStack(spacing: 4) { + Text("Test Popup") + .font(.system(size: 14, weight: .bold)) + + Text("Tap outside to dismiss") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.black40) + } + .padding(12) + } + Spacer() + } + } + } +} From cb1e71541e45c9990d7487f1a61a49ffc53c334b Mon Sep 17 00:00:00 2001 From: Programistich <35292229+Programistich@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:49:31 +0300 Subject: [PATCH 7/7] [UI] Hide overlay test screen from release builds Wrap the Options debug menu entry in #if DEBUG alongside the existing Infrared layouts item so it is compiled out of Release. --- Flipper/iOS/UI/Options/OptionsView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Flipper/iOS/UI/Options/OptionsView.swift b/Flipper/iOS/UI/Options/OptionsView.swift index 7da6a25b..7006fb65 100644 --- a/Flipper/iOS/UI/Options/OptionsView.swift +++ b/Flipper/iOS/UI/Options/OptionsView.swift @@ -112,11 +112,10 @@ struct OptionsView: View { } .tint(.a1) + #if DEBUG NavigationLink(value: Destination.overlayTest) { Text("Overlay test") } - - #if DEBUG NavigationLink(value: Destination.infrared) { Text("Infrared layouts") }