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>
125 lines
8.9 KiB
Markdown
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.
|