diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..58df69d6c --- /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; }" + } + ] + } + ] + } +} diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 000000000..9e6600134 --- /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 000000000..f610fbc6b --- /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 000000000..5dbe8a61e --- /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 000000000..681311eb9 --- /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 000000000..bee7cf270 --- /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. diff --git a/Flipper/AppIntents/FlipperShortcuts.swift b/Flipper/AppIntents/FlipperShortcuts.swift index cf9d75709..817f44b2c 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)" ] ) } diff --git a/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift b/Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift index f8e813e96..91408673f 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 7c98f1467..03f676f63 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 dab5af587..7dd55724c 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 a0134ac8d..dbac2e0cc 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 12ddb6c71..7006fb650 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 { @@ -112,6 +113,9 @@ struct OptionsView: View { .tint(.a1) #if DEBUG + NavigationLink(value: Destination.overlayTest) { + Text("Overlay test") + } NavigationLink(value: Destination.infrared) { Text("Infrared layouts") } @@ -158,6 +162,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 000000000..b7c50b308 --- /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() + } + } + } +}