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>
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 solutionandroid/→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
- Make changes inside
desktop/and/orandroid/. - Commit and push inside each changed submodule.
- In the parent repo,
git add desktop androidto 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:
- Desktop enum —
desktop/WirelessTextSyncer.Windows/Models/SyncAction.cs: add a member. - Desktop JSON converter —
desktop/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs: add a string ↔ enum mapping in both theReadandWriteswitch expressions. - Desktop message shape —
desktop/WirelessTextSyncer.Windows/Models/SyncMessage.cs: add any new payload field with[JsonPropertyName]+initsetter. - Desktop handler —
desktop/WirelessTextSyncer.Windows/Services/SyncMessageHandler.csHandle: add acase. ThedefaultthrowsInvalidOperationException, so forgetting this fails loudly. - Android sender —
android/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/ConnectionService.ktcompanion object: add asendXxxhelper that builds aJSONObjectand callssocket.send(...), plus a matchingwhen (call.method)branch inMainActivity.ktto expose it over thewireless_text_syncer/connectionMethodChannel.
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(...); wiresserver.StatusChangedto a UI-threadSynchronizationContext.Postthat updates the tray icon; then callsserver.Start(8181)anddiscovery.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 aList<IWebSocketConnection>underclientsLock; dedupes by client IP (closes the old connection when the same IP reconnects); runs a 5 s heartbeat viaSendPing("wts-heartbeat"); prunes unavailable clients on every probe.SyncMessageHandler— the only place that mutates theremoteTextbuffer and dispatches toIKeyboardInjectionService/IAudioControlService.KeyboardInjectionService— two runtime modes (ClipboardPastedefault,SendInputTyping) selectable from the tray menu (TextInjectionModeenum). 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 postsWM_INPUTLANGCHANGEback.AudioControlService— NAudioMMDeviceEnumeratorwrapper for the system mute toggle (setMuteaction).DiscoveryResponderService— listens on UDP 8182 forwirelessTextSyncer.discoveryv1 broadcasts and answers withwirelessTextSyncer.servicev1 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) toConnectionService; forwards state snapshots back throughConnectionService.eventSink.ConnectionService.kt—START_STICKYforeground service that owns the OkHttpWebSocket, the persistent notification, and all connection state in acompanion object(so it survives Activity recreation and is reachable from the tile). AllsendXxxpaths (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 sameConnectionServicecompanion.DiscoveryClient.kt— UDP broadcast counterpart to the desktopDiscoveryResponderService.WirelessTextSyncerApplicationHolder.kt— emptyApplicationsubclass referenced by the manifestandroid: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 tomain/master. - Communication with the user: Chinese preferred.