diff --git a/.claude/launch.json b/.claude/launch.json index 45c21fc..55f03cf 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "tarkov-checker-dev", "runtimeExecutable": "pnpm", "runtimeArgs": ["dev"], - "port": 5173 + "port": 5173, + "autoPort": false } ] } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e68aee6..6fc1b45 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -57,7 +57,7 @@ jobs: # eslint-config-prettier disables format rules in ESLint, so # Prettier itself isn't verified by `pnpm lint`. Run it first — # a formatting drift fails fast before the heavier eslint pass. - - run: pnpm format:check + - run: pnpm format - run: pnpm lint diff --git a/.prettierignore b/.prettierignore index 3339bba..f542578 100644 --- a/.prettierignore +++ b/.prettierignore @@ -23,3 +23,7 @@ apps/client/.eslintrc-auto-import.json # Claude Code local tooling (launch config, vendored skills) .claude/** + +# Agent instructions — hand-formatted (tables, aligned lists), owned by +# whoever edits them, not by prettier +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index eb73c66..0197da7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,10 @@ - `apps/desktop` — Tauri 2 wrapper. The production artifact: Rust watcher/config pipeline (`src-tauri/src/server/`), the Tauri IPC commands the webview calls, AND an in-process HTTP server (axum) on - `127.0.0.1:47474` exposing `/api/ping`, `/api/config` (GET+PUT) and - `/events` (SSE). Single 6 MB `.exe` that ships as both the overlay - and the local HTTP backend for any browser pointed at the same + `0.0.0.0:47474` (binds all interfaces, trust model: same Wi-Fi = trusted) + exposing `/api/ping`, `/api/config` (GET+PUT), `/api/hotkeys` (GET+PUT, + +suspend/resume) and `/events` (SSE). Single 6 MB `.exe` that ships as both + the overlay and the local HTTP backend for any browser pointed at the same origin. See "In-process HTTP server" below. - `apps/client` — Vue + Leaflet map. Two transports, one codebase. Inside the Tauri webview: `invoke(...)` + `listen('position', ...)`. @@ -175,38 +176,64 @@ key back in the running app. ## Map rendering layers -`apps/client/src/features/map/` is split into two sibling folders by -ownership: +`apps/client/src/features/map/` is split into three sibling folders: - `composables/` — **framework hooks** for Leaflet/Vue plumbing: - `useLeafletMap`, `useFloorSwitcher`. Nothing here knows about extracts, - player markers, or future quest markers — pure map/Leaflet glue. -- `layers//` — **domain layers** rendered on top of the map. Each - layer fully owns its concerns: data adapter, icon HTML, tooltip HTML, - the composable that wires it into Leaflet. All layers use a registry - pattern: `layers/registry.ts` exports `registerMapLayer()` and - `useMapLayers()`. Currently: - - layers/extracts/ - useExtractsLayer.ts # Leaflet/Vue glue, data load, marker sync - icon.ts # slice geometry + makeIcon (composite via CSS clip-path) - tooltip.ts # buildTooltipHtml + escapeHtml + sortedEntries - index.ts # registerMapLayer call - layers/player/ - usePlayerLayer.ts # player position marker + follow logic - index.ts # registerMapLayer call - layers/airdrop/ - useAirdropLayer.ts # purple uncertainty circle around the predicted drop - index.ts # registerMapLayer call - - Each `index.ts` calls `registerMapLayer({ id, mount })` at module load; + `useLeafletMap`, `useFloorSwitcher`, `useLayerVisibility`, `useRailObstacle` + (the rail rect edge-indicator layers wrap around). Nothing here knows about + extracts, player markers, or future quest markers — pure map/Leaflet glue. +- `components/` — **UI surfaces** mounted by the page: + - `LayerRail.vue` — on-map left rail (vertically centered). Top icon = base map + selector flyout (MapSection component). Below that, one icon per layer **category** + (derived from registry `subgroup`: today only `player`, with `loot`/`quests` + dimmed future icons). Clicking a category icon opens a flyout listing its + layers: each row has a visibility `ToggleSwitch`, layer name, and a gear button + that expands that layer's settings component inline. Floor stepper at the rail's + bottom (multi-floor maps only) is a vertical ▲/level/▼. Rail hides when the + overlay is click-through-locked. When locked on a multi-floor map, a compact + read-only floor chip appears bottom-left. Flyouts position relative to the rail + via CSS `position: absolute; left: full; top: 1/2` (NOT `position: fixed` + which drifted in the Tauri WebView2 overlay). + - `MapView.vue` — mounts the Leaflet container, the LayerRail, and layer + composables. Wires Alt+mouse wheel (captured, throttled ~120ms) to step + floors instead of zooming on multi-floor maps. +- `layers//` — **domain layers** rendered on top of the map. Each layer fully + owns its concerns: data adapter, icon HTML, tooltip HTML, the composable that + wires it into Leaflet. All layers use a registry pattern: `layers/registry.ts` + exports `registerMapLayer()` and `useMapLayers()`. Currently: + + layers/extracts/ + useExtractsLayer.ts # Leaflet/Vue glue, data load, marker sync + icon.ts # slice geometry + makeIcon (composite via CSS clip-path) + tooltip.ts # buildTooltipHtml + escapeHtml + sortedEntries + useEdgeIndicators.ts # off-screen extract arrows; wraps left edge around the rail + index.ts # registerMapLayer call + layers/player/ + usePlayerLayer.ts # player position marker + recentering on follow + index.ts # registerMapLayer call + layers/airdrop/ + useAirdropLayer.ts # purple uncertainty circle around the predicted drop + index.ts # registerMapLayer call + + Each `index.ts` calls `registerMapLayer({ id, mount, category, order, titleKey, +settingsComponent? })` at module load (the rail metadata lives here — there is + no second registry for layers); `main.ts` loads all index files via `import.meta.glob('@/features/map/layers/*/index.ts', { eager: true })`. `MapView.vue` reads the registry with `useMapLayers()` and calls - `mount(ctx)` for each layer in setup(). When quest markers (or anything - else) get added later, create `layers/quests/{useQuestMarkers,icon,tooltip,index}.ts` - following the same pattern — no manual list to update. Airdrop's state - machine, tracker, and banner live in `features/airdrop/` — only the - Leaflet circle moved here. See "Airdrop feature" below. + `mount(ctx)` for each layer in setup(). `MapLayerContext` (in `layers/registry.ts`) + includes `visible: Ref` driven by the LayerRail toggle; the layer composable + watches it and adds/removes its Leaflet root accordingly. When quest markers (or + anything else) get added later, create `layers/quests/{useQuestMarkers,icon,tooltip,index}.ts` + following the same pattern — no manual list to update. Airdrop's state machine, + tracker, and banner live in `features/airdrop/` — only the Leaflet circle moved here. + See "Airdrop feature" below. + +**Off-screen extract arrows** (`useEdgeIndicators.ts`): arrows render on the viewport +edge pointing at extracts outside the visible bounds. On the overlay, the left edge +wraps around the LayerRail — a left-edge arrow whose y falls within the rail's +vertical span is pushed out to the rail's right edge (RAIL_GAP px beyond). Measured +each frame via `.layer-rail` rect so arrows flow dynamically as the rail animates +in/out on lock transitions. Extract markers are `L.divIcon` instances (not `L.icon`) — the composite PNG split via CSS `clip-path: polygon(...)` is built inline as HTML. @@ -234,9 +261,8 @@ icon's top edge gives a few pixels of overlap for visual cohesion. marker — an SVG arrow (rotated by `yaw + mapRotation + yawOffset`) or a circle fallback when `yaw === null`. The marker isn't created until the first position arrives. On every position update with changed -`(x, z, yaw)` and `playerFollow !== 'off'`, the map recenters and zooms -to `initialZoom + FOLLOW_ZOOM_DELTA[mode]` — that's why `initialZoom` -sits in `MapLayerContext`. +`(x, z)` and `playerFollow === 'on'`, the map recenters (zoom stays +fixed) — that's why `initialZoom` sits in `MapLayerContext`. ## Airdrop feature @@ -283,11 +309,31 @@ Two surfaces read/write this state: path without a restart. - **HTTP**: `GET /api/config` / `PUT /api/config` on the in-process axum server (see "In-process HTTP server" below). Same behaviour as - the IPC commands, same `ResolvedPaths` shape. - -The HTTP server binds on `127.0.0.1:47474` — fixed port, no TCP -exposure beyond loopback. LAN exposure is a future opt-in toggle -(see "Future architecture"). + the IPC commands, same `ConfigResponse` shape. + +The config get/update no longer returns bare `ResolvedPaths` — it returns +`ConfigResponse` (`paths.rs`), which `#[serde(flatten)]`s `ResolvedPaths` and +appends `deleteScreenshots: bool`. Wire shape is +`{ gameDir, logsDir, screenshotsDir, deleteScreenshots }`, mirrored by +`serverConfigResponseSchema` in `@shared/config-api`. `deleteScreenshots` is an +opt-in (default false) flag persisted in `config.json` alongside the path +overrides: when on, the screenshot watcher sends each parsed Tarkov screenshot +to the **OS recycle bin** (`trash` crate) right after broadcasting its +position — the image is never used beyond its filename, so nothing is lost, and +the folder stops filling up over long sessions. Only files whose name parses as +a Tarkov position screenshot are touched (`should_trash` in `screenshots.rs`); +unrelated files the user dropped in the folder are never deleted. The flag +reaches the watcher via a shared `Arc` on `WatcherSlot` +(`set_delete_screenshots`), so toggling it takes effect live; both PUT paths +(`update_config` IPC + `put_config_http`) set it before `apply_resolved`. UI is +a `ToggleSwitch` in the Paths settings section (saves immediately, no Save +button), so it's configured on the machine that owns the screenshots folder. + +The HTTP server binds on `0.0.0.0:47474` — all interfaces, fixed port. Same-Wi-Fi +machine browsers reach it via localhost/127.0.0.1 loopback; LAN phones reach it +via the LAN IP. Trust model: same Wi-Fi = trusted (gated by `CorsLayer` for +browser drive-by callers; LAN curl-equivalents unfiltered by design). See +"In-process HTTP server" section for architectural reasoning. ## User settings @@ -301,10 +347,11 @@ Persisted state lives in **per-feature** Pinia stores, not one big store: the `--extract-label-size` CSS variable on `` from `useExtractsLayer` in `layers/extracts/useExtractsLayer.ts` — which also re-binds tooltips so Leaflet recomputes positions for the new height). - - `playerFollow` — `"off" | "sm" | "md" | "lg"` — auto-recenter + zoom on - every fresh position update. Wired in `layers/player/usePlayerLayer.ts`; - only re-centers when `(x, z)` actually changes (skips spam updates when - the player stands still). Zoom step: `initialZoom + FOLLOW_ZOOM_DELTA[mode]`. + - `edgeIndicators` — boolean, default false. Off-screen extract arrows; opt-in. + - `playerFollow` — `"off" | "on"` — auto-recenter map on every fresh position + update. Wired in `layers/player/usePlayerLayer.ts`; only recenters when + `(x, z)` actually changes (skips spam updates when the player stands still). + Zoom stays fixed at `initialZoom`. - `autoMapSwitch` — boolean, default `true`. When on, incoming `map-change` events from the logs watcher flip `mapCode` to whatever the game just loaded (resolved through `canonicalMapCode()` so @@ -313,15 +360,25 @@ Persisted state lives in **per-feature** Pinia stores, not one big store: are silently dropped with one `console.warn`. Wiring lives in `features/map/composables/useAutoMapSwitch.ts`, mounted once at `App.vue` root. +- **Per-layer visibility**: `composables/useLayerVisibility.ts` exports a + function that returns a shared `persistedRef('tc.layer..visible', z.boolean(), true)` + per layer id (module-level singleton). The LayerRail toggle and the layer + composable read/write the same ref. Consumed by `MapLayerContext.visible` + in `layers/registry.ts`. - `apps/client/src/features/i18n/store.ts` — `apiLang` (`"en" | "ru"`). The store's `watch(apiLang, ..., {immediate: true})` mirrors the value into vue-i18n's `locale`; `main.ts` calls `useI18nStore()` eagerly after `app.use(createPinia())` so the persisted language is applied before any component reads `t()`. -- `apps/client/src/features/hotkeys/store.ts` — per-action hotkey combos. +- `apps/client/src/features/hotkeys/store.ts` — thin sync layer over the + **backend-owned** combos: `lockHotkey` stays a client `persistedRef` + (overlay-only), the five forwarded actions load from the backend + (`fetchHotkeys`) and `setAction` PUTs on change. See "Backend-owned + hotkeys" below. - `apps/client/src/features/overlay/store.ts` — Tauri-only: `alwaysOnTop`, `opacity` (0.3–1), `mapOpacity` (0–1), `zoom` - (`"75" | "100" | "125" | "150"`), plus a deliberately session-only + (`"75" | "100" | "125" | "150"`), `minimizeToTray` (default false; when on, + ✕ hides to tray instead of closing), plus a deliberately session-only `clickThrough` (plain `ref()`, not `persistedRef` — see note below). - `apps/client/src/features/airdrop/store.ts` — `dropMarkerRadius` (game meters, slider-bound in Settings). Wraps the airdrop state machine — @@ -331,40 +388,105 @@ Each store uses `persistedRef` from `@/shared/persisted-store` with its own key (`tc..`) — corrupt persisted data falls back to defaults silently. -**Session-only state.** `overlayClickThrough` intentionally does NOT -use `persistedRef`. Booting into a locked overlay with a broken hotkey -would be unrecoverable, so `App.vue` resets the value to `false` on -every Tauri startup. Reach for plain `ref()` (not `persistedRef`) any -time boot-time recoverability matters more than user-visible -continuity; default to `persistedRef` everywhere else. - -## Settings UI registry - -Settings sections use a registry pattern similar to map layers: each feature -that owns settings registers its UI section via `registerSettingsSection()`. - -- `features/settings/registry.ts` — `registerSettingsSection()` and - `useSettingsSections(group)`. Visibility logic: `'always'` (desktop + - phone, default), `'tauri'` (overlay only), `'desktop-or-tauri'` (phone at - ≥640px or overlay). Order uses multiples of 10. Currently: - - main: 10 map, 20 extracts, 30 player, 40 airdrop (tauri-only), 50 overlay (tauri-only), 60 hotkeys (tauri-only) - - system: 10 language, 20 paths (desktop-or-tauri) -- `features//settings.ts` — each feature with settings calls - `registerSettingsSection({ id, group, order, visible, component })` at - module load. Currently: `map`, `airdrop`, `overlay`, `hotkeys`, `i18n`, - `server` (each maps to a section component under - `features/settings/sections/`). -- `main.ts` loads all registration files via - `import.meta.glob('@/features/*/settings.ts', { eager: true })`. -- `SettingsPanel.vue` — gear icon in the top-right cluster next to the - transport-status pill (`App.vue`), opens a PrimeVue `Drawer` (right-side on - desktop, `position="full"` on `<640px`). Iterates `useSettingsSections('main')` - and `useSettingsSections('system')` to build the drawer — no hard-coded list - of sections. +**Session-only state.** `clickThrough` intentionally does NOT use `persistedRef`. +Booting into a locked overlay with a broken hotkey would be unrecoverable, so +`App.vue` resets the value to `false` on every Tauri startup. Reach for plain +`ref()` (not `persistedRef`) any time boot-time recoverability matters more than +user-visible continuity; default to `persistedRef` everywhere else. + +## Settings & layer registries + +**Two SEPARATE registries, one per surface — no shared-id matching across them.** + +- **Map-layer registry** (`features/map/layers/registry.ts`) — the single source + of truth for layers. `registerMapLayer({ id, mount, category, order, titleKey, +settingsComponent?, availability? })`; `useMapLayers()` reads them. Each layer + self-describes its rail `category` (`'player' | 'loot' | 'quests'`), `order`, + display `titleKey`, and optional inline `settingsComponent`. Layers register in + their own `layers//index.ts` (auto-loaded via the layers glob). Consumed by + the on-map **LayerRail** (`features/map/components/LayerRail.vue`): a left rail + with the base map selector at the top (a direct `MapSection` import — it's a + prerequisite, not a layer), then one icon per category (membership derived from + each layer's `category`; empty categories like loot/quests render as dimmed + future placeholders). A category flyout lists its layers, each with a visibility + toggle (`useLayerVisibility`) + the layer's `settingsComponent` inline via a gear. + `CATEGORY_META` in the rail is presentation-only (icon + which futures to tease), + NOT the catalogue. +- **Settings-section registry** (`features/settings/registry.ts`) — **system/app + settings only** (no layer concept; no groups/subgroups). `registerSettingsSection({ +id, order, titleKey, visible?, component })`; `useSettingsSections()` returns them + filtered by `visible` (`'always'` | `'tauri'` | `'desktop-or-tauri'` | `'browser'`, + where `'browser'` = `!isTauri`) and sorted by + `order`. Registered in `features//settings.ts` (auto-loaded via + `import.meta.glob('@/features/*/settings.ts', { eager: true })`). Currently: 10 + overlay (tauri), 20 hotkeys (always; rows read-only on a phone — no keyboard + to record), 25 display (browser), 30 language, + 40 paths (desktop-or-tauri), 50 pairing (tauri). Consumed by the gear **drawer** + (`SettingsPanel.vue`): a **non-modal** PrimeVue `Drawer` (right on desktop, + bottom-sheet on `<640px`) rendering a single flat **Accordion**; open-panel state + persists in `tc.settings.open`. Faction colours come from `FACTION_COLORS` in `packages/shared/src/maps.ts` so icons and tooltip stripes never drift across components. +## Mobile display (browser / phone) + +`apps/client/src/features/display/` holds the browser/phone fullscreen affordance. +Browser-only — the Display settings section registers with `visible: 'browser'` +(`!isTauri`); the frameless Tauri overlay has no browser chrome, so it's moot there. + +- **Fullscreen** lives ONLY in the Display settings section + (`components/DisplaySection.vue`) — a `ToggleSwitch` bound to live fullscreen + state via `composables/useFullscreenToggle.ts` (VueUse `useFullscreen` on + `document.documentElement`, so the address bar collapses). Android/desktop get a + real toggle; iOS Safari has **no Fullscreen API**, so on iOS the row shows an + "Add to Home Screen" hint instead (the PWA `display: standalone` route is the + only chrome-free path on iPhone). The whole row hides once installed as a PWA + (`isStandalone`). The toggle is `.catch(() => undefined)` — `requestFullscreen` + rejects in an iframe and there's nothing actionable. (There is no on-map + fullscreen button — it was tried in the top-right cluster but removed to keep + the bar uncluttered on narrow phones; see "Top bar" below.) +- `composables/useDisplayEnv.ts` exposes `isIos` (UA incl. iPadOS-as-Mac) and + `isStandalone` (`matchMedia('(display-mode: standalone)')` + `navigator.standalone`). + +**Top bar (browser).** The connection-status dot, the map name (only ≥380px), and +the two in-game clocks all live in the always-visible left chip +(`TarkovTimeChip.vue`), so the top-right browser cluster is just the settings gear +— this keeps the bar from overflowing on a 320px-wide phone (iPod touch). The clock +samples via `requestAnimationFrame` and swaps its ref only when the shown value +changes (in-game time runs 7×, so a fixed interval visibly stutters the seconds). + +**Keep-awake was considered and deliberately dropped.** There is no reliable web +way to keep a mobile screen awake over plain HTTP: the Screen Wake Lock API needs a +secure context (HTTPS / `localhost` — neither reaches a LAN phone, and it's absent +entirely on iOS < 16.4), and the muted/unmuted looping-`