# 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 ` or `Update desktop submodule ()`. ## The protocol — one-way JSON over WebSocket Android opens a WebSocket to `ws://: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` 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 //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.