Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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; }"
}
]
}
]
}
}
61 changes: 61 additions & 0 deletions .github/workflows/pullfrog.yml
Original file line number Diff line number Diff line change
@@ -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<<PULLFROG_EOF"
echo "Review pull request #${{ github.event.pull_request.number }} in ${{ github.repository }}."
echo "Fetch the PR diff and description via the GitHub API, review the changes for bugs, logic errors, and design problems, and leave a PR review with inline comments on specific problem areas. Do not push any commits."
echo "PULLFROG_EOF"
} >> "$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 }}
12 changes: 12 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mcpServers": {
"XcodeBuildMCP": {
"type": "stdio",
"command": "/opt/homebrew/bin/xcodebuildmcp",
"args": [
"mcp"
],
"env": {}
}
}
}
26 changes: 26 additions & 0 deletions .xcodebuildmcp/config.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions AGENTS.md
58 changes: 58 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion Flipper/AppIntents/FlipperShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ struct FlipperShortcuts: AppShortcutsProvider {
AppShortcut(
intent: PlayAlert(),
phrases: [
"Find Flipper"
"Find \(.applicationName)",
"Find my \(.applicationName)"
]
)
}
Expand Down
61 changes: 54 additions & 7 deletions Flipper/iOS/UI/InAppNotifications/Overlay/OverlayController.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>(
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
Expand All @@ -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 {
Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import SwiftUI

struct OverlayModifier<OverlayContent: View>: ViewModifier {
var isPresented: Binding<Bool>
var interaction: OverlayInteraction
@ViewBuilder var overlayContent: () -> OverlayContent

@EnvironmentObject private var controller: OverlayController

init(
isPresented: Binding<Bool>,
interaction: OverlayInteraction = .fullscreen,
@ViewBuilder overlayContent: @escaping () -> OverlayContent
) {
self.isPresented = isPresented
self.interaction = interaction
self.overlayContent = overlayContent
}

Expand All @@ -20,7 +23,9 @@ struct OverlayModifier<OverlayContent: View>: 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)
}
}
}
Expand Down
Loading
Loading