Files
WirelessTextSyncer/CLAUDE.md
Misaka 9daea364e4 Add project-level CLAUDE.md instructions
Document the three-repo submodule layout, the one-way JSON-over-WebSocket
protocol, desktop/android architecture, and the standard build/test/run
commands so future Claude Code sessions share one source of truth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 22:48:33 +08:00

125 lines
8.9 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
WirelessTextSyncer turns an Android device into a wireless text input companion for a Windows PC over the local network. The Android client (Flutter UI + Kotlin WebSocket foreground service) sends text/edit/mute events; the Windows side (a .NET 8 WinForms tray app) receives them over WebSocket and injects them via `SendInput` / clipboard paste / NAudio.
## Repository layout — three repos, one parent
This repo is an orchestration repo that pins two independent git submodules:
- `desktop/``gitea.server10086.icu/WirelessTextSyncer/desktop.git` — C# .NET 8 solution
- `android/``gitea.server10086.icu/WirelessTextSyncer/android.git` — Flutter app with native Kotlin
- Parent → `gitea.server10086.icu/admin/WirelessTextSyncer.git`
Each submodule is a standalone repository with its own `main` branch. There is **no shared codegen** between them — every protocol change must be applied to **both sides** by hand (see below).
### Cross-submodule change workflow
1. Make changes inside `desktop/` and/or `android/`.
2. Commit and push inside each changed submodule.
3. In the parent repo, `git add desktop android` to update the submodule pointers, then commit + push.
All commits auto-push (per global rule). Commit messages are English imperative. Parent-repo commits that bump submodule pointers conventionally look like `Update android and desktop submodules for <feature>` or `Update desktop submodule (<detail>)`.
## The protocol — one-way JSON over WebSocket
Android opens a WebSocket to `ws://<host>:8181` and sends JSON messages of the shape:
```json
{ "action": "insertText|replaceAll|backspace|enter|ping|setMute", "text"?: "...", "muted"?: true }
```
**Adding a new action requires edits in five places.** This is the most common multi-file change and the easiest to get half-right:
1. **Desktop enum**`desktop/WirelessTextSyncer.Windows/Models/SyncAction.cs`: add a member.
2. **Desktop JSON converter**`desktop/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs`: add a string ↔ enum mapping in **both** the `Read` and `Write` switch expressions.
3. **Desktop message shape**`desktop/WirelessTextSyncer.Windows/Models/SyncMessage.cs`: add any new payload field with `[JsonPropertyName]` + `init` setter.
4. **Desktop handler**`desktop/WirelessTextSyncer.Windows/Services/SyncMessageHandler.cs` `Handle`: add a `case`. The `default` throws `InvalidOperationException`, so forgetting this fails loudly.
5. **Android sender**`android/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/ConnectionService.kt` `companion object`: add a `sendXxx` helper that builds a `JSONObject` and calls `socket.send(...)`, plus a matching `when (call.method)` branch in `MainActivity.kt` to expose it over the `wireless_text_syncer/connection` MethodChannel.
The Android → desktop flow is **strictly one-way** — no ack, no request/response. The only desktop → android message is the initial `wirelessTextSyncer.service` info JSON (`{type, version, name, host, port}`) sent on socket open. Heartbeats are WebSocket-level pings on a 5 s interval (both sides).
Symmetric ports/constants: WebSocket `8181`, UDP discovery `8182` (the latter lives in `DiscoveryResponderService`).
## Desktop architecture (.NET 8 WinForms, manual DI)
Entry point is `Program.cs``TrayApplicationContext` (a WinForms `ApplicationContext`, no visible form). Composition is hand-rolled in `desktop/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs`:
- The constructor news up `KeyboardInjectionService`, `AudioControlService`, `SyncMessageHandler(keyboard, audio)`, `WebSocketServerService(handler)`, `DiscoveryResponderService(...)`; wires `server.StatusChanged` to a UI-thread `SynchronizationContext.Post` that updates the tray icon; then calls `server.Start(8181)` and `discovery.Start()`.
- `Dispose(bool)` tears everything down in reverse — any new service must be added in **both** places.
Services in `desktop/WirelessTextSyncer.Windows/Services/`:
- `WebSocketServerService` — Fleck server. Tracks clients in a `List<IWebSocketConnection>` under `clientsLock`; **dedupes by client IP** (closes the old connection when the same IP reconnects); runs a 5 s heartbeat via `SendPing("wts-heartbeat")`; prunes unavailable clients on every probe.
- `SyncMessageHandler` — the only place that mutates the `remoteText` buffer and dispatches to `IKeyboardInjectionService` / `IAudioControlService`.
- `KeyboardInjectionService` — two runtime modes (`ClipboardPaste` default, `SendInputTyping`) selectable from the tray menu (`TextInjectionMode` enum). Clipboard paste saves/restores the previous clipboard and falls back to SendInput on failure. SendInput typing temporarily switches the foreground window to the English keyboard layout (`KeyboardLayoutScope`, `00000409`) to dodge IME interference, then posts `WM_INPUTLANGCHANGE` back.
- `AudioControlService` — NAudio `MMDeviceEnumerator` wrapper for the system mute toggle (`setMute` action).
- `DiscoveryResponderService` — listens on UDP 8182 for `wirelessTextSyncer.discovery` v1 broadcasts and answers with `wirelessTextSyncer.service` v1 JSON.
- `AppLogger` — static; writes to `%LocalApplicationData%\WirelessTextSyncer\desktop.log`. Always look here first when debugging desktop.
Tests: `desktop/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs` (MSTest) uses `RecordingKeyboardInjectionService` / `RecordingAudioControlService` test doubles of the two `I*` interfaces. When adding an action, also add a deserialization test to keep the wire format symmetric.
## Android architecture (Flutter + Kotlin, foreground service)
Flutter side is a single `android/lib/main.dart` (~1.3 k LOC) holding the entire UI plus `NativeConnectionApi`, which wraps a MethodChannel (`wireless_text_syncer/connection`) and an EventChannel (`wireless_text_syncer/connection_state`).
Native side under `android/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/`:
- `MainActivity.kt` — registers both channels; dispatches MethodChannel calls (`connect`, `disconnect`, `sendText`, `sendEnter`, `sendMute`, `getState`, `startDiscovery`, `stopDiscovery`) to `ConnectionService`; forwards state snapshots back through `ConnectionService.eventSink`.
- `ConnectionService.kt``START_STICKY` **foreground service** that owns the OkHttp `WebSocket`, the persistent notification, and all connection state in a `companion object` (so it survives Activity recreation and is reachable from the tile). All `sendXxx` paths (`sendText``replaceAll`, `sendEnter``enter`, `sendMute``setMute`) live in this companion.
- `QuickSendActivity.kt` + `QuickSendTileService.kt` — Quick Settings tile + transparent bottom-sheet activity that send text without opening the main app; they call into the same `ConnectionService` companion.
- `DiscoveryClient.kt` — UDP broadcast counterpart to the desktop `DiscoveryResponderService`.
- `WirelessTextSyncerApplicationHolder.kt` — empty `Application` subclass referenced by the manifest `android:name`.
Last connection (host/port/name) is persisted in `SharedPreferences("wireless_text_syncer_connection")`.
## Build / test / run
The Python venv workflow does not apply here. Commands run from the relevant submodule.
### Desktop
```bash
cd desktop
dotnet restore
dotnet build
# Run from source (launches the tray app)
dotnet run --project ./WirelessTextSyncer.Windows/WirelessTextSyncer.Windows.csproj
# Tests (MSTest, 8 tests expected to pass)
dotnet test ./WirelessTextSyncer.Windows.Tests/WirelessTextSyncer.Windows.Tests.csproj
# Single-file release build (~69 MB, includes .NET runtime)
dotnet publish ./WirelessTextSyncer.Windows/WirelessTextSyncer.Windows.csproj \
-c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true \
-p:EnableCompressionInSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-o ./publish/win-x64-single
```
**Before a release publish: check for a running tray process and kill it.** Otherwise `GenerateBundle` fails with `MSB4018` / `IOException` because the output exe is locked. This is an authorised workflow step and does not need re-confirmation each time.
```bash
# Single-quoted to stop bash expanding $_
powershell -NoProfile -Command 'Get-Process | Where-Object { $_.ProcessName -like "*WirelessTextSyncer*" } | Select-Object Id, ProcessName, Path, StartTime | Format-List'
# Kill, if any (//PID //F under MSYS bash so flags aren't mangled into paths)
taskkill //PID <pid> //F
```
### Android
```bash
cd android
flutter pub get
flutter analyze lib/main.dart
flutter run # test device serial 4bc35a3d
flutter build apk --debug
```
## Conventions
- Commit messages: English, imperative, short first line.
- Trunk-based: all three repos use `main`. Never force-push to `main`/`master`.
- Communication with the user: Chinese preferred.