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

8.9 KiB

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:

{ "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 enumdesktop/WirelessTextSyncer.Windows/Models/SyncAction.cs: add a member.
  2. Desktop JSON converterdesktop/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs: add a string ↔ enum mapping in both the Read and Write switch expressions.
  3. Desktop message shapedesktop/WirelessTextSyncer.Windows/Models/SyncMessage.cs: add any new payload field with [JsonPropertyName] + init setter.
  4. Desktop handlerdesktop/WirelessTextSyncer.Windows/Services/SyncMessageHandler.cs Handle: add a case. The default throws InvalidOperationException, so forgetting this fails loudly.
  5. Android senderandroid/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.csTrayApplicationContext (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.ktSTART_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 (sendTextreplaceAll, sendEnterenter, sendMutesetMute) 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

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.

# 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

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.