.agents/architecture.md
The Go proxy core in core/ operates in two modes.
Android lib mode:
libclash.so, through go build -buildmode=c-shared with CGO.:core module owns JNI access to the in-process library. Flutter crosses the ${packageName}/service
MethodChannel through lib/plugins/service.dart and Android's ServicePlugin rather than talking to JNI directly.lib/core/lib.dart (CoreLib) implements the shared Core interface, gates method calls on its connection completer,
initializes and synchronizes Android shared state, and closes the native service path exactly once.Desktop core mode:
CGO_ENABLED=0.rust_api provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe on Windows.
Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives.lib/core/service.dart (CoreService) is the composition root. It wires the IPC transport, launcher selection,
lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself.lib/core/desktop/transport.dart converts native IPC frames into ready, connected, disconnected, failed, and data
events. A replaceable binding keeps RPC subscriptions stable when a failed or stale transport must be rebuilt.lib/core/desktop/rpc_client.dart owns request IDs and pending completers, waits up to 10 seconds for a connection,
applies a three-minute default method timeout, unwraps CoreMethodResponse, and fails all pending calls when transport
disconnects or closes.lib/core/desktop/lifecycle.dart serializes process intents and owns the authoritative desktop state machine.lib/core/desktop/launcher.dart abstracts direct child-process and Windows Helper ownership through idempotent process
leases. lib/core/desktop/helper_client.dart is the typed loopback HTTP client for the privileged Helper.lib/core/controller.dart (CoreController) selects the implementation based on platform. lib/core/interface.dart defines the shared CoreHandlerInterface.
Key Go core files:
core/hub.go: handler functions.core/method.go: MethodChannel-style method-call dispatch and response envelopes.core/message.go: non-blocking priority/bulk event queues and bounded message batching.core/lib.go: CGO exports.core/server.go: desktop socket/named-pipe client and framed message forwarding.CoreController.start(), restart(), stop(), and close() are the only shared lifecycle facade. close() is terminal;
callers must not try to reuse a closed platform implementation.
CoreAction in lib/providers/actions/core.dart owns the user-facing Core status and setup sequence:
startCore() publishes connecting, starts the platform Core, publishes connected, then initializes Core state. A
startup error publishes disconnected and displays the error.restartCore() coalesces overlapping callers behind one worker. _requestedRestartRevision records newer requests,
while _latestExplicitStart retains the newest requested post-restart running intent. After the lifecycle restart and
initCore(), the worker reapplies profile/running state until it has consumed the latest revision.Application exit is centralized in SystemAction and SystemExitCoordinator:
CoreController.close().The coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the
caller, and uses a three-second watchdog as an emergency application-exit path. Application.dispose() and
CoreManager.onCrash() do not independently destroy Core; this avoids competing shutdown owners.
DesktopCoreLifecycle is a latest-desired-intent reconciler, not a queue that blindly executes every request:
idle, starting, running, stopping, failed, and closed.applied, coalesced, or superseded, allowing callers and tests to distinguish a command
that won from one satisfied or replaced by a newer compatible intent.DesktopCoreFailure, the owned process is
cleaned up, and CoreService emits a Core crash event for the normal UI recovery path.Direct launch is used on macOS/Linux and as the Windows fallback when the privileged Helper is not ready. When the Helper is ready on Windows, the Helper owns the Core child and Dart owns it through a session-scoped lease.
Android deliberately keeps Flutter requests optimistic and the native layer authoritative:
ServicePlugin.start() and stop() acknowledge immediately after submitting intent. They do not wait for service
creation, VPN permission, binding, TUN establishment, or teardown.ServiceState owns the latest RunRequest, shared configuration, run time, and STOPPED/STARTING/STARTED/STOPPING
state. Identity checks discard obsolete work. startPreparationLock serializes permission/setup preparation and
transitionLock serializes actual service transitions.ServiceController owns exactly one ManagedServiceBinding, selects VpnService or ProxyService from VpnOptions,
binds with a five-second connection timeout, invokes ManagedService.start()/stop() off the main thread, and clears
binding/run-time state on failure or disconnection.ServiceState.requestStart()/requestStop() or the explicit system-action handlers instead of inferring intent from a
callback.Quick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:
ServiceState.handleStartAction()/handleStopAction() forward through TilePlugin to
TileManager, which updates normal Flutter setup state. Without Flutter, native code restores SharedState from
preferences, runs quickSetup, checks VPN permission, and submits the native request directly.VpnService through onStartCommand() without FlClash's bound-service path. The service
sends the explicit, permission-protected VPN_START_REQUESTED broadcast to ServiceBroadcastReceiver, which routes it to
ServiceState.handleStartAction() so Core/configuration and the normal binding are restored before TUN is treated as
ready.VpnService.onRevoke() stops TUN/modules first, then sends VPN_REVOKED; the receiver only requests a stop when
ServiceController still owns an active VPN binding.ServiceBroadcastReceiver uses goAsync() and an atomic one-shot completion. Normal completion or a nine-second
watchdog calls PendingResult.finish() exactly once; the watchdog releases Android's broadcast lease and does not
cancel or redefine the underlying lifecycle intent.The shared protocol uses CoreMethodCall(id, method, arguments) and CoreMethodResponse(id, result, error) in both
directions. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
remain strings.
Go event delivery is intentionally non-blocking:
CoreService from lifecycle failures rather than sent through the Go queue.Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
CoreStatusButton in lib/views/dashboard/widgets/core_status_button.dart is the desktop dashboard's status/restart
surface. It is shown only outside dashboard edit mode and only when coreLib == null:
connecting -> connected transition still shows at least 600 milliseconds of progress instead of flashing.connecting; mounting while already connecting does not invent a new
delay. A real disconnected transition cancels the hold immediately so failure is never hidden, while a long-running
connecting state remains visible after the timer expires.CoreAction; the widget never starts Core directly.Proxy delay testing follows the same failure-safe UI rule. proxyDelayTest() records an in-progress zero delay, writes the
real result on success, and logs plus records -1 on exceptions. DelayTestButton reverses its animation in finally, so
an RPC failure cannot leave the control permanently spinning.
Provider files in lib/providers/:
app.dart: runtime/UI state, logs, traffic, delays, loading, navigation.config.dart: persistent config providers, app settings, theme, VPN, proxy style.state.dart: derived/computed providers, navigation, proxy, tray, color scheme.action.dart: business logic notifiers, setup, backup, core lifecycle, proxy selection.database.dart: Drift database provider wrappers.globalState in lib/state.dart is a singleton holding app lifecycle, timers, theme, and start/stop state. Providers are generated into lib/providers/generated/.
The app uses Drift/SQLite in lib/database/. Current schema version is 2.
Tables:
ProfilesScriptsRulesProfileRuleLinks (profile_rule_mapping)ProxyGroupsIconRecords (icon_records)Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
Generated Drift output lives in lib/database/generated/database.g.dart. After schema changes, run code generation and add or update focused database tests under test/database/ when converter or migration behavior changes.
Managers are nested InheritedWidget/StatefulWidget components in lib/application.dart:
AppEnvManager > StatusManager > ThemeManager
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
> ConnectivityManager > CoreManager > AppStateManager
> [Mobile: AndroidManager > VpnManager | Desktop: WindowHeaderContainer]
Each manager in lib/manager/ handles a specific platform concern. Desktop-only managers are conditionally inserted.
lib/core/controller.dart (CoreController) is a singleton facade over CoreHandlerInterface. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an @visibleForTesting constructor and resetInstance() for test injection.
lib/providers/action.dart is the public library entry point for action
providers. The Riverpod notifier implementations are split by responsibility
under lib/providers/actions/ and joined to the entry point with part
directives, so consumers continue to import the same public API:
CommonAction: update check and common UI operations.SetupAction: config setup and TUN management.BackupAction: backup/restore with WebDAV sync.CoreAction: core lifecycle, initialization, coalesced restart, and post-restart profile/running-state application.SystemAction: system integration, tray, coordinated resource cleanup, terminal Core close, exit, and brightness.StoreAction: profile storage operations.ThemeAction: theme state updates.ProxiesAction: group management and proxy selection.ProfilesAction: profile CRUD, auto-update, import.GeoResourceAction: geo resource updates and URL configuration.Desktop:
WindowManagerTrayManagerHotKeyManagerProxyManagerMobile:
AndroidManagerTileManagerVpnManagerShared:
ConnectivityManagerCoreManagerAppStateManagerStatusManagerThemeManagersetup.dart is the release build orchestrator:
env.json (APP_ENV).flutter_distributor for packaging.Go core building is handled by build_tool, a standalone Dart CLI in plugins/setup/buildkit/build_tool/.
Platform build hooks inside flutter build trigger build_tool automatically:
build_pod.sh, build_tool macos.buildkit/cmake/buildkit.cmake, build_tool linux.buildkit/cmake/buildkit.cmake, build_tool windows. CMake forwards the active configuration through BUILDKIT_CONFIGURATION.buildkit/gradle/plugin.gradle, build_tool android.plugins/setup/ is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native
build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code
must not import or call it.
Responsibilities are deliberately split:
buildkit/build_tool/ owns target resolution, input fingerprinting, compilation, output copying, and cache validation.core/ and services/helper/ remain source owners; libclash/ and Android jniLibs/header directories are generated
output locations.setup.dart remains the release/package orchestrator and does not pre-build
platform artifacts or use dart-define for Core integrity data. The Windows
build tool writes the runtime manifest.json beside the Core output, and the
Windows bundle copies it beside the application executable.Platform outputs remain explicit:
c-shared, then copies libclash.so and generated headers into the :core Android module.FlClashCore process used by the desktop socket integration.FlClashCore.exe, the Rust FlClashHelperService.exe privileged helper, and a
manifest.json containing only coreSha256.The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a
Go core and, on Windows, a separate Rust helper. Per-target records live under .dart_tool/setup_build_cache/v1/:
go list -deps inputs inside core/ and Clash.Meta, module files, effective
build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details.taskkill.--force rebuild only the affected target.flutter clean removes .dart_tool, so the next native build performs one full core rebuild. Manual builds can bypass
the cache with make core-<platform> FORCE=1.This differs from rust_api: rust_api is a runtime Flutter Rust Bridge integration whose Cargokit hooks produce its native
FFI library, while setup is only the build and packaging bridge for FlClash's external core artifacts.
Windows helper integrity/version check:
manifest.json and sends it with /ping. Debug, Profile, and Release
builds use the same Helper protocol and may use TUN through the same flow./ping is loopback-only and requires no request token. The Helper compares the requested SHA256 with its embedded value
and checks that the fixed FlClashCore.exe beside it exists; /start performs the actual Core SHA256 verification before
every launch. The response includes the running Helper path and protocol header; Dart checks both against the current
installation. The launcher selects the Helper only when /ping reports ready; any other readiness (missing manifest,
unavailable Helper, or a Helper built for a different Core) falls back to the direct Core without requesting elevation.
If /start reports a pre-spawn failure — coreVerificationFailed (the on-disk Core no longer matches the SHA the
Helper and manifest agree on) or processLaunchFailed (the Core process could not be spawned) — the launcher degrades
to the direct Core rather than failing the launch. /start releases the previously managed Core before it verifies,
so the Helper owns no Core when either code is reported and the direct retry cannot race a Helper-managed Core.
A mismatched Helper is reinstalled through the explicit TUN authorization flow, not at startup./start failure — silently drops TUN and keeps the
Core running. Degrading is preferred over failing the launch: an unverified Core carries no privilege the direct
launch path did not already have. manifestMissing is the one readiness that is surfaced to the user, because it
means the installation itself is incomplete./start receives only
that address and session ID, validates the fixed FlClashCore_<session> namespace, starts the fixed Core beside the
Helper, and returns the same session ID plus the spawned PID. Flutter verifies both the session and named-pipe peer PID./stop requires the same session ID. A missing process returns notRunning; a different owner returns
sessionMismatch without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim
that the loopback HTTP endpoints are authenticated.Build configuration defaults live in build_tool/lib/src/options.dart and can be overridden via a root build_config.yaml.
Architecture detection is automatic. The --description flag passed to flutter_distributor adds arch suffixes to artifact names, such as FlClash-0.8.93-macos-arm64.dmg.
setup: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.proxy: system proxy configuration.rust_api: runtime Flutter Rust Bridge FFI plugin built through Cargokit.tray_manager: system tray fork/customization.wifi_ssid: Wi-Fi SSID detection.window_ext: window extensions.flutter_distributor: app packaging/distribution.services/helper/ is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with:
make core-windows
The build tool always compiles the Helper in Rust release mode after calculating the SHA256 of the Core produced for the active Flutter configuration.
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
FlClashHelperService.exe install stops and removes any stale registration, creates the auto-start service for the
current executable path, starts it, and waits for the running state.FlClashHelperService.exe uninstall stops the service, waits for shutdown, removes its registration, and is also used
by the Windows package uninstaller.The Dart layer only launches the helper's install command through ShellExecuteW; it does not compose sc.exe,
taskkill, or cmd.exe command lines.
In every Flutter build mode /start opens the fixed Core executable beside the Helper without write/delete sharing,
validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation.
/ping only compares the requested coreSha256 with the Helper's embedded value and checks the fixed Core path exists;
it never hashes the Core. Protocol version 6 uses 32-character lowercase-hex session ownership:
GET /ping?coreSha256=... returns the current Helper executable path with x-flclash-helper-protocol when the
requested SHA matches.POST /start rejects unknown JSON fields, validates {address, sessionId}, then releases any previously managed Core
before verifying the Core — so every outcome, including a rejected one, leaves the Helper owning no Core — and returns
{sessionId, pid}.POST /stop validates {sessionId} and only stops the matching managed Core. A session mismatch is HTTP 409.GET /logs exposes the bounded recent Helper/Core stderr buffer with no-store caching.All endpoints bind only to 127.0.0.1:47890 and do not use request-token authentication. Lifecycle safety comes from the
fixed executable/hash, strict pipe namespace, session-scoped stop contract, and Dart-side peer-PID verification. When the
Helper service itself shuts down, it unconditionally stops the Core process it owns.