Compare commits
159 Commits
v0.8.30
...
v0.8.99-pre.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1de388c91b | |||
| 8192cab171 | |||
| 74f97f0a73 | |||
| 8215e62c9c | |||
| 84e1284588 | |||
| 24f237eadc | |||
| 97af21de6d | |||
| 35b4e54f05 | |||
| e30f60a8b8 | |||
| ed9cb1ef3d | |||
| a3e406c97a | |||
| eefd35a6bf | |||
| c7be7023d3 | |||
| 7c61c90ac2 | |||
| c5bf5bd487 | |||
| 60f371afd8 | |||
| db03880579 | |||
| d0b1562e1d | |||
| 26cfbaf456 | |||
| aaf934c2d1 | |||
| c0fcbc03ee | |||
| ae29f38f6b | |||
| adf715f8ac | |||
| c6eaa0a68b | |||
| 96fcb9ab5e | |||
| 62addf738a | |||
| e2f678909d | |||
| 7fb4f4f711 | |||
| 903e2b8879 | |||
| 2df430ca31 | |||
| aee7ee2643 | |||
| 9a99897864 | |||
| 4551e57ff4 | |||
| 1cffeaa29e | |||
| 5f2c12642b | |||
| c493df386d | |||
| 425d32b50d | |||
| 51e24ef9de | |||
| 7c831855ef | |||
| 7e7f1f89f3 | |||
| 5179d55dfb | |||
| ac2f6b919e | |||
| 45015f856b | |||
| 672eaccd35 | |||
| 2fbb96f5c1 | |||
| 243b3037d9 | |||
| 6e404ab19c | |||
| 2395a4b20c | |||
| d3c3f04062 | |||
| 201062dc5d | |||
| 45b163184d | |||
| 2ab70f193a | |||
| ed7868282a | |||
| e956373ef4 | |||
| 1154e7b245 | |||
| adb890d763 | |||
| 1477f9bd9c | |||
| a06e813249 | |||
| afbc5adb05 | |||
| 76c9f08d4a | |||
| f83a8e0cce | |||
| f5544f1af7 | |||
| eeb543780a | |||
| 676f2d058a | |||
| fb9d0cb22c | |||
| e7eb312254 | |||
| c9cd80bcb3 | |||
| a77b3a35e8 | |||
| 2d2708d7bd | |||
| ef5f6dbd59 | |||
| b6c7b15e3e | |||
| de9c5ba9cc | |||
| 2aae00cf68 | |||
| 68be2d34a1 | |||
| 7895ccf720 | |||
| e92900dbbd | |||
| eada271c49 | |||
| 5dda2854be | |||
| 5184ed6fc7 | |||
| 4e679f776e | |||
| 96328f66e9 | |||
| 3eb14ab8a1 | |||
| c6266b7917 | |||
| 6c27f2e2f1 | |||
| e04a0094b1 | |||
| 683e6a58ea | |||
| b340feeb49 | |||
| 6a39b7ef5a | |||
| 35f89fea90 | |||
| 58acd9c1ab | |||
| ef97ef40a1 | |||
| 9cb75f4814 | |||
| 375c4e0884 | |||
| ece8a48181 | |||
| 4b32a096dd | |||
| 04d6a928eb | |||
| 22e71ec6e1 | |||
| 6f1b07e26b | |||
| 1e3ab1c717 | |||
| 3a43dc2fe9 | |||
| c94f64cf78 | |||
| 25764fc4d3 | |||
| 649dbb9886 | |||
| e1dd616d74 | |||
| c91d78b63f | |||
| 82767325e5 | |||
| 3f0f7f051b | |||
| 15c64327db | |||
| 09e35eb5dd | |||
| 77c4935106 | |||
| e6da643186 | |||
| 61bd4e4549 | |||
| a5f4d12748 | |||
| 9dc67f31a3 | |||
| 846ec9728f | |||
| 6e5f1b8e5f | |||
| aca4a3e979 | |||
| 3783c3c650 | |||
| 01aed99002 | |||
| 9aec1e75e4 | |||
| 5dfb95a22d | |||
| 0f8cfa20b2 | |||
| 3933fd9d9e | |||
| b7400f2ce8 | |||
| d0ec580932 | |||
| 6bc0be9876 | |||
| 38221bcd10 | |||
| bee2f8aa4f | |||
| 6cfcaa4edc | |||
| 3fdc69edd5 | |||
| ed7ade0d72 | |||
| 621ddefc65 | |||
| 6de89d7de4 | |||
| c36df8cb4a | |||
| 530817b268 | |||
| 721dd20251 | |||
| f2aa8851ae | |||
| ec2890cab2 | |||
| ca946c1b06 | |||
| 3bc3172723 | |||
| 82be4cc45f | |||
| 2c3f4ae8a8 | |||
| 891977408e | |||
| 5292f34e8d | |||
| 1c54db6bf3 | |||
| a4b5f4abdb | |||
| aa4ffbe4fb | |||
| 3d25298639 | |||
| 1765576d09 | |||
| 2dd45062f1 | |||
| c6407984ac | |||
| 53af86238e | |||
| b20d9edec2 | |||
| 5c3a0c576d | |||
| 6dcb466fd3 | |||
| acbcec358b | |||
| a923549ddf | |||
| 07bd21580b | |||
| 57ceb64a5e |
@@ -0,0 +1,32 @@
|
||||
# Agent Configuration Model
|
||||
|
||||
Use the smallest durable surface that matches the job.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
|
||||
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
|
||||
- `.agents/skills/*/SKILL.md`: repo skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
|
||||
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
|
||||
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. None exist yet. Do not mix these with coding conventions in `.agents/rules.md`.
|
||||
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
- Put stable team conventions in `AGENTS.md` only when every task must see them without opening another file.
|
||||
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
|
||||
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
|
||||
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
|
||||
- Restate the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: a gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
|
||||
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
|
||||
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
|
||||
|
||||
## Skill Authoring Rules
|
||||
|
||||
- Use lowercase hyphenated names.
|
||||
- Start descriptions with `Use when...`.
|
||||
- Describe trigger conditions in the description, not the workflow.
|
||||
- Keep `SKILL.md` lean; link to `.agents/*.md` for large reference material.
|
||||
- Add scripts only when deterministic behavior is needed repeatedly.
|
||||
@@ -0,0 +1,981 @@
|
||||
# Architecture
|
||||
|
||||
## Core Integration
|
||||
|
||||
The Go proxy core in `core/` operates in two modes.
|
||||
|
||||
Android lib mode:
|
||||
|
||||
- Go core is compiled as a C shared library, `libclash.so`, through `go build -buildmode=c-shared` with CGO.
|
||||
- The Android `: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.
|
||||
- Because Core is in the application process on Android, application RSS already includes Core memory.
|
||||
|
||||
Desktop core mode:
|
||||
|
||||
- Go core runs as a separate process with `CGO_ENABLED=0`.
|
||||
- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe
|
||||
on Windows. Dart 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, and holds no lifecycle logic of its own.
|
||||
- `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 privileged Helper ownership through idempotent process
|
||||
leases. `lib/core/desktop/helper_client.dart` is the typed local 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 state/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.
|
||||
|
||||
## DNS Override
|
||||
|
||||
`PatchClashConfig.dns` holds a full `Dns` model, but only the keys listed in `dnsOverrideKeys` reach a profile.
|
||||
`DnsOverrideKey` names one Clash key each, with `fallback-filter.*` entries for the nested filter, and covers every
|
||||
yaml key of mihomo's `RawDNS` and `RawFallbackFilter`; `test/models/dns_override_test.dart` reads
|
||||
`core/Clash.Meta/config/config.go` and fails when a core bump adds or drops one. `Dns.overrideJson` builds the
|
||||
fragment in model order and `mergeDnsOverride` merges it one level deep, so a partial fallback filter keeps the
|
||||
profile's other filter keys. `_makeRealProfileTask` applies the fragment when `overrideDns` is on; for a profile whose
|
||||
`dns.enable` is not true it first fills `defaultDns` for `baselineDnsOverrideKeys` and then applies the fragment
|
||||
whatever the toggle says. The DNS page's quick edit opens `overrideYaml` in an `EditorPage` and applies it on the way
|
||||
back through `applyOverrideYaml`, which rebuilds the key set from the keys the document names and rejects unknown keys
|
||||
or bad values; a rejected document can only be discarded or fixed. The page has no reset, because there is nothing to
|
||||
reset to.
|
||||
|
||||
A fresh config overrides nothing: `dnsOverrideKeys` starts empty and the profile's own section stands until an entry
|
||||
is added. `baselineDnsOverrideKeys` is the separate, smaller thing the core is never without — the `enable`,
|
||||
`enhanced-mode: fake-ip` and `nameserver` given to a profile that brings no enabled DNS section of its own, so the
|
||||
core always resolves. Every other `Dns` field starts at mihomo's own default, which `UnmarshalRawConfig` starts from,
|
||||
so adding an entry changes nothing until it is edited; `listen` and `fake-ip-range6`, which mihomo leaves off, start
|
||||
from a working value instead. A config saved before the key list existed overrode the whole section as the model held
|
||||
it then, so `PatchClashConfig.fromJson` gives it those twenty keys rather than the empty set.
|
||||
|
||||
## NTP Override
|
||||
|
||||
`PatchClashConfig.ntp` mirrors the DNS shape: a full `Ntp` model of which only the keys in `ntpOverrideKeys` reach a
|
||||
profile, an empty set on a fresh config, and one `NtpOverrideKey` per yaml key of mihomo's `RawNTP`;
|
||||
`test/models/ntp_override_test.dart` reads `core/Clash.Meta/config/config.go` and fails when a core bump adds, drops
|
||||
or redefaults one. The section is flat, so `_makeRealProfileTask` spreads the fragment over the profile's own `ntp`
|
||||
map instead of the one-level merge DNS needs, and writes nothing at all while `overrideNtp` is off. `interval` is
|
||||
minutes, which is what `ReCreateNTPService` multiplies. The page carries the same add sheet and quick edit as DNS.
|
||||
|
||||
Safe mode forces `write-to-system` off on whatever `ntp` section the profile carries, because setting the system clock
|
||||
is a host change; mihomo's own `loadConfig` already drops it on Android, where the seccomp policy kills the process
|
||||
for `settimeofday`.
|
||||
|
||||
## Listener Exposure
|
||||
|
||||
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
|
||||
generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is
|
||||
`ExternalControllerStatus.close` by default and never binds anywhere but loopback.
|
||||
|
||||
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
|
||||
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
|
||||
it does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn
|
||||
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans
|
||||
four paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears
|
||||
`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list
|
||||
to a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`
|
||||
withholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that
|
||||
traffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's
|
||||
description tells users to supply credentials manually instead.
|
||||
|
||||
### Interface Name Modes
|
||||
|
||||
The same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a
|
||||
subscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and
|
||||
`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`
|
||||
is installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`
|
||||
shows the mode picker and text field only on desktop.
|
||||
|
||||
## Lifecycle Ownership And Convergence
|
||||
|
||||
### Shared Flutter Layer
|
||||
|
||||
`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.
|
||||
After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every
|
||||
pass, until it has consumed the latest revision, so the newest start intent wins.
|
||||
- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible
|
||||
for determining whether a Core process/service is actually running.
|
||||
|
||||
Application exit is centralized in `SystemAction` and `SystemExitCoordinator`:
|
||||
|
||||
1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.
|
||||
2. Close the desktop window.
|
||||
3. Call terminal `CoreController.close()`.
|
||||
4. Exit the application exactly once.
|
||||
|
||||
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.
|
||||
|
||||
### Desktop Lifecycle
|
||||
|
||||
`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:
|
||||
|
||||
- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.
|
||||
- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.
|
||||
- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command
|
||||
that won from one satisfied or replaced by a newer compatible intent.
|
||||
- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,
|
||||
launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals
|
||||
the process PID returned by the Helper lease.
|
||||
- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits
|
||||
for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport
|
||||
before later starts.
|
||||
- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be
|
||||
cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a
|
||||
best-effort basis because the application is exiting.
|
||||
- An unexpected disconnect or transport failure while running is converted to `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, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not
|
||||
ready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.
|
||||
|
||||
### Android Service Lifecycle
|
||||
|
||||
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.
|
||||
- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through
|
||||
`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:
|
||||
|
||||
- With a Flutter engine attached, `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.
|
||||
- Android may create an Always-on `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.
|
||||
|
||||
## Core Protocol And Event Delivery
|
||||
|
||||
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:
|
||||
|
||||
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
|
||||
Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry
|
||||
priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than
|
||||
sent through the Go queue.
|
||||
- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot
|
||||
evict state.
|
||||
- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core
|
||||
work never blocks on event delivery.
|
||||
- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is
|
||||
guaranteed after eight priority messages to prevent starvation.
|
||||
- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after
|
||||
the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be
|
||||
encoded per connection and per query for nobody.
|
||||
|
||||
DNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug
|
||||
log, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many
|
||||
callers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach
|
||||
a resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and
|
||||
report `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy
|
||||
server dialing), and unlabeled queries report `other`.
|
||||
|
||||
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.
|
||||
|
||||
## User-Facing Core And Delay Feedback
|
||||
|
||||
`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`:
|
||||
|
||||
- Provider state remains authoritative. The widget keeps a separate display-only status so a fast
|
||||
`connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.
|
||||
- The hold arms only after an observed transition to `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.
|
||||
- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show
|
||||
the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.
|
||||
|
||||
Proxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in
|
||||
`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last
|
||||
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. `ProxiesView`
|
||||
clears the loading flag of its delay test action in `finally`, so an RPC failure cannot leave the action spinning.
|
||||
|
||||
## Route Consistency
|
||||
|
||||
A probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in
|
||||
force or is being asked again. The full design is `docs/plans/probe-route-consistency.md`; the mechanism has three
|
||||
parts.
|
||||
|
||||
- The Core is the source of truth for the route. `core/route.go` keeps a structural epoch (advanced by a config
|
||||
apply, a mode or IPv6 switch, and any proxy provider whose version changed) and a `picks` map from each Selector,
|
||||
URLTest and Fallback group to what it resolves to right now, versioned on every change. `handleChangeProxy`
|
||||
re-reads the picks under `selectMu` and answers `ChangeProxyResult{changed}`, so the connection reset only runs when
|
||||
the pick moved. A health check moving a URLTest pick has no mihomo hook, so the picks are polled every second while
|
||||
the host watches (`watchRoute(true)`) and the listeners are up. Every probe answer carries the counters it started
|
||||
under; `routeChanged` publishes the state whenever it differs from the last read.
|
||||
- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and
|
||||
not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has
|
||||
returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges
|
||||
answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core
|
||||
when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.
|
||||
It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its
|
||||
counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch
|
||||
itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.
|
||||
- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix
|
||||
in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one
|
||||
request in flight per target. An answer is stamped with the Core's counters and the host state at launch, and
|
||||
`RouteState.isFresh` walks its chain against the picks, so a selection change only invalidates results that went
|
||||
through the changed group while an epoch change invalidates everything. A stale watched entry is asked again at
|
||||
once; an unwatched one is dropped. Nothing launches while the app is hidden: what falls due waits for it to be shown.
|
||||
Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and
|
||||
a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and
|
||||
runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service
|
||||
check is one to four full HTTPS fetches where a delay test is a single empty request.
|
||||
|
||||
`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as
|
||||
a new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the
|
||||
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
|
||||
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
|
||||
|
||||
## Icons
|
||||
|
||||
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
|
||||
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
|
||||
can animate its shape.
|
||||
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
|
||||
superellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the
|
||||
stroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an
|
||||
outlined icon with its filled variant:
|
||||
|
||||
- `body` fills;
|
||||
- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;
|
||||
- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from
|
||||
`Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.
|
||||
|
||||
`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its
|
||||
shorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and
|
||||
return as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into
|
||||
one layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes
|
||||
cross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking
|
||||
its size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left
|
||||
text when `Glyph.matchTextDirection` is set.
|
||||
`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows
|
||||
some other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as
|
||||
`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs
|
||||
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
|
||||
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
|
||||
|
||||
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, `FilledButton`
|
||||
in its tonal and icon forms, and floating action buttons draw their glyph with `fill: 1`, because an outline on a tinted
|
||||
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
|
||||
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
|
||||
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
|
||||
the button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the
|
||||
selected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS
|
||||
tab bar it fills every destination and marks the selected one by colour alone.
|
||||
|
||||
Framework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`
|
||||
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
|
||||
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
|
||||
|
||||
Empty states draw no image assets either. `NullStatus` in `lib/widgets/null_status.dart` shows the glyph for its
|
||||
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
|
||||
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
|
||||
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
|
||||
`primaryContainer` as light as `primary` in dark mode.
|
||||
|
||||
`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,
|
||||
pixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.
|
||||
|
||||
## App Bar Actions
|
||||
|
||||
`CommonScaffold` keeps at most two buttons in sight in its bar. A page hands it what may fold as data: `primaryAction`,
|
||||
`iconActions`, `menuItems`, and `searchActions` while searching; custom widgets passed through `actions` never fold.
|
||||
Past two, only the search button (the clear button while searching) stays in sight, or on a bar without search the
|
||||
primary action, or the first icon action when `foldPrimaryAction` is set; everything else goes into one overflow menu,
|
||||
primary action first, and a folded action that is loading shows as a disabled item. `primaryAction` counts only where the bar holds it, in a bottom sheet and on a phone's home
|
||||
page, whose foot the navigation dock takes; elsewhere it is the page's floating action button. No page adds a
|
||||
floating action button of its own: a secondary action such as selecting every app goes into `menuItems`, and saving
|
||||
an edit page is a check among its `iconActions`. Actions on a multi-selection, such as delete and select all, go into
|
||||
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
|
||||
or clear button, so they never fold.
|
||||
|
||||
## Settings Rows
|
||||
|
||||
`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`,
|
||||
`ConfigTextItem`, and `ConfigListEditItem`. Each takes a `selector` (a `ProviderListenable`, normally
|
||||
`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting
|
||||
rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive
|
||||
`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape.
|
||||
|
||||
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
|
||||
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
|
||||
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
|
||||
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
|
||||
second provider write — stay hand-written rather than growing extra parameters on the shared items.
|
||||
|
||||
## State Management
|
||||
|
||||
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.
|
||||
Like `action.dart`, this is an entry point only: the providers live under
|
||||
`lib/providers/state/` and are joined with `part` directives, so importing
|
||||
`state.dart` still reaches all of them.
|
||||
- `state/proxies.dart`: group and proxy lists, filter/sort, delay, selection.
|
||||
- `state/navigation.dart`: navigation items, current page, dashboard, more tools.
|
||||
- `state/system.dart`: tray, VPN params, access control, hot keys, shared state.
|
||||
- `state/theme.dart`: dynamic color, color scheme, brightness.
|
||||
- `state/profile.dart`: profiles, current profile, clash config, setup state.
|
||||
- `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers.
|
||||
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
|
||||
- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`.
|
||||
- `route_state.dart` and `routed_probe.dart`: the route tracker and the probe cache; see Route Consistency above.
|
||||
- `outbound_ip.dart` and `service_status.dart`: the dashboard probes built on them.
|
||||
- `database.dart`: Drift database provider wrappers.
|
||||
|
||||
### Reaching Singletons
|
||||
|
||||
`lib/common/` and `lib/core/` publish process-wide singletons (`coreController`,
|
||||
`system`, `preferences`, `appPath`, `request`, and others). Code that already has a
|
||||
`Ref` or a `WidgetRef` reads them through a provider instead, so a test can scope a
|
||||
fake to one `ProviderContainer` rather than swapping a global and relying on a
|
||||
tearDown to put it back.
|
||||
|
||||
`coreHandlerProvider` is the established case. Every call site under
|
||||
`lib/providers/`, `lib/manager/` and `lib/views/` goes through it; notifiers and
|
||||
`ConsumerState` classes that touch Core repeatedly hold it as
|
||||
`CoreController get _core => ref.read(coreHandlerProvider)`.
|
||||
|
||||
Tests override it with `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))`,
|
||||
which does not claim the singleton. `CoreController.test` does claim it, and is
|
||||
only for tests that have not moved yet. Prefer the scoped override even when a
|
||||
test passes either way: a test that claims the singleton makes the global and the
|
||||
provider resolve to the same fake, so it cannot tell a provider read from a
|
||||
leftover global read, and a half-migrated call site stays green.
|
||||
|
||||
One deliberate exception: `globalState` owns the `ProviderContainer`, so it cannot
|
||||
itself live in one. Code without a `Ref` reaches providers through
|
||||
`globalState.container.read(...)`. The remaining call sites are `lib/core/lib.dart`,
|
||||
`lib/common/print.dart`, and the tray `read` callback in
|
||||
`lib/providers/actions/system.dart` — all singletons or platform callbacks with no
|
||||
`Ref` in scope.
|
||||
|
||||
`lib/models/profile.dart` does not reach Core. `Profile.saveFile` and
|
||||
`Profile.update` take a `ValidateConfig` callback, and every caller passes
|
||||
`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile
|
||||
path that never validates never resolves the controller.
|
||||
|
||||
The UI layer must not reach a process-wide singleton directly.
|
||||
`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`,
|
||||
`lib/pages` and `lib/features` for `globalState.container` and the bare
|
||||
`coreController` global and fails the run on either. Widgets that need Core hold
|
||||
`CoreController get _core => ref.read(coreHandlerProvider)`.
|
||||
|
||||
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
|
||||
context-derived caches assigned by `ThemeManager`, and tests already scope them by
|
||||
assigning in the app builder (see `test/helpers/test_app.dart`); moving them into
|
||||
providers would touch every layout call site without changing behaviour.
|
||||
|
||||
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
|
||||
package info, the measure and theme, the container, and the start/stop flags —
|
||||
plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and
|
||||
`attach` live in `lib/bootstrap.dart`, above `lib/common`, because they drive the
|
||||
window, the autostart entry, the tray and the permission prompts. Providers are
|
||||
generated into `lib/providers/generated/`.
|
||||
|
||||
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
|
||||
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
|
||||
the key directly and does not import `lib/state.dart`.
|
||||
|
||||
### Platform Layering
|
||||
|
||||
`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`,
|
||||
`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import
|
||||
`tray`, `window`, `launch_at_startup`, and `screen_retriever`;
|
||||
exporting them would put those packages in the compile graph of every file that
|
||||
imports the barrel for a string helper. Import the specific module instead.
|
||||
|
||||
`test/lint/platform_layering_test.dart` enforces four rules. Three are local: the
|
||||
barrel never re-exports one of those five modules, nothing under `lib/common`,
|
||||
`lib/enum` or `lib/models` other than those five imports a desktop platform
|
||||
package, and `lib/common` never imports the `lib/manager/manager.dart` barrel
|
||||
(import the single manager needed, as `common/context.dart` does with
|
||||
`manager/status_manager.dart`). The fourth walks the barrel's whole transitive
|
||||
closure and fails if *any* file in it imports one of those packages. That one is
|
||||
the real invariant — the local rules only stop the shortest path, and every leak
|
||||
found so far arrived through a longer one.
|
||||
|
||||
The shapes that keep the closure small:
|
||||
|
||||
- `System.back` and `System.exit` do not touch `window`; the window half of
|
||||
both lives in `SystemAction`.
|
||||
- `KeyboardModifier.toHotKeyModifier()` lives in
|
||||
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
|
||||
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
|
||||
`common/num.dart`, `common/print.dart` and `common/request.dart` import
|
||||
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
|
||||
`GlobalState` reaches lands in the barrel's closure; the ambient state it
|
||||
holds reaches nothing platform-specific.
|
||||
- `SystemAction` talks to `WindowPort` and `TrayPort` from
|
||||
`lib/common/app_ports.dart` instead of importing `common/window.dart` and
|
||||
`common/tray.dart`. `lib/bootstrap.dart` binds `windowPort` and `trayPort` to
|
||||
the real implementations; both stay null in tests, where every call through
|
||||
them is a no-op. A test that needs the real tray assigns `trayPort` itself, as
|
||||
`test/common/tray_menu_test.dart` does.
|
||||
- `lib/providers/providers.dart` re-exports `action.dart`, so `lib/common` and
|
||||
`providers/app.dart` import `providers/state.dart` and `providers/config.dart`
|
||||
directly; the barrel would reach the whole action layer.
|
||||
- A data type in a lower layer never holds the widget that renders it, because
|
||||
that drags the whole view tree into the barrel's closure. The route table is
|
||||
`lib/views/navigation.dart` implementing `NavigationPort`, which
|
||||
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
|
||||
unbound it yields no items, so a test that renders navigation assigns
|
||||
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
|
||||
is plain data, since it is persisted in the app settings; the widget mapping
|
||||
lives in `lib/views/dashboard/widget_registry.dart`, the reverse lookup relying
|
||||
on the branches returning canonical consts.
|
||||
|
||||
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
|
||||
contains no `lib/views` file. Data the provider layer needs from the UI layer
|
||||
goes through a port in `lib/common/app_ports.dart` rather than an import in the
|
||||
other direction.
|
||||
|
||||
### High-Frequency Buffers
|
||||
|
||||
`logsProvider`, `requestsProvider`, `dnsQueriesProvider` and `trafficsProvider` hold a `FixedList`
|
||||
(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared
|
||||
buffer tagged with a generation counter:
|
||||
|
||||
- `append` mutates the buffer in place and returns a new wrapper one generation
|
||||
ahead. That is what providers publish, so `updateShouldNotify` still fires.
|
||||
- `list` returns an immutable copy, cached until the next mutation. It must stay
|
||||
eager: an older wrapper shares the buffer, so its contents move on. Anything
|
||||
that needs a stable view has to read `list` at the moment it is notified, not
|
||||
hold the wrapper and read later.
|
||||
- Consumers that only need to know *that* the buffer changed watch `revision`,
|
||||
not `list` — selecting on the list snapshots and deep-compares the whole
|
||||
buffer on every arrival, which is what this design exists to avoid. See
|
||||
`lib/views/logs.dart` for the pattern: watch the generation, snapshot inside
|
||||
the throttled callback.
|
||||
|
||||
`add`/`clear` mutate in place without advancing the generation; use them only on
|
||||
a buffer you own outright (seeding, resets, tests), never on published state.
|
||||
|
||||
### Background Work
|
||||
|
||||
A proxy client runs for days with nobody looking at it, so work that only feeds the UI stops when the UI cannot be
|
||||
seen. There are three levels of gating:
|
||||
|
||||
- `appVisibleProvider` is false while the desktop window is hidden or the Android activity is in the background
|
||||
(`hidden`, `paused`, `detached`). `AppStateManager` writes it. The one-second run-time and traffic ticker in
|
||||
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
|
||||
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
|
||||
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
|
||||
`resumed` and its home page is the current one.
|
||||
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
|
||||
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
|
||||
otherwise keep scheduling frames.
|
||||
|
||||
A provider's own `ref.listen` delivers nothing while no one listens to that provider. That is the usual state of an
|
||||
action notifier, which is only ever read. A derived provider it listens to is never recomputed, and even a `ref.read`
|
||||
of it returns a stale value. So an action that has to follow a signal listens to the keep-alive provider that holds
|
||||
it, as `SetupAction` does with `appVisibleProvider`, not to a provider derived from that.
|
||||
|
||||
On Android, `SuspendModule` suspends the Core while the screen is off and the device is in Doze. Health checks that
|
||||
run in Doze all fail, so after a suspension the Core re-probes every provider. That re-probe waits for the screen to
|
||||
come on. A Doze maintenance window resumes the Core too, but probing there would spend the window's radio time on
|
||||
results nobody sees.
|
||||
|
||||
## Database
|
||||
|
||||
The app uses Drift/SQLite in `lib/database/`. `schemaVersion` in `lib/database/database.dart` is the migration
|
||||
counter; every schema change bumps it and adds the matching migration step.
|
||||
|
||||
Tables:
|
||||
|
||||
- `Profiles`
|
||||
- `Scripts`
|
||||
- `Rules`
|
||||
- `ProfileRuleLinks` (`profile_rule_mapping`)
|
||||
- `ProxyGroups`
|
||||
- `CustomProxies` (`custom_proxies`)
|
||||
- `IconRecords` (`icon_records`)
|
||||
- `ClashProviders` (`clash_providers`)
|
||||
|
||||
Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
|
||||
|
||||
App-level providers (`ClashProviders`, plus every profile offered as a proxy provider) and a custom overwrite's own
|
||||
proxies (`CustomProxies`) are soft-disabled through `feature.customProviders` and `feature.customProxies`
|
||||
(`lib/common/feature.dart`), switched on by `--dart-define=FEATURE_CUSTOM_PROVIDERS=true` and
|
||||
`FEATURE_CUSTOM_PROXIES=true`. While off, their entries are hidden, `appProviderNames` and `appProviderLabels` are
|
||||
empty, and `setupState` loads neither, so stored rows stay in the database but never reach a profile. Backups still
|
||||
carry both tables.
|
||||
|
||||
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.
|
||||
|
||||
## Manager Stack
|
||||
|
||||
Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`:
|
||||
|
||||
```text
|
||||
AppEnvManager > LocaleManager > StatusManager > ThemeManager
|
||||
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
|
||||
[Mobile: AndroidManager > TileManager]
|
||||
> AppStateManager > CoreManager > ConnectivityManager
|
||||
> [Desktop: WindowHeaderContainer] [Mobile: VpnManager]
|
||||
> app content
|
||||
```
|
||||
|
||||
Each manager in `lib/manager/` handles a specific platform concern. The
|
||||
platform slots are exclusive: no desktop manager appears on mobile and no mobile
|
||||
manager appears on desktop.
|
||||
|
||||
The order is an ownership contract, not a layout detail. `ConnectivityManager`
|
||||
sits below `CoreManager` because its `onConnectivityChanged` callback reads
|
||||
Core-backed state, so Core must already be mounted when it fires. `StatusManager`
|
||||
and `ThemeManager` sit above the platform managers so a platform manager can
|
||||
surface a message or read the theme.
|
||||
|
||||
`buildManagerStack()` is a pure function of `isDesktop`, the connectivity
|
||||
callback, and the app content, so `test/application_test.dart` asserts the whole
|
||||
order by constructing the stack without mounting it. Changing the nesting means
|
||||
updating both this diagram and that test.
|
||||
|
||||
## Core Controller and Actions
|
||||
|
||||
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either the Android service channel (`CoreLib`) or the 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 orchestration over a per-run temp work directory; the view supplies where an archive
|
||||
goes (local save, WebDAV upload) or comes from. The archive format lives in `lib/common/backup.dart`, which stages a
|
||||
restore without touching the data directory; files, database rows and settings are applied only after the whole
|
||||
archive has been read and validated.
|
||||
- `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.
|
||||
- `UpdatingAction`: stale sweep over `UpdatingKeys`.
|
||||
|
||||
`UpdatingKeys` in `lib/providers/app.dart` owns every per-entity updating flag; `isUpdatingProvider(key)` is the
|
||||
read-only view widgets watch. Callers pair `start(key, scope: ...)` with `stop(key, operation)` using the returned
|
||||
token, so overlapping operations on one key are reference counted and a late `stop` from a superseded operation
|
||||
cannot clear a newer one. Keys started with `UpdatingScope.core` depend on the Core to make progress, so
|
||||
`UpdatingKeys` discards them itself when `coreStatusProvider` leaves `connected` — that is the state's own
|
||||
invariant, not a policy, and it must stay inside the notifier where no warm-up ordering can miss it.
|
||||
`UpdatingScope.local` keys (profile updates run entirely in Dart) survive a Core restart. `UpdatingAction` holds
|
||||
only the policy half: the periodic sweep that discards a key stuck past `updatingStaleTimeout`. Do not move the
|
||||
timeout back into `UpdatingKeys`, and do not widen the disconnect reset to every scope.
|
||||
|
||||
## Safe Mode
|
||||
|
||||
`--dart-define=SAFE_MODE=true` builds a FlClash that never changes the host network, so a development build can run
|
||||
next to an installed one. It is a compile-time constant with two faces: `safeModeBuild` in `lib/common/constant.dart`
|
||||
for the process-wide handles that exist before the provider container, and `safeModeProvider` in
|
||||
`lib/providers/app.dart` for everything reached through Riverpod, which is also what tests override. Settings are never
|
||||
rewritten; each owner reads the flag where it would otherwise act:
|
||||
|
||||
- Core config: `SetupAction._effectivePatchConfig` is the one place the profile path and `updateConfig` derive what
|
||||
the Core receives from the user's `PatchClashConfig`. In safe mode it reports `tun.enable: false` and
|
||||
`ExternalControllerStatus.close`, so the generated profile and every `updateConfig` carry no TUN and an empty
|
||||
`external-controller` (the Core's `route.ReCreateServer` binds no RESTful API port) while the settings keep the
|
||||
user's values and the installed app keeps `127.0.0.1:9090`. `SetupAction.requestAdmin` skips authorization.
|
||||
- System clock: `_makeRealProfileTask` sets `write-to-system: false` on whatever `ntp` section the generated profile
|
||||
ends up with, so neither the user's override nor the profile's own NTP config can move the host's time.
|
||||
- System proxy: the `proxy` plugin handle in `lib/common/proxy.dart` is `null`, so `ProxyManager` and exit cleanup
|
||||
never write the OS setting, not even the `stopProxy` that would clear what the installed app configured.
|
||||
- System DNS: `systemDnsCoordinator` is `null` and `shouldPatchSystemDnsProvider` is `false`, so neither the apply nor
|
||||
the restore path runs. The restore path matters because a profile or release build shares the preferences of the
|
||||
installed app (only a macOS or Android debug build has its own bundle id) and would restore from its record.
|
||||
- Login item: the `autoLaunch` handle in `lib/common/launch.dart` is `null`, so neither the boot sync nor the setting
|
||||
listener registers the build's executable or removes the installed app's entry, which on Windows is the same
|
||||
`FlClash` value in the user's Run key.
|
||||
- Privileged Core: `CoreService` resolves the launcher with `hasHelper: false`, which is the direct child-process path
|
||||
on every desktop platform; the Helper keeps managing the installed app's Core.
|
||||
- Core listeners: `SetupAction._setCoreRunning` and the `AppStateManager` suspend listener never call `startListener` or
|
||||
`stopListener`, so the Core's `isRunning` stays false and `updateListeners` binds nothing, while `isStartProvider`
|
||||
and `applyProfile` behave as usual. The installed app keeps the mixed, DNS and other loopback ports.
|
||||
- Android: `sharedStateProvider` hands the service `VpnOptions.enable: false` and `systemProxy: false`, so the
|
||||
foreground service runs without a `VpnService`.
|
||||
- Wi-Fi SSID: `ConnectivityManager` never reads the SSID and `Permissions.checkLocationPermissions` neither checks
|
||||
nor requests location authorization, so `currentSSIDProvider` stays `null` and the excluded-network suspend never
|
||||
trips. The macOS `wifi_ssid` plugin builds its `CLLocationManager` and `CWWiFiClient` lazily, so a build that
|
||||
never asks never reaches locationd or wifid.
|
||||
- Global hotkeys: `HotKeyManager` neither subscribes to hotkey events nor registers the configured shortcuts with the
|
||||
OS; the in-window `Shortcuts` (close window, escape) stay.
|
||||
- URL schemes and launcher shortcuts: `Window.init` skips the Windows registry and Linux desktop-entry registration
|
||||
of the `clash` schemes, and Android never calls `initShortcuts`.
|
||||
- Presentation: `AppEnvManager` shows a `SAFE MODE` banner ahead of the channel banner, and `TrayState.safeMode` gives
|
||||
the tray the `status_4` bug icon, a suffixed tooltip and a menu of only show and exit. `trayStateProvider` and
|
||||
`trayTitleStateProvider` report `showTrayTitle: false`, so the macOS menu bar carries no traffic title.
|
||||
|
||||
A new host integration, anything that changes the OS's routes, proxy, DNS, login items, privileges or VPN state, or
|
||||
that asks the OS for a permission, a global shortcut or a registration, must consult the flag at its owner in the same way and get a test that overrides `safeModeProvider`.
|
||||
|
||||
## Build System
|
||||
|
||||
`setup.dart` is the release build orchestrator: it writes `env.json` (`APP_ENV`), activates `flutter_distributor` from
|
||||
the `chen08209/flutter_distributor` fork pinned to a `v<version>-flclash.<n>` tag (cut a new tag there and bump
|
||||
`--git-ref` when the fork changes), and leaves the Core artifacts to the build hook.
|
||||
|
||||
The Go core and the Rust helper are built by a Dart build hook. `plugins/setup/hook/build.dart` only constructs
|
||||
`CoreBuilder`, a `package:hooks` `Builder` in `plugins/setup/setup_hooks/`, the same shape `rust_api` uses. Flutter
|
||||
runs it for every platform build and for `flutter test`, once per target architecture. `CoreBuilder` turns the hook
|
||||
input into a `BuildRequest` (repository root, `Target`, Android toolchain) and hands it to `buildPlatform`. It lives in
|
||||
`setup_hooks` so `dart test` there can cover it; a test run of `plugins/setup` itself would execute the hook. Failures
|
||||
reach the runner as `BuildError` (Go or Cargo failed, no Core for the architecture) or `InfraError` (a toolchain could
|
||||
not start, Flutter passed no NDK compiler, the package is not at `plugins/setup`).
|
||||
|
||||
What the hook protocol forces:
|
||||
|
||||
- Android derives the per-ABI clang wrapper from the C compiler and `targetNdkApi` (the app's `minSdk`) Flutter
|
||||
passes; `hooks_runner` filters the environment, so `ANDROID_NDK` is not read.
|
||||
- `PATH` arrives unextended, so `runCommand` appends the Homebrew, Go and rustup locations that Xcode's and Gradle's
|
||||
stripped `PATH` hides.
|
||||
- The hook does not know the build mode: the protocol carries none and `linkingEnabled` only says whether link hooks
|
||||
run, so nothing in the hook branches on debug versus release. It cannot tell `flutter test` from a build either; the
|
||||
`build_assets: false` user-define in the root `pubspec.yaml` is the one switch (see `.agents/commands.md`), and
|
||||
`setup.dart` refuses to package while it is set.
|
||||
- The hook declares the files it read but the *directories* it wrote: `hooks_runner` hashes a file by content and a
|
||||
directory by child names, so `libclash/macos/` catches a deleted artifact without MD5-ing a 55 MB Core on every
|
||||
build. A rebuild touches those directories after the runner's cutoff, so the next build runs the hook once more and
|
||||
setup's cache answers it; that extra run is expected.
|
||||
- The runner discards hook output on success, even under `-v`, so the hook mirrors it into
|
||||
`.dart_tool/setup_build_cache/hook.log` (rotated at 512 KB), one `===` header per invocation.
|
||||
|
||||
Platform projects copy the artifacts out of `libclash/`; application code must not import `plugins/setup`:
|
||||
|
||||
- Android: the Go core is built `c-shared`, and `libclash.so` with its headers lands in the `:core` module (see Android
|
||||
Native Task Ordering).
|
||||
- macOS: a standalone `FlClashCore`. `Release.xcconfig` pins release and profile `ARCHS` to the host because
|
||||
flutter_tools otherwise builds a universal binary and every artifact ships one slice; the hook skips a non-host slice
|
||||
for the same reason. The `Stage Core` phase copies the Core after the hook may have rewritten it and fails when it is
|
||||
missing or lacks a slice for `ARCHS`, so a skipped hook cannot stage a stale Core silently.
|
||||
- Linux and Windows: `FlClashCore`, the Rust `FlClashHelperService`, and a `manifest.json` holding `coreSha256`; the
|
||||
Core builds first because the Helper embeds its hash. The CMake `install` rules copy them, and the Windows bundle
|
||||
places `manifest.json` beside the executable. A Helper running from a Debug build keeps its exe open and the install
|
||||
fails behind an opaque `MSB3073`, so `windows/CMakeLists.txt` stops it from an `install(CODE)` step for the `Debug`
|
||||
configuration only, and only when the staged Helper differs from the installed one; that step is where the
|
||||
configuration is known, and a Release install never stops a registered service. The step matches the process by
|
||||
executable path rather than image name, so a Helper registered from an installed release keeps running.
|
||||
|
||||
Setup keeps its own cache under `.dart_tool/setup_build_cache/v1/` because it builds a Go core and, on Windows and
|
||||
Linux, a Rust helper:
|
||||
|
||||
- Go fingerprints cover the target-specific `go list -deps` inputs in `core/` and `Clash.Meta`, module files, the
|
||||
effective build configuration, `setup_hooks` sources, target flags, the Go toolchain and the Android clang version.
|
||||
Helper fingerprints cover its Rust sources and manifests, Cargo/Rust toolchains and flags, and the expected Core
|
||||
SHA256.
|
||||
- A hit requires the fingerprint and every recorded output's path, size and modification state to match, and skips
|
||||
compilation and copying.
|
||||
- Records are written only after a successful build, under per-target locks. Compilation happens in a staging
|
||||
directory and moves into place on success, so a failed build never destroys the previous artifacts.
|
||||
- `flutter clean` removes `.dart_tool` and forces one full rebuild; deleting `.dart_tool/setup_build_cache/` does the
|
||||
same without a clean.
|
||||
|
||||
Windows helper integrity/version check:
|
||||
|
||||
- `setup_hooks` constructs the Core first, calculates its SHA256, and always
|
||||
builds the Rust Helper with release hardening and that expected hash.
|
||||
- Flutter reads the Core SHA256 from the bundled `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.
|
||||
- TUN is not a required run condition. A direct Core runs unelevated and cannot bring up TUN, so any degrade to the
|
||||
direct Core — an unready Helper at resolve time, or a pre-spawn `/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.
|
||||
- Flutter creates a 128-bit lowercase-hex session ID and uses it as the random named-pipe suffix. `/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.
|
||||
- The Core must not outlive the Helper. On Linux the systemd unit's cgroup and `Restart=on-failure` cover that; on
|
||||
Windows the Helper puts the Core in a Job Object with `KILL_ON_JOB_CLOSE`, so the kernel kills the Core when the Helper
|
||||
process ends for any reason, and the `install` subcommand configures SCM failure actions (three restarts, 5 s apart,
|
||||
reset after 24 h). Stopping a Core sends SIGTERM first on Linux, because only a shutdown removes the policy routes
|
||||
sing-tun installed; the Core runs the same teardown itself when its IPC host disconnects or it receives a termination
|
||||
signal. `HelperClient.stopTimeout` must stay above the Helper's graceful-plus-kill budget.
|
||||
- Never take `MANAGED_CORE` or `LOGS` with `lock().unwrap()`. The Helper is a long-lived service running as SYSTEM, so a
|
||||
single panic while a lock is held would poison it and turn every later request into another panic — the service stays
|
||||
dead until Windows restarts it. `lock_surviving_poison` recovers the guard through `PoisonError::into_inner` instead.
|
||||
`hub.rs` uses it at every lock site, tests included, and two tests in that file pin the behaviour.
|
||||
|
||||
Build configuration defaults live in `plugins/setup/setup_hooks/lib/src/options.dart` and can be overridden via the 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`.
|
||||
|
||||
#### Android Native Task Ordering
|
||||
|
||||
`:core` consumes `libclash.so` and its headers as files the setup build hook writes into `android/core/src/main`, not
|
||||
as an asset the hook hands back, so nothing in Gradle's model links the two. AGP's configure fingerprint tracks only
|
||||
`CMakeLists.txt` and its own generated files, which makes a wrong ordering *sticky*: a configure that ran before the
|
||||
hook stays cached and every later build reuses it. `CMakeLists.txt` therefore links `clash` unconditionally, and
|
||||
`android/core/build.gradle.kts` fails before any native task runs when those files are absent — both push the failure
|
||||
out of the configure step, where it would be cached, and into a place that re-evaluates every build. Three details
|
||||
there are easy to get wrong:
|
||||
|
||||
- `defaultConfig.ndk.abiFilters` is derived from the `target-platform` property Flutter passes to Gradle, mapped
|
||||
through the same ABI table as `Target.forPlatform('android')` in `setup_hooks`; keep the two tables equal. The hook
|
||||
builds a Core only for the platforms of the current build, so a `:core` ABI outside that set has no `libclash.so` to
|
||||
link, and without any filter AGP would configure the NDK's full default ABI set.
|
||||
- Match task names by **prefix**. AGP puts the ABI in the names of the tasks that do the work
|
||||
(`configureCMakeDebug[arm64-v8a]`). The bare `configureCMakeDebug`, `buildCMakeDebug`, and `externalNativeBuildDebug`
|
||||
that an exact-name match catches are grouping tasks that never enter an app build's execution graph.
|
||||
- Use `mustRunAfter`, not `dependsOn`. `:core` declares only `debug` and `release` variants, so a **profile** app
|
||||
consumes `:core`'s `debug` variant; no `:core` variant name maps onto the right `:app` task, and `dependsOn` would
|
||||
drag a second, wrong-mode Flutter build into the graph.
|
||||
|
||||
A standalone `./gradlew :core:assembleDebug` stays legal once the hook has run at least once; it only warns that the
|
||||
artifacts under `src/main` are whatever the last hook run left behind, which is the one failure mode this guard cannot
|
||||
detect. Verify a change here with `./gradlew ":core:buildCMakeDebug[arm64-v8a]" :app:compileFlutterBuildDebug
|
||||
--dry-run`: the Flutter task must be listed first even though the CMake task was requested first.
|
||||
|
||||
#### Android Crash Symbols
|
||||
|
||||
Crashlytics NDK symbolicates a native crash only from libraries that still carry their symbols, and AGP strips every
|
||||
`.so` it packages, so the Android build keeps symbols until `:app`'s own strip step and the APK does not grow:
|
||||
|
||||
- The hook drops `-s` and `-w` from `go_ldflags` for the `c-shared` Android targets only; the desktop Cores stay
|
||||
stripped because nothing consumes their symbols.
|
||||
- `:core` compiles with `-g`, does not `--strip-all`, and sets `packaging.jniLibs.keepDebugSymbols` so its own strip
|
||||
task leaves `libclash.so` and `libcore.so` intact on the way into `:app`'s merged native libs, which is where the
|
||||
Crashlytics plugin reads them.
|
||||
- `:app` turns on `nativeSymbolUploadEnabled` and finalizes `assembleRelease` and `bundleRelease` with
|
||||
`uploadCrashlyticsSymbolFileRelease` only when release signing is configured, the same condition that marks a real
|
||||
release build; the checked-in `google-services.json` is a placeholder, so a local upload is noise at best, and CI
|
||||
supplies both files together. The Crashlytics plugin wires the R8 mapping upload itself but leaves the native symbol
|
||||
upload to the caller, so nothing else triggers it. `generateCrashlyticsSymbolFileRelease` runs `dump_syms` over the
|
||||
Go Core for every ABI, which is the other reason it stays off for unsigned builds.
|
||||
|
||||
## Local Plugins
|
||||
|
||||
- `setup`: build-time harness for Go core artifacts and the Rust helper, driven by a Dart build hook; no runtime Dart API.
|
||||
- `proxy`: system proxy configuration.
|
||||
- `rust_api`: runtime Flutter Rust Bridge FFI package built through Native Assets. See below.
|
||||
- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork.
|
||||
- `window`: desktop window control for Linux, macOS and Windows. Written for FlClash; replaced the
|
||||
`window_manager` fork. See below.
|
||||
- `wifi_ssid`: Wi-Fi SSID detection.
|
||||
|
||||
## window Plugin
|
||||
|
||||
`plugins/window` exposes `desktopWindow` (`DesktopWindow`) on the `window` method channel; `plugins/window/README.md`
|
||||
is the channel contract (methods, arguments, events, effects, runner hooks) and every platform implements exactly that
|
||||
table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the only application callers.
|
||||
|
||||
- Each platform keeps one style record (title bar style and button visibility everywhere, rounded corners on
|
||||
Windows, effect, tint and brightness on Windows and macOS) and reapplies it whole from a single `apply` routine, so DWM margins
|
||||
on Windows and `isOpaque`/effect views on macOS have one writer. Add a visual setting to that record, never a second
|
||||
code path.
|
||||
- Method dispatch is a table on every platform, and every handler validates its arguments; a bad call answers with a
|
||||
`bad_args` `PlatformException`, a capability the platform lacks with `unsupported`. Coordinates cross the channel in
|
||||
logical pixels; Windows converts with `GetDpiForWindow`.
|
||||
- Position and size changes surface as one `geometry-changed` event that each platform coalesces natively (150 ms
|
||||
after the last `WM_WINDOWPOSCHANGED`, did-move/did-resize, or configure-event); `WindowManager` only debounces the
|
||||
capture on top of it.
|
||||
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` lets `DefWindowProc` compute the client rect and then puts
|
||||
the top edge back (plus the frame height when maximized, so the taskbar stays uncovered; plus 1 px otherwise, which
|
||||
Windows 11 needs to draw the top border and Windows 10 needs to avoid a white line). Fullscreen is emulated by the
|
||||
plugin, which raises `enter-full-screen` and
|
||||
`leave-full-screen` itself and mutes `WM_SIZE` while it is on. The find-running-window IPC
|
||||
(`WindowPluginFindRunningWindow`/`WindowPluginActivateWindow`) registers a window message from the executable path
|
||||
and whitelists it through UIPI so a non-elevated launcher reaches an elevated instance.
|
||||
- Linux: GTK drops the placement of an unmapped window, so `hide` saves the geometry and the `map-event` handler
|
||||
applies it, then corrects the placement for 150 ms while the window manager settles (it may place the window
|
||||
after the map handler ran). `isPositionSupported` is X11-only; Wayland owns placement and re-shown windows land where the
|
||||
compositor puts them. Linux supports no effect: GTK 3 has no desktop-neutral blur protocol, so `isEffectSupported`
|
||||
is true only for `none` and the runner keeps the default visual.
|
||||
- macOS: `WindowPlugin.instance` is reached from `AppDelegate` for `applicationShouldTerminate` (emits
|
||||
`should-terminate`) and `applicationShouldHandleReopen` (emits `activate`); `MainFlutterWindow` calls
|
||||
`hiddenWindowAtLaunch()` so the first `order` keeps the window hidden until Dart shows it.
|
||||
- Window effects (`transparent`, `blur`, `acrylic`, `mica`) are capability-gated by `isEffectSupported`; the Flutter
|
||||
content must paint a translucent background for them to show. `ThemeProps.sidebarBlur` (on by default) drives
|
||||
them: `Window.setBlur` picks `blur` on macOS and `acrylic` (or `blur`) on Windows and caches only a successful
|
||||
probe, so a platform that answered unsupported is asked again the next time blur is turned on. `WindowManager` reapplies it
|
||||
whenever the setting or the app brightness changes and records the outcome in `windowBlurProvider`, and only the
|
||||
sidebar and the caption strip paint translucently while it is on. On macOS the sidebar paints fully transparent
|
||||
and the effect view follows the window's active state, which is the flutter_acrylic `TransparentMacOSSidebar`
|
||||
look; Windows keeps a 50% tint because the accent effects blur too little for the rail to stay readable.
|
||||
- Sidebar blur is soft-disabled: `feature.sidebarBlur` (`lib/common/feature.dart`) is off unless the build passes
|
||||
`--dart-define=FEATURE_SIDEBAR_BLUR=true`. While it is off the theme page hides the toggle and
|
||||
`windowBlurRequestProvider` asks for no effect whatever `ThemeProps.sidebarBlur` holds. `WindowManager` still sends
|
||||
that request at startup: the macOS plugin installs its effect view in `ensureInitialized` and leaves it active until
|
||||
the first `setEffect`, so the `none` request is what makes the window opaque and parks the effect view.
|
||||
|
||||
## rust_api Crate Layout
|
||||
|
||||
`plugins/rust_api` has no platform folders. `hook/build.dart` is a Dart build hook: Flutter runs it for every platform
|
||||
build and for `flutter test`, and `flutter_rust_bridge_hooks` (over `native_toolchain_rust`) compiles the crate with
|
||||
Cargo and registers `librust_api` as a code asset that Flutter bundles and signs. The build runs
|
||||
`rustup run <channel>`, so `rust/rust-toolchain.toml` must pin an exact channel and list every target the project ships;
|
||||
the hook refuses `stable` and a target missing from that list. `rustup show` installs the pinned toolchain and those
|
||||
targets on first use.
|
||||
|
||||
`flutter_rust_bridge_codegen generate` (see `.agents/commands.md`) writes `lib/src/rust/frb_generated.dart`,
|
||||
`frb_generated.io.dart`, `frb_generated.web.dart`, one `lib/src/rust/api/<module>.dart` per Rust API module, and
|
||||
`rust/src/frb_generated.rs`; none of them is edited by hand. A new Rust function is a public function in `rust/src/api/`,
|
||||
plus `pub mod <name>;` in `api/mod.rs` and an export from `lib/rust_api.dart` when it starts a new module. `RustLib` is
|
||||
the singleton entry point: `RustLib.init()` runs before any call and `RustLib.dispose()` on teardown.
|
||||
|
||||
`plugins/rust_api/rust/src/` separates the bridge boundary from the code behind it:
|
||||
|
||||
- `api/` is the only input flutter_rust_bridge parses (`rust_input: crate::api`). Every function there is a thin
|
||||
delegation, so the generated bindings stay identical on every platform.
|
||||
- `ipc/` implements the desktop socket server: `frame` (length-prefixed framing and the write backoff), `queue` (the
|
||||
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
|
||||
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
|
||||
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
|
||||
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
|
||||
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
|
||||
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
|
||||
to Dart. Linux is X11 only; a Wayland session without XWayland gets an error rather than a silent no-op.
|
||||
|
||||
What a platform does not use, it does not compile. `interprocess` and `global-hotkey` are declared under
|
||||
`cfg(not(target_os = "android"))`, and `ipc/mod.rs` and `hotkey/mod.rs` swap in their `unsupported.rs` there, because
|
||||
Android loads the Core in-process and has no global shortcuts. Adding a capability follows the same shape: implement it in its own module, gate the
|
||||
dependency by target, and keep the `api/` entry point unconditional so one set of bindings still serves every platform.
|
||||
|
||||
`RustLib.init()` runs on every platform, because the script engine is shared.
|
||||
|
||||
## Profile Script Engine
|
||||
|
||||
`lib/common/javascript.dart` sends the profile as JSON to `evaluate_script`, which runs `main(config)` on QuickJS and
|
||||
returns the JSON the script produced. Nothing about the script runs in Dart.
|
||||
|
||||
- QuickJS is compiled from source for the target being built, which is what removed the prebuilt `quickjs-c-bridge`
|
||||
binaries: `flutter_js` shipped x64 Windows and desktop-only libraries, so Windows ARM64 could not start (#2361).
|
||||
- `rquickjs` carries pre-generated bindings for every target this project builds except the Android ones, so Android
|
||||
builds enable its `bindgen` feature. That needs the NDK's own libclang and sysroot: `native_toolchain_rust` exports
|
||||
the sysroot through `BINDGEN_EXTRA_CLANG_ARGS_<target>`, and `hook/build.dart` adds `LIBCLANG_PATH` from the NDK
|
||||
toolchain Flutter hands the hook, because bindgen otherwise loads whatever libclang the host has, or none.
|
||||
- Evaluation is bounded: a 10-second interrupt deadline and a memory ceiling, because a script that never returns would
|
||||
otherwise hold the profile forever. `console` is installed before the script runs, since scripts written for other
|
||||
clients log as they work.
|
||||
- `rust/tests/fixtures/profile_script.js` is the compatibility regression: an overwrite written for the suite that
|
||||
performs the transform real ones perform, so it exercises `Map`/`Set`, spread, destructuring, optional chaining,
|
||||
nullish coalescing, `Object.fromEntries`, named capture groups and lookbehind in one pass. Keep it first-party and
|
||||
free of external URLs — vendoring somebody's published script here carries their attribution and their links.
|
||||
|
||||
## Rust Helper Service
|
||||
|
||||
`services/helper/` is the privileged helper that starts the core elevated so TUN works. It ships on Windows and Linux
|
||||
and is built by the setup build hook alongside the Core, which 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.
|
||||
|
||||
Linux takes the same shape with systemd in place of the Service Control Manager, and the same install timing: nothing
|
||||
is registered at package install, and `Linux.registerService` asks for elevation only when TUN authorization needs it.
|
||||
|
||||
- `FlClashHelperService install`, run through `pkexec` so polkit raises the system prompt, writes
|
||||
`/etc/systemd/system/flclash-helper.service` for the current executable path and enables and restarts it. It reads
|
||||
`PKEXEC_UID`/`SUDO_UID` to learn who asked, and refuses to install without one — there would be no account to grant
|
||||
the socket to. It also refuses a Helper whose binary or directory is not root-owned and non-writable (a unit runs it
|
||||
as root at every boot, so an unpacked bundle would be a standing escalation), and refuses to replace a unit already
|
||||
installed for a different UID rather than restart the service out from under that account.
|
||||
- That ownership check is why the `flutter_distributor` fork normalizes the packaging tree to 0755/0644 before
|
||||
`dpkg-deb`, `rpmbuild` and `appimagetool` run: they record modes verbatim, and Ubuntu's per-user default umask
|
||||
of 002 would otherwise ship `/opt/FlClash` as 0775, which the installer rejects as group-writable.
|
||||
- The rpm spec sets `debug_package` and `__os_install_post` to nil for the same reason: rpmbuild's find-debuginfo and
|
||||
brp-strip rewrite `FlClashCore`, and a Core whose SHA256 no longer matches the Helper's embedded value is refused at
|
||||
`/start`, which silently degrades every launch to the direct Core.
|
||||
- `FlClashHelperService uninstall` disables the unit, removes it and reloads systemd.
|
||||
- The unit carries `Group=` (the owner's primary GID), `RuntimeDirectory=flclash`, the owner's UID/GID in
|
||||
`FLCLASH_HELPER_OWNER_UID`/`_GID`, a double-quoted `ExecStart=` with `%` escaped, and `Restart=on-failure` under a
|
||||
start limit so a broken unit ends up failed instead of restarting forever. The helper serves
|
||||
`/run/flclash/helper.sock` at mode `0660`, additionally drops any connection whose `SO_PEERCRED` UID is not the
|
||||
owner's, logs and retries an `accept` failure instead of letting hyper end the server, and handles SIGTERM so
|
||||
`systemctl stop` still runs its own Core teardown.
|
||||
- `/start` additionally requires the Core address to be a socket owned by the owner UID before spawning, since the
|
||||
Core connects to it as root.
|
||||
- The Core is spawned with the owner's real UID and an effective UID of 0, which is what a setuid Core would have had.
|
||||
It is the signal `core/ownership_unix.go` uses to hand the files it created back to the user; without it a root
|
||||
service would leave a root-owned config tree in the owner's home.
|
||||
- An AppImage has neither a stable executable path nor a writable Core, and its FUSE mount is `nosuid`, so
|
||||
`system.isAppImage` reports TUN authorization as unavailable instead of prompting for a password that cannot help.
|
||||
- A Linux host without systemd (`/run/systemd/system` absent) has no Helper: `system.hasHelperService` is false there,
|
||||
readiness is the `stat` check, and `pkexec` sets the setuid bit on the bundled Core as before.
|
||||
|
||||
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.
|
||||
|
||||
Endpoints bind only to `127.0.0.1:47890` on Windows and to `/run/flclash/helper.sock` on Linux, and do not use
|
||||
request-token authentication. Lifecycle safety comes from the fixed executable/hash, the strict address namespace
|
||||
(`\\.\pipe\FlClashCore_<32 hex>` on Windows, `/tmp/FlClashSocket_<digits>.sock` on Linux), the session-scoped stop
|
||||
contract, Dart-side peer-PID verification on Windows and, on Unix, the Core socket that `plugins/rust_api` sets to
|
||||
mode `0600` so only the owning user (and the root-effective Core) can connect. When the Helper service itself shuts
|
||||
down, it unconditionally stops the Core process it owns; under systemd the unit's control group does the same.
|
||||
@@ -0,0 +1,313 @@
|
||||
# Commands
|
||||
|
||||
## Building
|
||||
|
||||
Update submodules first. The ClashMeta Go core lives in `core/Clash.Meta/`.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Full package build, including Go core, Flutter, and packaging, runs through `setup.dart`:
|
||||
|
||||
```bash
|
||||
dart setup.dart macos
|
||||
dart setup.dart linux
|
||||
dart setup.dart windows
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
The Go core and the Rust helper build automatically: Flutter runs
|
||||
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
|
||||
and the hook drives `CoreBuilder` from the `setup_hooks` package in
|
||||
`plugins/setup/setup_hooks/`. Android builds take the NDK from the C compiler
|
||||
Flutter hands the hook; no `ANDROID_NDK` variable is needed.
|
||||
Artifacts land in `libclash/`. The hook reruns only when Go, Rust, or
|
||||
`setup_hooks` inputs change; the fingerprint cache lives in
|
||||
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
|
||||
|
||||
```bash
|
||||
rm -rf .dart_tool/setup_build_cache
|
||||
```
|
||||
|
||||
Flutter hides the hook's output on success, so to see why the Core was or was
|
||||
not rebuilt read `.dart_tool/setup_build_cache/hook.log`; each invocation starts
|
||||
with a `===` line carrying its timestamp and target.
|
||||
|
||||
The hook runs for `flutter test` and for `dart run` of any root-package script
|
||||
too, so a pure Dart test run needs the Go toolchain, and on Linux and Windows
|
||||
also `cargo` and `rustc` — the Helper fingerprint shells out to both even on a
|
||||
cache hit. The hook protocol carries no build mode and no caller, so the only
|
||||
switch is the user-define in the root `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
user_defines:
|
||||
setup:
|
||||
build_assets: true # false turns the Go core and Helper hook into a no-op
|
||||
rust_api:
|
||||
build_assets: true # false does the same for the Rust library
|
||||
code_forge:
|
||||
build_assets: true # and for the code editor's Rust library
|
||||
```
|
||||
|
||||
CI flips all three to `false` with `yq` before `dart run tool/changelog.dart` and
|
||||
`flutter test`, and restores the file afterwards; no test loads any of the libraries.
|
||||
Never commit `false`: a build with it set stages whatever `libclash/` already
|
||||
holds and bundles no Rust library, which is why `setup.dart` refuses to package
|
||||
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
|
||||
but the hook cache keys on the user-define, so the first build afterwards runs
|
||||
the hooks again.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
Use the default Flutter SDK directly:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
flutter test
|
||||
```
|
||||
|
||||
Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
|
||||
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
|
||||
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
|
||||
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
|
||||
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
|
||||
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
|
||||
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
|
||||
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
|
||||
```
|
||||
|
||||
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ. The build is bannered `SAFE MODE`, the tray shows the
|
||||
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
|
||||
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
|
||||
|
||||
## Code Generation
|
||||
|
||||
Run code generation after modifying models, providers, or database schema:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
dart run build_runner watch
|
||||
```
|
||||
|
||||
Code generation covers:
|
||||
|
||||
- Riverpod providers through `riverpod_generator`.
|
||||
- Models through `freezed` and `json_serializable`.
|
||||
- Database tables through `drift_dev`.
|
||||
|
||||
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
|
||||
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
|
||||
`rust/src/api/`:
|
||||
|
||||
```bash
|
||||
cd plugins/rust_api && flutter_rust_bridge_codegen generate
|
||||
```
|
||||
|
||||
Generated output paths, configured in `build.yaml`:
|
||||
|
||||
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
|
||||
- `lib/providers/generated/*.g.dart`.
|
||||
- `lib/database/generated/*.g.dart`.
|
||||
|
||||
Tray and Windows app icons are generated, not hand-edited. `assets_source/images/icon/*.svg` is
|
||||
the source of truth; the script needs `rsvg-convert` (librsvg) on `PATH`:
|
||||
|
||||
```bash
|
||||
dart run tool/generate_status_icons.dart
|
||||
```
|
||||
|
||||
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
|
||||
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
|
||||
from `assets/images/icon.png`. `pubspec.yaml` declares the two tray directories with `platforms:` so each
|
||||
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
|
||||
script's `statusIconNames`, nothing in `pubspec.yaml`.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
|
||||
|
||||
```bash
|
||||
flutter test test/models/
|
||||
flutter test test/core/
|
||||
flutter test test/core/desktop/
|
||||
flutter test test/providers/
|
||||
flutter test test/common/
|
||||
flutter test test/database/
|
||||
flutter test test/widgets/
|
||||
flutter test test/setup_test.dart
|
||||
flutter test plugins/proxy/test/proxy_test.dart
|
||||
```
|
||||
|
||||
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
|
||||
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
|
||||
run of `plugins/setup` would execute the build hook itself.
|
||||
|
||||
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
|
||||
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
|
||||
|
||||
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
|
||||
|
||||
For the current Core/service architecture, useful focused checks are:
|
||||
|
||||
```bash
|
||||
flutter test test/core/desktop/
|
||||
flutter test test/core/service_test.dart
|
||||
flutter test test/core/protocol_contract_test.dart
|
||||
flutter test test/manager/core_manager_test.dart
|
||||
flutter test test/providers/action_test.dart test/providers/system_action_test.dart
|
||||
flutter test test/widgets/core_status_button_test.dart
|
||||
```
|
||||
|
||||
What those suites own:
|
||||
|
||||
- `test/core/desktop/`: replaceable IPC transport, RPC request correlation/failure, direct/Helper process leases, and
|
||||
latest-intent desktop lifecycle convergence.
|
||||
- `test/core/service_test.dart`: `CoreService` composition and terminal close behavior.
|
||||
- `test/core/protocol_contract_test.dart`: shared Dart/Go method and event-envelope compatibility, including event batches.
|
||||
- `test/providers/action_test.dart`: Core start/restart orchestration and overlapping restart requests.
|
||||
- `test/providers/system_action_test.dart`: ordered, idempotent exit cleanup and watchdog behavior.
|
||||
- `test/widgets/core_status_button_test.dart`: 600-millisecond connecting presentation hold, immediate failure display,
|
||||
long-running connecting state, and disconnected restart.
|
||||
|
||||
## Native Component Verification
|
||||
|
||||
The CI Go-wrapper checks can be reproduced without CGO:
|
||||
|
||||
```bash
|
||||
cd core
|
||||
CGO_ENABLED=0 go test .
|
||||
CGO_ENABLED=0 go vet .
|
||||
```
|
||||
|
||||
The Windows Helper's loopback/session protocol tests are host-independent by default. Windows CI additionally enables its
|
||||
service implementation:
|
||||
|
||||
```bash
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
```
|
||||
|
||||
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
|
||||
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
|
||||
registered with `java_home`, so export it by prefix:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
|
||||
./gradlew :service:compileDebugKotlin
|
||||
./gradlew :app:compileDebugKotlin
|
||||
```
|
||||
|
||||
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
|
||||
emulator validation; Kotlin compilation cannot prove those system callbacks.
|
||||
|
||||
## Changelog And Release
|
||||
|
||||
The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files:
|
||||
`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers
|
||||
that decide the wording.
|
||||
|
||||
The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment
|
||||
(`<!-- flclash:changelog:json … -->`), so the release body GitHub already returns to `checkForUpdate` carries the
|
||||
notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English
|
||||
bullets when a release predates it.
|
||||
|
||||
```bash
|
||||
dart run tool/changelog.dart verify # what CI checks
|
||||
dart run tool/changelog.dart release --version 0.8.96
|
||||
dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work
|
||||
dart run tool/changelog.dart render release --out release.md
|
||||
dart run tool/changelog.dart render telegram --out telegram.md
|
||||
```
|
||||
|
||||
Releasing a stable version, in order:
|
||||
|
||||
```bash
|
||||
tool/bump_version.sh all
|
||||
dart run tool/changelog.dart release --version 0.8.96
|
||||
git commit -am "chore(release): v0.8.96"
|
||||
git tag v0.8.96
|
||||
git push origin main && git push origin v0.8.96
|
||||
```
|
||||
|
||||
Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a
|
||||
lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires.
|
||||
|
||||
`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping
|
||||
the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with
|
||||
`--push`:
|
||||
|
||||
```bash
|
||||
tool/release.sh pre --dry-run # plan and notes, changes nothing
|
||||
tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push
|
||||
tool/release.sh stable --push # changelog, chore(release) commit, tag, push
|
||||
```
|
||||
|
||||
The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and
|
||||
the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in
|
||||
`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit
|
||||
inside that version's range.
|
||||
|
||||
Entries at or below `v0.8.96` are frozen: they predate the pipeline, live under the `<!-- changelog:frozen -->` marker
|
||||
in `CHANGELOG.md`, and are never regenerated.
|
||||
|
||||
`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks
|
||||
(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as
|
||||
skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such
|
||||
branch fail on an unrelated release.
|
||||
|
||||
Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the
|
||||
Telegram post. They publish no GitHub release, so the update dialog never sees them. `build --unreleased` reads the
|
||||
version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle:
|
||||
while `v<pubspec version>` is still tagged it refuses to collect anything and the release job fails.
|
||||
|
||||
## Verify
|
||||
|
||||
In the public repository every branch push runs the `dart` job; in the private
|
||||
`origin` every check job runs only when started by hand (`workflow_dispatch`).
|
||||
The `dart` job performs these root-package checks in order:
|
||||
|
||||
```bash
|
||||
bash tool/check_commit_msg_test.sh
|
||||
bash tool/check_comment_density_test.sh
|
||||
flutter pub get
|
||||
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
|
||||
flutter analyze --no-fatal-infos
|
||||
dart run tool/changelog.dart verify # main and tags only
|
||||
flutter test --reporter expanded --coverage
|
||||
dart run tool/check_coverage.dart coverage/lcov.info 75
|
||||
```
|
||||
|
||||
Run `flutter analyze` before committing; CI fails on anything it reports.
|
||||
|
||||
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
|
||||
Root analysis excludes `plugins/**`, and root tests do not discover nested
|
||||
plugin packages, so parallel jobs validate the rest from their own package
|
||||
directories: `plugins` (local Flutter packages and the setup build tool), `go`
|
||||
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
|
||||
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
|
||||
tasks excluded so no native build hook runs), `rust` (both crates), and a
|
||||
Windows runner for the helper's `windows-service` feature. A `desktop` job also
|
||||
compiles a debug build per desktop platform. Release builds run only in the
|
||||
public repository and start once every job except `desktop` passes.
|
||||
|
||||
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
|
||||
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
|
||||
`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs
|
||||
no workflow edit; enumerating packages by hand in the workflow is what
|
||||
previously left `plugins/tray` unanalyzed and untested.
|
||||
|
||||
## Worktree Tooling
|
||||
|
||||
```bash
|
||||
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
|
||||
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Project Context
|
||||
|
||||
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using a Material You design with Surfboard-like UI.
|
||||
|
||||
## Version Notes
|
||||
|
||||
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
|
||||
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
|
||||
patch. `code_forge` 10.13 and later need Dart 3.13.2, so the CI pin cannot go
|
||||
below Flutter 3.47.2.
|
||||
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
|
||||
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
|
||||
proven by `flutter test`, which parses the generated code, not by analyze.
|
||||
|
||||
## Forked Dependencies
|
||||
|
||||
Three `pubspec.yaml` dependencies are pinned to a fork, two by commit SHA and
|
||||
`code_forge` by tag. All three forks live under `chen08209`, the same account
|
||||
that owns this repository, so they are maintained in-house rather than tracked
|
||||
from a third party: advancing a pin is a local decision, and there is no external
|
||||
maintainer to wait on for the patch itself. What each fork still waits on is the
|
||||
*upstream* fix that would let the pin be dropped entirely, recorded below.
|
||||
|
||||
Each entry records what the fork changes and what has to be true before it can go
|
||||
back to the published package, so a future upgrade does not have to rediscover it.
|
||||
Re-verify a fork by diffing its pub cache checkout against the published version
|
||||
of the same number:
|
||||
|
||||
```bash
|
||||
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
|
||||
```
|
||||
|
||||
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
|
||||
was replaced by the in-repo `plugins/window` package; the behaviors it carried
|
||||
(work-area maximize with a hidden title bar on Windows, geometry restore on
|
||||
re-map on Linux, corner preference, terminate and activate events) live there
|
||||
now, see the window plugin section of `architecture.md`.
|
||||
|
||||
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
|
||||
|
||||
- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename
|
||||
across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`,
|
||||
`createValue` → `setValue`, `getStringValue` → `getString`).
|
||||
- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3`
|
||||
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
|
||||
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
|
||||
|
||||
`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0.
|
||||
|
||||
- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`.
|
||||
Upstream quotes values but emits keys verbatim, so a profile key needing quotes
|
||||
is written as invalid YAML.
|
||||
- Drop the fork once upstream quotes map keys by the same
|
||||
`isValidUnquotedString` rule it already applies to values.
|
||||
|
||||
`code_forge` — `chen08209/code_forge`, tag `v10.14.0-flclash.4`, version 10.14.0.
|
||||
A new pin is a new `v<upstream version>-flclash.N` tag on the fork's `main`.
|
||||
|
||||
- Builds the Rust crate through a `hook/build.dart` Native Assets hook, the same
|
||||
`flutter_rust_bridge_hooks` path as `plugins/rust_api`, instead of the cargokit
|
||||
FFI plugin. The platform folders and `cargokit/` are gone, so macOS needs no
|
||||
CocoaPods fallback beside Swift Package Manager, and `rust/rust-toolchain.toml`
|
||||
pins the same channel and targets as `rust_api`.
|
||||
- The hook honours `hooks.user_defines.code_forge.build_assets`, and
|
||||
`flutter_rust_bridge` is pinned to the exact version `rust_api` generates with,
|
||||
because a codegen and runtime mismatch throws in `RustLib.init()`.
|
||||
- `CodeForgeKeyboardShortcuts.forPlatform` gives Cmd/Option bindings on Apple
|
||||
platforms, and `onContextMenu` and `scrollbarBuilder` let `lib/pages/editor.dart`
|
||||
show `CommonPopupMenu` and `FloatingScrollbar` instead of the built-in menu and
|
||||
scrollbar.
|
||||
- Also fixes the caret drawn while read-only, word-left movement from the end of
|
||||
a word, the `attached` assertion when a tap arrives after the input method
|
||||
connection closed, and a caret left under the software keyboard when a tap
|
||||
opens it.
|
||||
- Each change is its own commit, so one upstream adopts can be dropped on its
|
||||
own. The fork goes away only once upstream builds through Native Assets and has
|
||||
the hooks above.
|
||||
|
||||
## Dependency Ceilings
|
||||
|
||||
`flutter pub outdated` lists only upstream-held entries, so treat them as
|
||||
resolved-until-the-ceiling-moves rather than as debt:
|
||||
|
||||
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
|
||||
resolved by the Flutter SDK; neither is a bound this repository sets.
|
||||
|
||||
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
|
||||
`test` all resolve against the analyzer the pinned SDK ships; none of them is
|
||||
held back. When an upgrade fails version solving, record the blocker here with
|
||||
what has to change upstream before the pin can move, so the next
|
||||
`flutter pub outdated` run does not rediscover it.
|
||||
|
||||
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
|
||||
channel directly and decodes the palette list into a `TonalPalette`, instead of
|
||||
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
|
||||
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
|
||||
non-deprecated plugin accessor changes only that one function.
|
||||
|
||||
## Build Dependencies
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
- GCC and Inno Setup.
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
npm install -g appdmg
|
||||
```
|
||||
@@ -0,0 +1,524 @@
|
||||
# Rules
|
||||
|
||||
These are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.
|
||||
|
||||
## Dart and Flutter Style
|
||||
|
||||
The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under
|
||||
`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an
|
||||
individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.
|
||||
|
||||
`lint_options.yaml` enforces these non-default rules:
|
||||
|
||||
- `prefer_single_quotes: true`: always use single quotes.
|
||||
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
|
||||
- `sort_child_properties_last: true`: `child:` must be the last named parameter.
|
||||
- `avoid_print: true`: do not use `print()` calls.
|
||||
- `prefer_const_constructors: true` and `prefer_const_declarations: true`.
|
||||
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
|
||||
- `always_declare_return_types: true`.
|
||||
- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.
|
||||
|
||||
Failures whose whole content is a message meant for the user throw
|
||||
`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare
|
||||
message, which is what `globalState.safeRun` surfaces in the dialog, so the
|
||||
user-facing text is unchanged from the older `throw someMessage` idiom while the
|
||||
throw stays catchable as an `Exception` and carries a stack trace. Assert on it
|
||||
with `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a
|
||||
raw string.
|
||||
|
||||
### Corner Radius
|
||||
|
||||
Rounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers
|
||||
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use
|
||||
the superellipse API at each layer:
|
||||
|
||||
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
|
||||
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
|
||||
- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of
|
||||
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
|
||||
`clipBehavior` still clips to the shape path.
|
||||
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
|
||||
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
|
||||
|
||||
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
|
||||
carries the corner magnitude; the rendered geometry stays a superellipse.
|
||||
|
||||
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
|
||||
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
|
||||
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
|
||||
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
|
||||
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
|
||||
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
|
||||
|
||||
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
|
||||
with an enclosing clip or shape where it shows.
|
||||
|
||||
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
|
||||
tool plugins setup.dart` runs before `flutter analyze`.
|
||||
|
||||
These directories are excluded from analysis:
|
||||
|
||||
- `build/**`
|
||||
- `lib/l10n/intl/**`
|
||||
- `lib/**/generated/**`
|
||||
- `plugins/**`
|
||||
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are part of the code and some of them are load-bearing. What this repository limits is the useless and the
|
||||
frequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all
|
||||
of them — a file with three comments that matter is more readable than one with thirty.
|
||||
|
||||
The two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a
|
||||
judgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and
|
||||
`comment-density` measures it.
|
||||
|
||||
### What a Comment Must Earn
|
||||
|
||||
- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being
|
||||
worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.
|
||||
- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is
|
||||
noise.
|
||||
- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version
|
||||
history belongs in git and change rationale belongs in the commit message.
|
||||
- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better
|
||||
names or a smaller decomposition instead — reach for structure before prose.
|
||||
- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to
|
||||
break beats a paragraph asking the next reader not to break it.
|
||||
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
|
||||
that contains them. This does not need approval.
|
||||
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
|
||||
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
|
||||
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
|
||||
|
||||
### Density
|
||||
|
||||
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
|
||||
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
|
||||
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
|
||||
|
||||
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
|
||||
it is forked upstream code, not a house style.
|
||||
|
||||
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
|
||||
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
|
||||
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
|
||||
Behavior is pinned by `tool/check_comment_density_test.sh`, which CI runs directly.
|
||||
|
||||
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
|
||||
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
|
||||
|
||||
### Where Knowledge Belongs
|
||||
|
||||
A comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by
|
||||
how important it feels.
|
||||
|
||||
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
|
||||
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
|
||||
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
|
||||
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
|
||||
that.
|
||||
- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does
|
||||
better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.
|
||||
`lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in
|
||||
`core/common.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
|
||||
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
|
||||
|
||||
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
|
||||
leaving a repo-wide policy as a comment reaches only the reader of that one file.
|
||||
|
||||
## Core API Safety
|
||||
|
||||
- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is
|
||||
low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream
|
||||
rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into
|
||||
the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches
|
||||
themselves introduced — and say which of the two it is in the commit message.
|
||||
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
|
||||
a scope-specific cleanup API instead.
|
||||
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
|
||||
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
|
||||
A launch mode that runs the Core as any other user has to widen `authorize_peer` in `ipc/platform.rs`, and Windows
|
||||
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
|
||||
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
|
||||
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
|
||||
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
|
||||
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
|
||||
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
|
||||
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
|
||||
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
|
||||
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
|
||||
and reporting it from the message layer feeds the same batcher.
|
||||
- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through
|
||||
`free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI
|
||||
wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the
|
||||
C side, turns that read into a use-after-free.
|
||||
- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches
|
||||
from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound
|
||||
socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time.
|
||||
- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the
|
||||
process on the next JNI call on that thread, so a throw in `protect`/`resolveUid`/`resolvePackage`/`onResult` becomes a
|
||||
crash in unrelated code. For the same reason every one of those wrappers checks its `tun_interface`/`invoke_interface`
|
||||
for `nullptr` first: ART aborts on a call through a null object, and a callback released by `TunHandler.clear` while a
|
||||
connection is still resolving is exactly that.
|
||||
- The Android bridge resolves an owner in two steps — `resolve_uid` then `resolve_package` — because mihomo fills
|
||||
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
|
||||
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
|
||||
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
|
||||
the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use
|
||||
`logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.
|
||||
A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above
|
||||
`maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it
|
||||
down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is
|
||||
resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,
|
||||
so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`
|
||||
rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and
|
||||
`send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind
|
||||
the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state
|
||||
queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing
|
||||
on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands
|
||||
the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state
|
||||
minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case
|
||||
`send` closes on.
|
||||
- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by
|
||||
`safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every
|
||||
recovery and kills the process, which on Android is the whole application. The `//export` entry points in
|
||||
`core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery.
|
||||
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
|
||||
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
|
||||
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
|
||||
value. Stopping the TUN swaps `activeTunHandler` instead.
|
||||
- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by
|
||||
`invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild
|
||||
costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the
|
||||
proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without
|
||||
going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.
|
||||
- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and
|
||||
rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only
|
||||
when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so
|
||||
an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success
|
||||
against nothing.
|
||||
- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need
|
||||
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
|
||||
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
|
||||
`configMu`, fixing the order as `configMu` → `selectMu`.
|
||||
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
|
||||
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
|
||||
test of a large subscription reported whatever sat at the back of the queue as unreachable.
|
||||
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
|
||||
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
|
||||
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
|
||||
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
|
||||
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
|
||||
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
|
||||
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
|
||||
reply arriving; core status leaving `connected` cancels every run in flight.
|
||||
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
|
||||
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
|
||||
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
|
||||
claims per resource and `updater.GeoUpdateHook` releases.
|
||||
- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:
|
||||
`handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners
|
||||
takes the app offline over a profile the user can still switch away from, and the error already reaches the host,
|
||||
which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep
|
||||
an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig`
|
||||
resolves it to the defaults.
|
||||
- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application
|
||||
down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when
|
||||
it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.
|
||||
- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `go` job. Keep the build
|
||||
constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/`
|
||||
on any developer machine, because the files it pulls in need the NDK.
|
||||
|
||||
## Lifecycle Rules
|
||||
|
||||
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
|
||||
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
|
||||
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
|
||||
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
|
||||
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
|
||||
`handledExitAt` so the same exit cannot be counted twice.
|
||||
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
|
||||
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
|
||||
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
|
||||
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
|
||||
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
|
||||
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
|
||||
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
|
||||
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
|
||||
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
|
||||
Firebase uninitialized until the user consents.
|
||||
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
|
||||
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
|
||||
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
|
||||
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
|
||||
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
|
||||
stay centralized in `SystemAction`/`SystemExitCoordinator`.
|
||||
- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native
|
||||
`ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner.
|
||||
- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke
|
||||
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
|
||||
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
|
||||
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
|
||||
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
|
||||
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
|
||||
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
|
||||
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
|
||||
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
|
||||
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
|
||||
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
|
||||
container exists; everything else reads `safeModeProvider` so a test can override it.
|
||||
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
|
||||
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
|
||||
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
|
||||
means `WM_DESTROY` never runs.
|
||||
- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code
|
||||
declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around
|
||||
ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title
|
||||
and group submenus); query `Tray.instance.capabilities` for ability differences.
|
||||
- Every native `show` returns whether the tray now reflects the payload, and reports `false` instead of showing a broken
|
||||
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
|
||||
than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`.
|
||||
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
|
||||
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
|
||||
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
|
||||
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
|
||||
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
|
||||
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
|
||||
plugin.
|
||||
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
|
||||
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
|
||||
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
|
||||
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
|
||||
hop that the plugin API does not provide.
|
||||
- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable
|
||||
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the
|
||||
resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly
|
||||
costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
|
||||
state that has to stay in sync with the map.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
|
||||
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
|
||||
plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language
|
||||
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
|
||||
|
||||
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
|
||||
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
|
||||
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
|
||||
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
|
||||
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
|
||||
it in an unrelated test.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
|
||||
group's floor when new tests lift it; do not lower one to make a run pass.
|
||||
|
||||
Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so
|
||||
adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below
|
||||
the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can
|
||||
land.
|
||||
|
||||
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
|
||||
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
|
||||
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
|
||||
the singleton calls `CoreController.resetInstance()` in `tearDown`.
|
||||
|
||||
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
|
||||
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
|
||||
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
|
||||
|
||||
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
|
||||
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
|
||||
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
|
||||
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
|
||||
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
|
||||
at all; `test/common/launch_test.dart` pins the early return instead.
|
||||
|
||||
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
|
||||
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
|
||||
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
|
||||
awaiting one needs `tester.runAsync(...)` between the pumps.
|
||||
|
||||
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
|
||||
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
|
||||
setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that
|
||||
does open a file-backed database owns closing it.
|
||||
|
||||
`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike
|
||||
`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI
|
||||
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
|
||||
and leave headroom under a group floor that covers such a branch.
|
||||
|
||||
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
|
||||
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
|
||||
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
|
||||
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
|
||||
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
|
||||
|
||||
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
|
||||
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
|
||||
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
|
||||
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
|
||||
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
|
||||
width, not just its height — a height-only assertion cannot see this.
|
||||
|
||||
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
|
||||
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
|
||||
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
|
||||
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
|
||||
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
|
||||
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
|
||||
answers `isMaximized`/`isAlwaysOnTop` with a bool.
|
||||
|
||||
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
|
||||
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
|
||||
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
|
||||
`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also
|
||||
re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails
|
||||
the test on a pending timer.
|
||||
|
||||
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
|
||||
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
|
||||
`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees
|
||||
sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in
|
||||
that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller
|
||||
and are out of scope.
|
||||
|
||||
An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and
|
||||
VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces
|
||||
it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and
|
||||
`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails
|
||||
if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button
|
||||
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
|
||||
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
|
||||
|
||||
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
|
||||
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
|
||||
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
|
||||
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
|
||||
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
|
||||
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
|
||||
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
|
||||
|
||||
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
|
||||
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
|
||||
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
|
||||
`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a
|
||||
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
|
||||
typedefs are skipped: those are reached through the types they attach to, never by name.
|
||||
|
||||
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
|
||||
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
|
||||
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
|
||||
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
|
||||
invites the bug.
|
||||
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
|
||||
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
|
||||
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
|
||||
takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
```
|
||||
|
||||
When testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.
|
||||
|
||||
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
|
||||
failure, disposal, and any timer boundary that changes visible state.
|
||||
|
||||
## Running the App Locally
|
||||
|
||||
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
|
||||
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
|
||||
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
|
||||
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
|
||||
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
|
||||
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
|
||||
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
|
||||
which builds are kept apart.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
|
||||
`tool/check_commit_msg.sh`:
|
||||
|
||||
```text
|
||||
<type>[(scope)][!]: <description>
|
||||
```
|
||||
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
|
||||
- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.
|
||||
- `!` before the colon marks a breaking change.
|
||||
- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.
|
||||
Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.
|
||||
- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.
|
||||
- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history
|
||||
records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the
|
||||
known agent identities.
|
||||
|
||||
Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not
|
||||
`Optimize more details`.
|
||||
|
||||
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
|
||||
|
||||
### Changelog Trailers
|
||||
|
||||
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
|
||||
ships to users. The subject stays the developer facing summary and is only the fallback.
|
||||
|
||||
```text
|
||||
feat(profiles): support per-profile override script
|
||||
|
||||
Changelog: Per-profile override scripts
|
||||
```
|
||||
|
||||
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
|
||||
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
|
||||
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
|
||||
commit message.
|
||||
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
|
||||
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
|
||||
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
|
||||
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
|
||||
|
||||
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
|
||||
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
|
||||
says so without blocking.
|
||||
|
||||
## Generated Code
|
||||
|
||||
Do not manually edit generated files under:
|
||||
|
||||
- `lib/l10n/l10n.dart`
|
||||
- `lib/models/generated/`
|
||||
- `lib/providers/generated/`
|
||||
- `lib/database/generated/`
|
||||
- `lib/l10n/intl/`
|
||||
|
||||
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
|
||||
|
||||
`lib/l10n/l10n.dart` is the one file that still imports `package:flutter/material.dart`.
|
||||
`intl_utils` hardcodes that import in its own template, so regenerating rewrites it and
|
||||
there is nothing to fix here; `test/lint/design_package_test.dart` exempts the generated
|
||||
l10n paths for that reason. It is harmless because the file only needs `Locale`,
|
||||
`BuildContext`, `Localizations` and `LocalizationsDelegate`, which the legacy library and
|
||||
`material_ui` both re-export from the same `package:flutter/widgets.dart`. Everything a
|
||||
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
|
||||
survives only as a transitive dependency of `material_ui`.
|
||||
|
||||
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
|
||||
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
|
||||
English at runtime, so add the translation rather than leaving it out.
|
||||
|
||||
Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message(<runtime string>)`
|
||||
builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`,
|
||||
`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships
|
||||
as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families
|
||||
from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be
|
||||
registered there, so a new dynamic key cannot be added without also declaring what builds it.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Agent Skills
|
||||
|
||||
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
|
||||
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
|
||||
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
|
||||
|
||||
## Available Repo Skills
|
||||
|
||||
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
|
||||
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
|
||||
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
|
||||
VPN/TUN, and Windows Helper flow.
|
||||
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
|
||||
|
||||
## Authoring Notes
|
||||
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
|
||||
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
|
||||
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
|
||||
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
|
||||
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
|
||||
- Keep skill descriptions trigger-focused and start them with `Use when...`.
|
||||
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
|
||||
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: core-platform
|
||||
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
|
||||
---
|
||||
|
||||
# Core And Platform
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
|
||||
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
|
||||
## Read First
|
||||
|
||||
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
|
||||
JSON envelope and `core/message.go` queue rules, JNI ownership, `BootGuard`, tray, and SSID rules, and every
|
||||
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
|
||||
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
|
||||
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
|
||||
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
|
||||
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
|
||||
|
||||
## Owners
|
||||
|
||||
Every behavior here has one authoritative owner, and a change lands there:
|
||||
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
|
||||
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
|
||||
|
||||
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
|
||||
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
|
||||
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
|
||||
|
||||
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
|
||||
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
|
||||
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
|
||||
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
|
||||
owns.
|
||||
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
|
||||
modes, so a debug build is not exempt.
|
||||
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
|
||||
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: localization
|
||||
description: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.
|
||||
---
|
||||
|
||||
# Localization
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
|
||||
|
||||
Do not use this for README translation sync or manual edits to generated localization Dart.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
|
||||
|
||||
```bash
|
||||
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
|
||||
```
|
||||
|
||||
2. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
3. Add or update every source ARB:
|
||||
- `arb/intl_en.arb`
|
||||
- `arb/intl_zh_CN.arb`
|
||||
- `arb/intl_ja.arb`
|
||||
- `arb/intl_ru.arb`
|
||||
4. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
|
||||
5. Regenerate:
|
||||
|
||||
```bash
|
||||
dart run intl_utils:generate
|
||||
```
|
||||
|
||||
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
|
||||
lint test covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
|
||||
see; the ARB section of `.agents/rules.md` explains them.
|
||||
7. Re-run the Han-text scan for targeted strings.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
|
||||
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: pre-commit-quality-review
|
||||
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
|
||||
---
|
||||
|
||||
# Pre-commit Quality Review
|
||||
|
||||
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
|
||||
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
|
||||
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
|
||||
not consent.
|
||||
|
||||
## Triage
|
||||
|
||||
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
|
||||
repository-wide scan.
|
||||
|
||||
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
|
||||
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
|
||||
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
|
||||
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
|
||||
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
|
||||
- An explicit structural review request bypasses triage.
|
||||
|
||||
The snapshot under review is the intended commit compared with its baseline:
|
||||
|
||||
| Operation | Baseline |
|
||||
|---|---|
|
||||
| Regular commit | `HEAD` |
|
||||
| Amend | Parent of the commit being replaced |
|
||||
| Squash | Version preceding the agreed range |
|
||||
| Initial commit | Empty tree |
|
||||
|
||||
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
|
||||
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
|
||||
staging.
|
||||
|
||||
## Review the Final Structure
|
||||
|
||||
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
|
||||
affected units and compare before and after, not only the last patch. Report issues this change introduced or
|
||||
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
|
||||
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
|
||||
with implementation detail; responsibilities in layers that should not own them.
|
||||
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
|
||||
nothing; similar names or interfaces hiding different responsibilities.
|
||||
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
|
||||
dependent helpers; names and decomposition that do not reduce reading effort.
|
||||
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
|
||||
scattered ownership of error handling, cleanup, or state updates.
|
||||
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
|
||||
invalidated; coupling that makes existing test boundaries hard to use.
|
||||
|
||||
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
|
||||
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
|
||||
or demand abstractions for speculative needs.
|
||||
|
||||
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
|
||||
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
|
||||
widening this review.
|
||||
|
||||
## Findings and the User's Decision
|
||||
|
||||
A finding qualifies only when all three hold:
|
||||
|
||||
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
|
||||
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
|
||||
responsibility. Clarity or elegance alone is not enough.
|
||||
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
|
||||
|
||||
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
|
||||
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
|
||||
improvements into a suggestion list.
|
||||
|
||||
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
|
||||
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
|
||||
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
|
||||
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
|
||||
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
|
||||
duplicate state-update paths. Should I make that change before committing?"
|
||||
|
||||
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
|
||||
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
|
||||
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
|
||||
|
||||
## Resolve and Finish
|
||||
|
||||
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
|
||||
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
|
||||
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
|
||||
and exceeds the existing authorization.
|
||||
|
||||
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
|
||||
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
|
||||
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
|
||||
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
|
||||
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
|
||||
commit.
|
||||
|
||||
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
|
||||
Claim the commit is done only after it exists.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: ui-work
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions.
|
||||
---
|
||||
|
||||
# UI Work
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,
|
||||
dialogs, and interaction behavior.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
|
||||
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
|
||||
a layout the request does not need.
|
||||
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
|
||||
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
|
||||
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
|
||||
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
|
||||
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
|
||||
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
|
||||
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
|
||||
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
|
||||
fill animation, and when to fill.
|
||||
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
||||
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
||||
keep scrims from stacking.
|
||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
|
||||
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
|
||||
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
|
||||
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
|
||||
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
|
||||
|
||||
## Corner Radii
|
||||
|
||||
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
|
||||
builders. Never write a bare radius literal at a call site.
|
||||
|
||||
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 logical pixels tall
|
||||
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
|
||||
|
||||
| Token | Value | Shortest side | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `none` | 0 | - | square edges, and the flat side of a grouped run |
|
||||
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
|
||||
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
|
||||
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
|
||||
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
|
||||
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
|
||||
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
|
||||
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
|
||||
|
||||
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
|
||||
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
|
||||
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
|
||||
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
|
||||
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
|
||||
the size comes from a `LayoutBuilder` or scales with text.
|
||||
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
|
||||
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
|
||||
button themes. Do not restate those shapes at call sites, and
|
||||
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
||||
|
||||
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
|
||||
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
|
||||
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
|
||||
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
|
||||
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
|
||||
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
|
||||
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
|
||||
clears all three heights and the smallest dashboard tile.
|
||||
|
||||
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
|
||||
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
|
||||
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
|
||||
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
|
||||
|
||||
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
|
||||
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
|
||||
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
|
||||
|
||||
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
|
||||
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
|
||||
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
|
||||
|
||||
## Verification
|
||||
|
||||
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
|
||||
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
|
||||
tests of the touched view.
|
||||
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
|
||||
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
|
||||
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
|
||||
allowlist.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
|
||||
bypass them immediately.
|
||||
- Do not leave loading animations active when callbacks throw.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Worktrees
|
||||
|
||||
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
|
||||
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
|
||||
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/check_comment_density.sh\"",
|
||||
"timeout": 15,
|
||||
"statusMessage": "Checking comment density"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/core-platform
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/localization
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pre-commit-quality-review
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ui-work
|
||||
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,8 @@
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "apply_patch|Bash"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "bash tool/check_comment_density.sh"
|
||||
timeout = 15
|
||||
statusMessage = "Checking comment density"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
name: 问题反馈 / Bug report
|
||||
title: "[BUG] "
|
||||
description: 反馈你遇到的问题 / Report the issue you are experiencing
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 在提交问题之前,请确认以下事项:
|
||||
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
|
||||
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
|
||||
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
|
||||
## Before submitting the issue, please make sure of the following checklist:
|
||||
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
||||
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
|
||||
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述 / Describe the bug
|
||||
description: 详细清晰地描述你遇到的问题,并配合截图 / Describe the problem you encountered in detail and clearly, and provide screenshots
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 软件版本 / Version
|
||||
description: 请提供FlClash的具体版本 / Please provide the specific version of FlClash.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 复现步骤 / To Reproduce
|
||||
description: 请提供复现问题的步骤 / Steps to reproduce the behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 操作系统 / OS
|
||||
options:
|
||||
- Android
|
||||
- Windows
|
||||
- MacOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: 操作系统版本 / OS Version
|
||||
description: 请提供你的操作系统版本,Linux请额外提供桌面环境及窗口系统 / Please provide your OS version, for Linux, please also provide the desktop environment and window system
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly)
|
||||
description: 请提供完整或相关部分的Debug日志(请在“软件左侧菜单”->“设置”->“日志等级”调整到debug / Please provide a complete or relevant Debug log (please adjust it to debug in the left menu of software-> Settings-> Log Level)
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,4 @@
|
||||
contact_links:
|
||||
- name: 讨论交流 / Communication
|
||||
url: https://t.me/+G-veVtwBOl4wODc1
|
||||
about: 在 Telegram 群组中与其他用户讨论交流 / Communicate with other users in the Telegram group
|
||||
@@ -0,0 +1,42 @@
|
||||
name: 功能请求 / Feature request
|
||||
title: "[Feature] "
|
||||
description: 提出你的功能请求 / Propose your feature request
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 在提交问题之前,请确认以下事项:
|
||||
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
|
||||
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
|
||||
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
|
||||
## Before submitting the issue, please make sure of the following checklist:
|
||||
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
||||
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
|
||||
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 功能描述 / Feature description
|
||||
description: 详细清晰地描述你的功能请求 / A clear and concise description of what the feature is
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 使用场景 / Use case
|
||||
description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: os-labels
|
||||
attributes:
|
||||
label: 适用系统 / Target OS
|
||||
description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one)
|
||||
options:
|
||||
- label: Android
|
||||
- label: Windows
|
||||
- label: MacOS
|
||||
- label: Linux
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,39 @@
|
||||
cask "flclash" do
|
||||
version "VERSION"
|
||||
|
||||
on_macos do
|
||||
arch arm: "arm64", intel: "amd64"
|
||||
|
||||
sha256 arm: "ARM_SHA256",
|
||||
intel: "AMD_SHA256"
|
||||
|
||||
url "https://github.com/chen08209/FlClash/releases/download/v#{version}/FlClash-#{version}-macos-#{arch}.dmg"
|
||||
end
|
||||
|
||||
name "FlClash"
|
||||
desc "Multi-platform proxy client based on ClashMeta"
|
||||
homepage "https://github.com/chen08209/FlClash"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
depends_on :macos
|
||||
|
||||
app "FlClash.app"
|
||||
|
||||
postflight do
|
||||
system_command "xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
|
||||
end
|
||||
|
||||
uninstall quit: "com.follow.clash"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/com.follow.clash",
|
||||
"~/Library/Caches/com.follow.clash",
|
||||
"~/Library/Preferences/com.follow.clash.plist",
|
||||
"~/Library/Saved Application State/com.follow.clash.savedState",
|
||||
]
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
<div align=center>
|
||||
|
||||
[](https://img.shields.io/github/downloads/chen08209/FlClash/vVERSION/)
|
||||
|
||||
</div>
|
||||
|
||||
**Download based on your OS:**
|
||||
|
||||
<div align=left>
|
||||
<table>
|
||||
<thead align=left>
|
||||
<tr>
|
||||
<th>OS</th>
|
||||
<th>Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody align=left>
|
||||
<tr>
|
||||
<td>Android</td>
|
||||
<td>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-arm64-v8a.apk"><img src="https://img.shields.io/badge/APK-ARMv8-168039.svg?logo=android"></a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-armeabi-v7a.apk"><img src="https://img.shields.io/badge/APK-ARMv7-45bf55.svg?logo=android"></a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-x86_64.apk"><img src="https://img.shields.io/badge/APK-x64-96ed89.svg?logo=android"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64-setup.exe"><img src="https://img.shields.io/badge/Setup-x64-2d7d9a.svg?logo=windows"></a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64.zip"><img src="https://img.shields.io/badge/Portable-x64-67b7d1.svg?logo=windows"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>macOS</td>
|
||||
<td>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-arm64.dmg"><img src="https://img.shields.io/badge/DMG-Apple%20Silicon-%23000000.svg?logo=apple"></a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-amd64.dmg"><img src="https://img.shields.io/badge/DMG-Intel%20X64-%2300A9E0.svg?logo=apple"></a><br>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux</td>
|
||||
<td>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
**List of all changes:** [ChangeLog](https://github.com/chen08209/FlClash/blob/main/CHANGELOG.md)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,558 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
env:
|
||||
IS_STABLE: ${{ !contains(github.ref, '-') }}
|
||||
FLUTTER_VERSION: '3.47.4'
|
||||
GO_VERSION: '1.26.4'
|
||||
NDK_VERSION: r28c
|
||||
|
||||
jobs:
|
||||
dart:
|
||||
name: Dart
|
||||
# A private copy pays for its Actions minutes, so it runs the checks only when
|
||||
# started by hand.
|
||||
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Test commit message hook
|
||||
run: bash tool/check_commit_msg_test.sh
|
||||
|
||||
- name: Test comment density hook
|
||||
run: bash tool/check_comment_density_test.sh
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Check format
|
||||
run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
|
||||
- name: Skip native build hooks
|
||||
run: |
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false | .hooks.user_defines.code_forge.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Verify changelog
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||
run: dart run tool/changelog.dart verify
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test --reporter expanded --coverage
|
||||
|
||||
- name: Check coverage
|
||||
run: dart run tool/check_coverage.dart coverage/lcov.info 75
|
||||
|
||||
plugins:
|
||||
name: Plugins
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Validate local Flutter packages
|
||||
run: bash tool/check_plugins.sh
|
||||
|
||||
- name: Validate setup hooks package
|
||||
working-directory: plugins/setup/setup_hooks
|
||||
run: |
|
||||
dart pub get
|
||||
dart analyze
|
||||
dart test
|
||||
|
||||
desktop:
|
||||
name: Desktop ${{ matrix.platform }}
|
||||
if: *checks
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
os: windows-2022
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: desktop-${{ matrix.platform }}
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
|
||||
|
||||
- name: Setup Xcode
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
# Compiles the desktop runners and the native plugin code that only the
|
||||
# tagged release matrix built before; the bundle itself is discarded.
|
||||
- name: Build debug bundle
|
||||
run: flutter build ${{ matrix.platform }} --debug
|
||||
|
||||
go:
|
||||
name: Go core
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
working-directory: core
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
if ! compgen -G '*_test.go' > /dev/null; then
|
||||
echo 'No Go tests found in core/; the test step would pass vacuously.' >&2
|
||||
exit 1
|
||||
fi
|
||||
test -z "$(gofmt -l ./*.go ./platform ./tun)" || {
|
||||
gofmt -l ./*.go ./platform ./tun >&2
|
||||
exit 1
|
||||
}
|
||||
go test -count=1 .
|
||||
go vet .
|
||||
GOOS=linux go vet ./platform
|
||||
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
|
||||
- name: Validate the Android core library
|
||||
working-directory: core
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
# Nothing else compiles the `android && cgo` files before a `v*` tag.
|
||||
CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)"
|
||||
test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; }
|
||||
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
|
||||
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
|
||||
export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC
|
||||
go vet "-tags=$tags" . ./tun ./platform
|
||||
|
||||
android:
|
||||
name: Android unit tests
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Run Android unit tests
|
||||
working-directory: android
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
{
|
||||
echo "flutter.sdk=$FLUTTER_ROOT"
|
||||
echo "sdk.dir=$ANDROID_HOME"
|
||||
} > local.properties
|
||||
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
|
||||
test -d "$wrapper" || flutter precache
|
||||
cp -R "$wrapper/." .
|
||||
chmod +x gradlew
|
||||
# `:core` only calls System.loadLibrary; the excluded Flutter tasks would run
|
||||
# the native build hooks for every debug ABI, which no JVM unit test consumes.
|
||||
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
|
||||
-x :app:compileFlutterBuildDebug -x :app:copyFlutterAssetsDebug
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
|
||||
- name: Check format
|
||||
run: |
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
|
||||
|
||||
windows-helper-test:
|
||||
name: Windows helper test
|
||||
if: *checks
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: services/helper
|
||||
|
||||
- name: Check format
|
||||
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
|
||||
- name: Run Windows service tests
|
||||
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
|
||||
build:
|
||||
# Releases publish where the release links point: the public repository.
|
||||
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: android
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
os: Windows-2022
|
||||
arch: amd64
|
||||
- platform: linux
|
||||
os: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: macos
|
||||
os: macos-15-intel
|
||||
arch: amd64
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
arch: arm64
|
||||
- platform: windows
|
||||
os: windows-11-arm
|
||||
arch: arm64
|
||||
- platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Setup rust
|
||||
if: startsWith(matrix.os, 'windows-11-arm')
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
$cargoPath = "$env:USERPROFILE\.cargo\bin"
|
||||
Add-Content $env:GITHUB_PATH $cargoPath
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Android Signing
|
||||
if: startsWith(matrix.platform,'android')
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
|
||||
{
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
||||
} >> android/local.properties
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.platform }}-${{ matrix.arch || 'all' }}
|
||||
workspaces: services/helper
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Gradle
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
# Stable publishes no Windows or Linux arm64 archive; master makes the action
|
||||
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: master
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Setup Xcode
|
||||
if: startsWith(matrix.platform,'macos')
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: build-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
- name: Get Flutter Dependency
|
||||
run: |
|
||||
flutter --version
|
||||
flutter pub get
|
||||
|
||||
- name: Setup
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifact-${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) }}
|
||||
path: ./dist
|
||||
overwrite: true
|
||||
|
||||
upload:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [ build ]
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
telegram-bot-api:
|
||||
image: aiogram/telegram-bot-api:latest
|
||||
env:
|
||||
TELEGRAM_API_ID: ${{ secrets.TELEGRAM_API_ID }}
|
||||
TELEGRAM_API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
|
||||
ports:
|
||||
- 8081:8081
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./dist/
|
||||
pattern: artifact-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Skip native build hooks
|
||||
run: |
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false | .hooks.user_defines.code_forge.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
env:
|
||||
IS_STABLE: ${{ env.IS_STABLE }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
target=()
|
||||
if [[ "$IS_STABLE" == 'true' ]]; then
|
||||
target=(--tag "$TAG")
|
||||
else
|
||||
dart run tool/changelog.dart build --unreleased
|
||||
fi
|
||||
dart run tool/changelog.dart render release "${target[@]}" --out release.md
|
||||
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
|
||||
|
||||
- name: Push to telegram
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
python release_telegram.py
|
||||
|
||||
- name: Patch release.md
|
||||
run: |
|
||||
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
|
||||
|
||||
- name: Generate sha256
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
cd ./dist
|
||||
sha256sum ./* > SHA256SUMS
|
||||
|
||||
- name: Release
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./dist/*
|
||||
body_path: './release.md'
|
||||
|
||||
- name: Create Homebrew Cask Source Dir
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
arm_sha=$(sha256sum "./dist/FlClash-${version}-macos-arm64.dmg" | awk '{print $1}')
|
||||
amd_sha=$(sha256sum "./dist/FlClash-${version}-macos-amd64.dmg" | awk '{print $1}')
|
||||
|
||||
mkdir -p ./brew/Casks
|
||||
sed \
|
||||
-e "s|VERSION|$version|g" \
|
||||
-e "s|ARM_SHA256|$arm_sha|g" \
|
||||
-e "s|AMD_SHA256|$amd_sha|g" \
|
||||
./.github/homebrew_cask_template.rb > ./brew/Casks/flclash.rb
|
||||
|
||||
- name: Push to Homebrew tap
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
uses: cpina/github-action-push-to-another-repository@v1.7.2
|
||||
env:
|
||||
SSH_DEPLOY_KEY: ${{ secrets.HOMEBREW_SSH_DEPLOY_KEY }}
|
||||
with:
|
||||
source-directory: ./brew/
|
||||
destination-github-username: chen08209
|
||||
destination-repository-name: homebrew-tap
|
||||
user-name: 'chen08029'
|
||||
user-email: 'chen08209@gmail.com'
|
||||
target-branch: main
|
||||
commit-message: Update Homebrew cask from ${{ github.ref_name }}
|
||||
target-directory: /
|
||||
|
||||
- name: Create Fdroid Source Dir
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
mkdir -p ./tmp
|
||||
cp ./dist/*android-arm64-v8a* ./tmp/ || true
|
||||
echo "Files copied successfully"
|
||||
|
||||
- name: Push to fdroid repo
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
uses: cpina/github-action-push-to-another-repository@v1.7.2
|
||||
env:
|
||||
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
with:
|
||||
source-directory: ./tmp/
|
||||
destination-github-username: chen08209
|
||||
destination-repository-name: FlClash-fdroid-repo
|
||||
user-name: 'chen08029'
|
||||
user-email: 'chen08209@gmail.com'
|
||||
target-branch: main
|
||||
commit-message: Update from ${{ github.ref_name }}
|
||||
target-directory: /tmp/
|
||||
@@ -1,124 +0,0 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: android
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
os: windows-latest
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
- platform: macos
|
||||
os: macos-13
|
||||
|
||||
steps:
|
||||
- name: Check Matrix
|
||||
run: |
|
||||
echo "Running on ${{ matrix.os }}"
|
||||
echo "Arch: ${{ runner.arch }}"
|
||||
gcc --version
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup JAVA
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 17
|
||||
|
||||
- name: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: r26b
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
- name: Setup Android Signing
|
||||
if: startsWith(matrix.platform,'android')
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
|
||||
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'core/go.mod'
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.x'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter Dependency
|
||||
run: flutter pub get
|
||||
|
||||
- name: Setup
|
||||
run: |
|
||||
dart setup.dart ${{ matrix.platform }}
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifact-${{ matrix.platform }}
|
||||
path: ./dist
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
|
||||
upload-release:
|
||||
if: ${{ !contains(github.ref, '+') }}
|
||||
permissions: write-all
|
||||
needs: [ build ]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./dist/
|
||||
pattern: artifact-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Pre Release
|
||||
run: |
|
||||
pip install gitchangelog pystache mustache markdown
|
||||
pre=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
|
||||
if [ -z "pre" ]; then
|
||||
echo "init" > release.md
|
||||
else
|
||||
current="${{ github.ref_name }}"
|
||||
echo -e "\n\n<details markdown=1><summary>All changes from $current to the latest commit:</summary>\n\n" >> release.md
|
||||
gitchangelog "${pre}.." >> release.md 2>&1 || echo "Error in gitchangelog"
|
||||
echo -e "\n\n</details>" >> release.md
|
||||
fi
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./dist/*
|
||||
body_path: './release.md'
|
||||
@@ -5,9 +5,11 @@
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
@@ -19,7 +21,7 @@ migrate_working_dir/
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
@@ -39,15 +41,39 @@ app.*.symbols
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Claude Code - ignore user-specific settings only
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Documentation (generated/planning artifacts)
|
||||
docs/
|
||||
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
/android/**/.cxx
|
||||
/android/**/build
|
||||
/android/common/**/.**/
|
||||
/android/common/local.*
|
||||
/android/core/**/includes/
|
||||
/android/core/**/cmake-build-*/
|
||||
/android/core/**/jniLibs/
|
||||
|
||||
|
||||
|
||||
#libclash
|
||||
#FlClash
|
||||
/libclash/
|
||||
|
||||
#jniLibs
|
||||
/android/app/src/main/jniLibs/
|
||||
/services/helper/target
|
||||
/macos/**/Package.resolved
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/plugins/rust_api/.claude/
|
||||
/issue-triage/
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/coverage/lcov.info
|
||||
|
||||
@@ -2,7 +2,3 @@
|
||||
path = core/Clash.Meta
|
||||
url = git@github.com:chen08209/Clash.Meta.git
|
||||
branch = FlClash
|
||||
[submodule "plugins/flutter_distributor"]
|
||||
path = plugins/flutter_distributor
|
||||
url = git@github.com:chen08209/flutter_distributor.git
|
||||
branch = FlClash
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled.
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
channel: stable
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
@@ -13,26 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: android
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: ios
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: linux
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: macos
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: web
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: windows
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dart-format
|
||||
name: dart format
|
||||
entry: dart format
|
||||
language: system
|
||||
files: \.dart$
|
||||
stages: [pre-commit]
|
||||
- id: comment-density
|
||||
name: comment density
|
||||
entry: tool/check_comment_density.sh
|
||||
language: system
|
||||
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
|
||||
stages: [pre-commit]
|
||||
- id: flutter-analyze
|
||||
name: flutter analyze (pre-push)
|
||||
entry: flutter analyze --no-fatal-infos
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
- id: commit-msg-format
|
||||
name: conventional commit message
|
||||
entry: tool/check_commit_msg.sh
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"binary": {
|
||||
"env": {
|
||||
"CGO_ENABLED": "0",
|
||||
// "GOOS": "android",
|
||||
// "GOARCH": "arm64",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under
|
||||
`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.
|
||||
|
||||
## Start Here
|
||||
|
||||
Read these files before making changes:
|
||||
|
||||
- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.
|
||||
- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.
|
||||
- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.
|
||||
|
||||
Read these only when the task touches their area:
|
||||
|
||||
- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and
|
||||
local plugins.
|
||||
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
|
||||
command rules, and hooks.
|
||||
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
|
||||
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
|
||||
|
||||
## Highest Priority Rules
|
||||
|
||||
- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.
|
||||
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
|
||||
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
|
||||
- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the
|
||||
summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks
|
||||
out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.
|
||||
- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
|
||||
say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
|
||||
wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
|
||||
annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
|
||||
Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file
|
||||
whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already
|
||||
touch. Preserve
|
||||
`// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See
|
||||
[.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.
|
||||
- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never
|
||||
kill a FlClash instance by name; the user's own instance is not yours to close. See
|
||||
[.agents/rules.md](.agents/rules.md).
|
||||
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
- Run code generation after modifying models, providers, or database schema.
|
||||
- Do not manually edit generated files.
|
||||
- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent
|
||||
arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of
|
||||
truth.
|
||||
- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while
|
||||
native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded
|
||||
requests.
|
||||
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
|
||||
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
|
||||
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
|
||||
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
|
||||
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
|
||||
[.agents/rules.md](.agents/rules.md).
|
||||
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
|
||||
preferences, and declared return types.
|
||||
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
|
||||
suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows
|
||||
cargo), say which check was skipped rather than dropping it silently.
|
||||
|
||||
## Repo Skills
|
||||
|
||||
Use repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the
|
||||
same skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit
|
||||
structural quality review.
|
||||
@@ -0,0 +1,735 @@
|
||||
# Changelog
|
||||
|
||||
## v0.8.98 (2026-09-14)
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
- **resources** Refresh the geo file size and time after an update finishes (c5bf5bd)
|
||||
- **core** Keep the core running while Windows sleeps with the app suspended (60f371a)
|
||||
|
||||
## v0.8.97 (2026-09-10)
|
||||
|
||||
**Features**
|
||||
|
||||
- **ui** Rework the app UI and refresh the localization (26cfbaf)
|
||||
- **app** Rework the app layer and window handling, and add proxy authentication (aaf934c)
|
||||
- **desktop** Rework the desktop runners, packaging, and native build (c0fcbc0)
|
||||
- **android** Rework the Android VPN service and lifecycle handling (ae29f38)
|
||||
- **plugins** Rework the desktop plugins and add the Helper service and Rust bridge (adf715f)
|
||||
- **core** Rework the core IPC and process lifecycle (c6eaa0a)
|
||||
|
||||
<!-- changelog:frozen -->
|
||||
<!-- Entries below predate the structured pipeline. Their wording is kept as written; only the heading and list style were normalized. -->
|
||||
|
||||
## v0.8.96 (2026-08-17)
|
||||
|
||||
- Optimize commented policy
|
||||
- Fix whole group delay test failing on Windows
|
||||
- Optimize package icon loading and connections polling
|
||||
|
||||
## v0.8.95 (2026-08-14)
|
||||
|
||||
- Optimize core service
|
||||
- Optimize Android TV launcher icon
|
||||
- Optimize back navigation
|
||||
- Optimize more details
|
||||
- Fix some issues
|
||||
- Optimize app layout
|
||||
- Optimize focus control
|
||||
- Adjust android process
|
||||
|
||||
## v0.8.94 (2026-07-11)
|
||||
|
||||
- Fix macos performance issue
|
||||
- Support custom global-ua
|
||||
- Update core
|
||||
- Optimize some details
|
||||
- Fix linux silent launching not working
|
||||
|
||||
## v0.8.93 (2026-05-29)
|
||||
|
||||
- Support custom overwrite
|
||||
- Support run on demand
|
||||
- Optimize windows ipc
|
||||
- Optimize windows arm64
|
||||
- Optimize build
|
||||
- Optimize some details
|
||||
- Update core
|
||||
|
||||
## v0.8.92 (2026-02-02)
|
||||
|
||||
- Add sqlite store
|
||||
- Optimize android quick action
|
||||
- Optimize backup and restore
|
||||
- Optimize more details
|
||||
|
||||
## v0.8.91 (2025-12-12)
|
||||
|
||||
- Fix windows some issues
|
||||
- Optimize overwrite handle
|
||||
- Optimize access control page
|
||||
- Optimize some details
|
||||
|
||||
## v0.8.90 (2025-10-08)
|
||||
|
||||
- Fix android tile service
|
||||
- Support append system DNS
|
||||
- Fix some issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.89 (2025-09-27)
|
||||
|
||||
- Fix some issues
|
||||
- Optimize Windows service mode
|
||||
- Update core
|
||||
- Update changelog
|
||||
|
||||
## v0.8.88 (2025-09-23)
|
||||
|
||||
- Add android separates the core process
|
||||
- Support core status check and force restart
|
||||
- Optimize proxies page and access page
|
||||
- Update flutter and pub dependencies
|
||||
- Update go version
|
||||
- Optimize more details
|
||||
- Update changelog
|
||||
|
||||
## v0.8.87 (2025-07-29)
|
||||
|
||||
- Optimize desktop view
|
||||
- Optimize logs, requests, connection pages
|
||||
- Optimize windows tray auto hide
|
||||
- Optimize some details
|
||||
- Update core
|
||||
- Update changelog
|
||||
|
||||
## v0.8.86 (2025-06-15)
|
||||
|
||||
- Fix windows tun issues
|
||||
- Optimize android get system dns
|
||||
- Optimize more details
|
||||
- Update changelog
|
||||
|
||||
## v0.8.85 (2025-06-07)
|
||||
|
||||
- Support override script
|
||||
- Support proxies search
|
||||
- Support svg display
|
||||
- Optimize config persistence
|
||||
- Add some scenes auto close connections
|
||||
- Update core
|
||||
- Optimize more details
|
||||
|
||||
## v0.8.84 (2025-05-01)
|
||||
|
||||
- Fix windows service verify issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.83 (2025-05-01)
|
||||
|
||||
- Add windows server mode start process verify
|
||||
- Add linux deb dependencies
|
||||
- Add backup recovery strategy select
|
||||
- Support custom text scaling
|
||||
- Optimize the display of different text scale
|
||||
- Optimize windows setup experience
|
||||
- Optimize startTun performance
|
||||
- Optimize android tv experience
|
||||
- Optimize default option
|
||||
- Optimize computed text size
|
||||
- Optimize hyperOS freeform window
|
||||
- Add developer mode
|
||||
- Update core
|
||||
- Optimize more details
|
||||
- Add issues template
|
||||
- Update changelog
|
||||
|
||||
## v0.8.82 (2025-04-18)
|
||||
|
||||
- Optimize android vpn performance
|
||||
- Add custom primary color and color scheme
|
||||
- Add linux nad windows arm release
|
||||
- Optimize requests and logs page
|
||||
- Fix map input page delete issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.81 (2025-04-08)
|
||||
|
||||
- Add rule override
|
||||
- Update core
|
||||
- Optimize more details
|
||||
- Update changelog
|
||||
|
||||
## v0.8.80 (2025-03-10)
|
||||
|
||||
- Optimize dashboard performance
|
||||
- Fix some issues
|
||||
- Fix unselected proxy group delay issues
|
||||
- Fix asn url issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.79 (2025-03-07)
|
||||
|
||||
- Fix tab delay view issues
|
||||
- Fix tray action issues
|
||||
- Fix get profile redirect client ua issues
|
||||
- Fix proxy card delay view issues
|
||||
- Add Russian, Japanese adaptation
|
||||
- Fix some issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.78 (2025-03-05)
|
||||
|
||||
- Fix list form input view issues
|
||||
- Fix traffic view issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.77 (2025-03-05)
|
||||
|
||||
- Optimize performance
|
||||
- Update core
|
||||
- Optimize core stability
|
||||
- Fix linux tun authority check error
|
||||
- Fix some issues
|
||||
- Fix scroll physics error
|
||||
- Update changelog
|
||||
|
||||
## v0.8.75 (2025-02-09)
|
||||
|
||||
- Add windows storage corruption detection
|
||||
- Fix core crash caused by windows resource manager restart
|
||||
- Optimize logs, requests, access to pages
|
||||
- Fix macos bypass domain issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.74 (2025-02-03)
|
||||
|
||||
- Fix some issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.73 (2025-02-02)
|
||||
|
||||
- Update popup menu
|
||||
- Add file editor
|
||||
- Fix android service issues
|
||||
- Optimize desktop background performance
|
||||
- Optimize android main process performance
|
||||
- Optimize delay test
|
||||
- Optimize vpn protect
|
||||
- Update changelog
|
||||
|
||||
## v0.8.72 (2025-01-10)
|
||||
|
||||
- Update core
|
||||
- Fix some issues
|
||||
- Update changelog
|
||||
|
||||
## v0.8.71 (2025-01-09)
|
||||
|
||||
- Remake dashboard
|
||||
- Optimize theme
|
||||
- Optimize more details
|
||||
- Update flutter version
|
||||
- Update changelog
|
||||
|
||||
## v0.8.70 (2024-12-09)
|
||||
|
||||
- Support better window position memory
|
||||
- Add windows arm64 and linux arm64 build script
|
||||
- Optimize some details
|
||||
|
||||
## v0.8.69 (2024-12-06)
|
||||
|
||||
- Remake desktop
|
||||
- Optimize change proxy
|
||||
- Optimize network check
|
||||
- Fix fallback issues
|
||||
- Optimize lots of details
|
||||
- Update change.yaml
|
||||
- Fix android tile issues
|
||||
- Fix windows tray issues
|
||||
- Support setting bypassDomain
|
||||
- Update flutter version
|
||||
- Fix android service issues
|
||||
- Fix macos dock exit button issues
|
||||
- Add route address setting
|
||||
- Optimize provider view
|
||||
- Update changelog
|
||||
- Update CHANGELOG.md
|
||||
|
||||
## v0.8.67 (2024-11-09)
|
||||
|
||||
- Add android shortcuts
|
||||
- Fix init params issues
|
||||
- Fix dynamic color issues
|
||||
- Optimize navigator animate
|
||||
- Optimize window init
|
||||
- Optimize fab
|
||||
- Optimize save
|
||||
|
||||
## v0.8.66 (2024-10-26)
|
||||
|
||||
- Fix the collapse issues
|
||||
- Add fontFamily options
|
||||
|
||||
## v0.8.65 (2024-10-26)
|
||||
|
||||
- Update core version
|
||||
- Update flutter version
|
||||
- Optimize ip check
|
||||
- Optimize url-test
|
||||
|
||||
## v0.8.64 (2024-10-12)
|
||||
|
||||
- Update release message
|
||||
- Init auto gen changelog
|
||||
- Fix windows tray issues
|
||||
- Fix urltest issues
|
||||
- Add auto changelog
|
||||
- Fix windows admin auto launch issues
|
||||
- Add android vpn options
|
||||
- Support proxies icon configuration
|
||||
- Optimize android immersion display
|
||||
- Fix some issues
|
||||
- Optimize ip detection
|
||||
- Support android vpn ipv6 inbound switch
|
||||
- Support log export
|
||||
- Optimize more details
|
||||
- Fix android system dns issues
|
||||
- Optimize dns default option
|
||||
- Fix some issues
|
||||
- Update readme
|
||||
|
||||
## v0.8.60 (2024-09-17)
|
||||
|
||||
- Fix build error2
|
||||
- Fix build error
|
||||
- Support desktop hotkey
|
||||
- Support android ipv6 inbound
|
||||
- Support android system dns
|
||||
- fix some bugs
|
||||
|
||||
## v0.8.59 (2024-09-09)
|
||||
|
||||
- Fix delete profile error
|
||||
|
||||
## v0.8.58 (2024-09-08)
|
||||
|
||||
- Fix submit error 2
|
||||
- Fix submit error
|
||||
- Optimize DNS strategy
|
||||
- Fix the problem that the tray is not displayed in some cases
|
||||
- Optimize tray
|
||||
- Update core
|
||||
- Fix some error
|
||||
|
||||
## v0.8.57 (2024-09-02)
|
||||
|
||||
- Fix tun update issues
|
||||
- Add DNS override
|
||||
- Fixed some bugs
|
||||
- Optimize more detail
|
||||
- Add Hosts override
|
||||
|
||||
## v0.8.56 (2024-08-26)
|
||||
|
||||
- fix android tip error
|
||||
- fix windows auto launch error
|
||||
|
||||
## v0.8.55 (2024-08-25)
|
||||
|
||||
- Fix windows tray issues
|
||||
- Optimize windows logic
|
||||
- Optimize app logic
|
||||
- Support windows administrator auto launch
|
||||
- Support android close vpn
|
||||
|
||||
## v0.8.53 (2024-08-15)
|
||||
|
||||
- Change flutter version
|
||||
- Support profiles sort
|
||||
- Support windows country flags display
|
||||
- Optimize proxies page and profiles page columns
|
||||
|
||||
## v0.8.52 (2024-08-11)
|
||||
|
||||
- Update flutter version
|
||||
- Update version
|
||||
- Update timeout time
|
||||
- Update access control page
|
||||
- Fix bug
|
||||
|
||||
## v0.8.51 (2024-08-05)
|
||||
|
||||
- Optimize provider page
|
||||
- Optimize delay test
|
||||
- Support local backup and recovery
|
||||
- Fix android tile service issues
|
||||
|
||||
## v0.8.49 (2024-07-31)
|
||||
|
||||
- Fix linux core build error
|
||||
- Add proxy-only traffic statistics
|
||||
- Update core
|
||||
- Optimize more details
|
||||
- Merge pull request #140 from txyyh/main
|
||||
- 添加自建 F-Droid 仓库相关 workflow
|
||||
- Rename readme fingerprint
|
||||
- Rename workflow deploy repo name
|
||||
- Add download guide to README
|
||||
- Add push release files to fdroid-repo
|
||||
|
||||
## v0.8.48 (2024-07-25)
|
||||
|
||||
- Optimize proxies page
|
||||
- Fix ua issues
|
||||
- Optimize more details
|
||||
|
||||
## v0.8.47 (2024-07-22)
|
||||
|
||||
- Fix windows build error
|
||||
|
||||
## v0.8.46 (2024-07-22)
|
||||
|
||||
- Update app icon
|
||||
- Fix desktop backup error
|
||||
- Optimize request ua
|
||||
- Change android icon
|
||||
- Optimize dashboard
|
||||
|
||||
## v0.8.44 (2024-07-18)
|
||||
|
||||
- Remove request validate certificate
|
||||
- Sync core
|
||||
|
||||
## v0.8.43 (2024-07-18)
|
||||
|
||||
- Fix windows error
|
||||
|
||||
## v0.8.42 (2024-07-18)
|
||||
|
||||
- Fix setup.dart error
|
||||
- Fix android system proxy not effective
|
||||
- Add macos arm64
|
||||
|
||||
## v0.8.41 (2024-07-17)
|
||||
|
||||
- Optimize proxies page
|
||||
- Support mouse drag scroll
|
||||
- Adjust desktop ui
|
||||
- Revert "Fix android vpn issues"
|
||||
- This reverts commit 891977408e6938e2acd74e9b9adb959c48c79988.
|
||||
|
||||
## v0.8.40 (2024-07-15)
|
||||
|
||||
- Fix android vpn issues
|
||||
- Fix android vpn issues
|
||||
- Rollback partial modification
|
||||
|
||||
## v0.8.39 (2024-07-15)
|
||||
|
||||
- Fix the problem that ui can't be synchronized when android vpn is occupied by an external
|
||||
- Override default socksPort,port
|
||||
|
||||
## v0.8.38 (2024-07-14)
|
||||
|
||||
- Fix fab issues
|
||||
|
||||
## v0.8.37 (2024-07-14)
|
||||
|
||||
- Update version
|
||||
- Fix the problem that vpn cannot be started in some cases
|
||||
- Fix the problem that geodata url does not take effect
|
||||
|
||||
## v0.8.36 (2024-07-13)
|
||||
|
||||
- Update ua
|
||||
- Fix change outbound mode without check ip issues
|
||||
- Separate android ui and vpn
|
||||
- Fix url validate issues 2
|
||||
- Add android hidden from the recent task
|
||||
- Add geoip file
|
||||
- Support modify geoData URL
|
||||
|
||||
## v0.8.35 (2024-07-07)
|
||||
|
||||
- Fix url validate issues
|
||||
- Fix check ip performance problem
|
||||
- Optimize resources page
|
||||
|
||||
## v0.8.34 (2024-07-04)
|
||||
|
||||
- Add ua selector
|
||||
- Support modify test url
|
||||
- Optimize android proxy
|
||||
- Fix the error that async proxy provider could not selected the proxy
|
||||
|
||||
## v0.8.33 (2024-07-01)
|
||||
|
||||
- Fix android proxy error
|
||||
- Fix submit error
|
||||
- Add windows tun
|
||||
- Optimize android proxy
|
||||
- Optimize change profile
|
||||
- Update application ua
|
||||
- Optimize delay test
|
||||
|
||||
## v0.8.32 (2024-06-28)
|
||||
|
||||
- Fix android repeated request notification issues
|
||||
|
||||
## v0.8.31 (2024-06-28)
|
||||
|
||||
- Fix memory overflow issues
|
||||
|
||||
## v0.8.30 (2024-06-27)
|
||||
|
||||
- Optimize proxies expansion panel 2
|
||||
- Fix android scan qrcode error
|
||||
|
||||
## v0.8.29 (2024-06-27)
|
||||
|
||||
- Optimize proxies expansion panel
|
||||
- Fix text error
|
||||
|
||||
## v0.8.28 (2024-06-26)
|
||||
|
||||
- Optimize proxy
|
||||
- Optimize delayed sorting performance
|
||||
- Add expansion panel proxies page
|
||||
- Support to adjust the proxy card size
|
||||
- Support to adjust proxies columns number
|
||||
- Fix autoRun show issues
|
||||
- Fix Android 10 issues
|
||||
- Optimize ip show
|
||||
|
||||
## v0.8.26 (2024-06-22)
|
||||
|
||||
- Add intranet IP display
|
||||
- Add connections page
|
||||
- Add search in connections, requests
|
||||
- Add keyword search in connections, requests, logs
|
||||
- Add basic viewing editing capabilities
|
||||
- Optimize update profile
|
||||
|
||||
## v0.8.25 (2024-06-19)
|
||||
|
||||
- Update version
|
||||
- Fix the problem of excessive memory usage in traffic usage.
|
||||
- Add lightBlue theme color
|
||||
- Fix start unable to update profile issues
|
||||
- Fix flashback caused by process
|
||||
|
||||
## v0.8.23 (2024-06-16)
|
||||
|
||||
- Add build version
|
||||
- Optimize quick start
|
||||
- Update system default option
|
||||
|
||||
## v0.8.22 (2024-06-16)
|
||||
|
||||
- Update build.yml
|
||||
- Fix android vpn close issues
|
||||
- Add requests page
|
||||
- Fix checkUpdate dark mode style error
|
||||
- Fix quickStart error open app
|
||||
- Add memory proxies tab index
|
||||
- Support hidden group
|
||||
- Optimize logs
|
||||
- Fix externalController hot load error
|
||||
|
||||
## v0.8.21 (2024-06-13)
|
||||
|
||||
- Add tcp concurrent switch
|
||||
- Add system proxy switch
|
||||
- Add geodata loader switch
|
||||
- Add external controller switch
|
||||
- Add auto gc on trim memory
|
||||
- Fix android notification error
|
||||
|
||||
## v0.8.20 (2024-06-12)
|
||||
|
||||
- Fix ipv6 error
|
||||
- Fix android udp direct error
|
||||
- Add ipv6 switch
|
||||
- Add access all selected button
|
||||
- Remove android low version splash
|
||||
|
||||
## v0.8.19 (2024-06-10)
|
||||
|
||||
- Update version
|
||||
- Add allowBypass
|
||||
- Fix Android only pick .text file issues
|
||||
|
||||
## v0.8.18 (2024-06-09)
|
||||
|
||||
- Fix search issues
|
||||
|
||||
## v0.8.17 (2024-06-09)
|
||||
|
||||
- Fix LoadBalance, Relay load error
|
||||
- Fix build.yml4
|
||||
- Fix build.yml3
|
||||
- Fix build.yml2
|
||||
- Fix build.yml
|
||||
- Add search function at access control
|
||||
- Fix the issues with the profile add button to cover the edit button
|
||||
- Adapt LoadBalance and Relay
|
||||
- Add arm
|
||||
- Fix android notification icon error
|
||||
|
||||
## v0.8.16 (2024-06-08)
|
||||
|
||||
- Add one-click update all profiles
|
||||
- Add expire show
|
||||
|
||||
## v0.8.15 (2024-06-06)
|
||||
|
||||
- Temp remove tun mode
|
||||
- Remove macos in workflow
|
||||
- Change go version
|
||||
|
||||
## v0.8.14 (2024-06-06)
|
||||
|
||||
- Update Version
|
||||
- Fix tun unable to open
|
||||
|
||||
## v0.8.13 (2024-06-06)
|
||||
|
||||
- Optimize delay test2
|
||||
- Optimize delay test
|
||||
- Add check ip
|
||||
- add check ip request
|
||||
|
||||
## v0.8.12 (2024-06-06)
|
||||
|
||||
- Fix the problem that the download of remote resources failed after GeodataMode was turned on, which caused the
|
||||
application to flash back.
|
||||
- Fix edit profile error
|
||||
- Fix quickStart change proxy error
|
||||
- Fix core version
|
||||
|
||||
## v0.8.10 (2024-06-05)
|
||||
|
||||
- Fix core version
|
||||
|
||||
## v0.8.9 (2024-06-05)
|
||||
|
||||
- Update file_picker
|
||||
- Add resources page
|
||||
- Optimize more detail
|
||||
- Add access selected sorted
|
||||
- Fix notification duplicate creation issue
|
||||
- Fix AccessControl click issue
|
||||
|
||||
## v0.8.7 (2024-05-31)
|
||||
|
||||
- Fix Workflow
|
||||
- Fix Linux unable to open
|
||||
- Update README.md 3
|
||||
- Create LICENSE
|
||||
- Update README.md 2
|
||||
- Update README.md
|
||||
- Optimize workFlow
|
||||
|
||||
## v0.8.6 (2024-05-31)
|
||||
|
||||
- optimize checkUpdate
|
||||
|
||||
## v0.8.5 (2024-05-30)
|
||||
|
||||
- Fix submit error
|
||||
|
||||
## v0.8.4 (2024-05-30)
|
||||
|
||||
- add WebDAV
|
||||
- add Auto check updates
|
||||
- Optimize more details
|
||||
- optimize delayTest
|
||||
|
||||
## v0.8.2 (2024-05-15)
|
||||
|
||||
- upgrade flutter version
|
||||
|
||||
## v0.8.1 (2024-05-15)
|
||||
|
||||
- Update kernel
|
||||
- Add import profile via QR code image
|
||||
|
||||
## v0.8.0 (2024-05-11)
|
||||
|
||||
- Add compatibility mode and adapt clash scheme.
|
||||
|
||||
## v0.7.14 (2024-05-07)
|
||||
|
||||
- update Version
|
||||
- Reconstruction application proxy logic
|
||||
|
||||
## v0.7.13 (2024-05-06)
|
||||
|
||||
- Fix Tab destroy error
|
||||
|
||||
## v0.7.12 (2024-05-06)
|
||||
|
||||
- Optimize repeat healthcheck
|
||||
|
||||
## v0.7.11 (2024-05-06)
|
||||
|
||||
- Optimize Direct mode ui
|
||||
|
||||
## v0.7.10 (2024-05-06)
|
||||
|
||||
- Optimize Healthcheck
|
||||
- Remove proxies position animation, improve performance
|
||||
- Add Telegram Link
|
||||
- Update healthcheck policy
|
||||
- New Check URLTest
|
||||
- Fix the problem of invalid auto-selection
|
||||
|
||||
## v0.7.8 (2024-05-05)
|
||||
|
||||
- New Async UpdateConfig
|
||||
- add changeProfileDebounce
|
||||
- Update Workflow
|
||||
- Fix ChangeProfile block
|
||||
- Fix Release Message Error
|
||||
|
||||
## v0.7.7 (2024-05-04)
|
||||
|
||||
- Update Selector 2
|
||||
|
||||
## v0.7.6 (2024-05-04)
|
||||
|
||||
- Update Version
|
||||
- Fix Proxies Select Error
|
||||
|
||||
## v0.7.5 (2024-05-03)
|
||||
|
||||
- Fix the problem that the proxy group is empty in global mode.
|
||||
- Fix the problem that the proxy group is empty in global mode.
|
||||
|
||||
## v0.7.4 (2024-05-03)
|
||||
|
||||
- Add ProxyProvider2
|
||||
|
||||
## v0.7.3 (2024-05-03)
|
||||
|
||||
- Add ProxyProvider
|
||||
- Update Version
|
||||
- Update ProxyGroup Sort
|
||||
- Fix Android quickStart VpnService some problems
|
||||
|
||||
## v0.7.1 (2024-05-01)
|
||||
|
||||
- Update version
|
||||
- Set Android notification low importance
|
||||
- Fix the issue that VpnService can't be closed correctly in special cases
|
||||
- Fix the problem that TileService is not destroyed correctly in some cases
|
||||
- Adjust tab animation defaults
|
||||
- Add Telegram in README_zh_CN.md
|
||||
- Add Telegram
|
||||
|
||||
## v0.7.0 (2024-04-30)
|
||||
|
||||
- update mobile_scanner
|
||||
- Initial commit
|
||||
@@ -0,0 +1,12 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help submodules
|
||||
|
||||
help:
|
||||
@echo 'make submodules # update git submodules (Clash.Meta core sources)'
|
||||
@echo ''
|
||||
@echo 'The Go core and Rust helper build automatically through the setup build'
|
||||
@echo 'hook during flutter build; see .agents/commands.md.'
|
||||
|
||||
submodules:
|
||||
git submodule update --init --recursive
|
||||
@@ -6,24 +6,17 @@
|
||||
|
||||
## FlClash
|
||||
|
||||
<p style="text-align: left;">
|
||||
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
|
||||
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
|
||||
<a href="LICENSE">
|
||||
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
|
||||
</a>
|
||||
</p>
|
||||
[](https://github.com/chen08209/FlClash/releases/)[](https://github.com/chen08209/FlClash/releases/)[](LICENSE)
|
||||
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash on desktop and mobile" src="snapshots/preview.png" width="90%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
@@ -38,9 +31,38 @@ on Mobile:
|
||||
|
||||
✨ Support subscription link, Dark mode
|
||||
|
||||
## Contact
|
||||
## Use
|
||||
|
||||
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
|
||||
### Linux
|
||||
|
||||
⚠️ Make sure to install the following dependencies before using them
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
Support the following actions
|
||||
|
||||
```bash
|
||||
com.follow.clash.action.START
|
||||
|
||||
com.follow.clash.action.STOP
|
||||
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
## Download
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
@@ -55,50 +77,52 @@ on Mobile:
|
||||
|
||||
- android
|
||||
|
||||
1. Install `Android SDK` , `Android NDK`
|
||||
1. Install `Android SDK`, `Android NDK`
|
||||
|
||||
2. Set `ANDROID_NDK` environment variables
|
||||
|
||||
3. Run Build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. You need a windows client
|
||||
|
||||
2. Install `Gcc`,`Inno Setup`
|
||||
2. Set `ANDROID_NDK` environment variable
|
||||
|
||||
3. Run build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. Requires a Windows client
|
||||
|
||||
2. Install `GCC`, `Inno Setup`
|
||||
|
||||
3. Run build script
|
||||
|
||||
```bash
|
||||
dart setup.dart windows
|
||||
```
|
||||
|
||||
- linux
|
||||
|
||||
1. You need a linux client
|
||||
1. Requires a Linux client
|
||||
|
||||
2. Run build script
|
||||
2. Dependencies are auto-installed by setup script, or manually:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
3. Run build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
- macOS
|
||||
|
||||
1. You need a macOS client
|
||||
1. Requires a macOS client
|
||||
|
||||
2. Run build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
## Star
|
||||
|
||||
@@ -108,4 +132,4 @@ The easiest way to support developers is to click on the star (⭐) at the top o
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -6,24 +6,17 @@
|
||||
|
||||
## FlClash
|
||||
|
||||
<p style="text-align: left;">
|
||||
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
|
||||
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
|
||||
<a href="LICENSE">
|
||||
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
|
||||
</a>
|
||||
</p>
|
||||
[](https://github.com/chen08209/FlClash/releases/)[](https://github.com/chen08209/FlClash/releases/)[](LICENSE)
|
||||
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
基于ClashMeta的多平台代理客户端,简单易用,开源无广告。
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash on desktop and mobile" src="snapshots/preview.png" width="90%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
@@ -38,9 +31,38 @@ on Mobile:
|
||||
|
||||
✨ 支持一键导入订阅, 深色模式
|
||||
|
||||
## Contact
|
||||
## Use
|
||||
|
||||
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
|
||||
### Linux
|
||||
|
||||
⚠️ 使用前请确保安装以下依赖
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
支持下列操作
|
||||
|
||||
```bash
|
||||
com.follow.clash.action.START
|
||||
|
||||
com.follow.clash.action.STOP
|
||||
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
## Download
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
@@ -62,29 +84,34 @@ on Mobile:
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart android
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. 你需要一个windows客户端
|
||||
|
||||
2. 安装 `Gcc`,`Inno Setup`
|
||||
2. 安装 `GCC`,`Inno Setup`
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart
|
||||
dart setup.dart windows
|
||||
```
|
||||
|
||||
- linux
|
||||
|
||||
1. 你需要一个linux客户端
|
||||
|
||||
2. 运行构建脚本
|
||||
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
- macOS
|
||||
@@ -94,10 +121,10 @@ on Mobile:
|
||||
2. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
## Star History
|
||||
## Star
|
||||
|
||||
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
|
||||
|
||||
@@ -105,4 +132,4 @@ on Mobile:
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
include: lint_options.yaml
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at
|
||||
# https://dart-lang.github.io/linter/lints/index.html.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- lib/l10n/intl/**
|
||||
- lib/**/generated/**
|
||||
- plugins/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import com.android.build.gradle.tasks.MergeSourceSetFolders
|
||||
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def defStoreFile = file("keystore.jks")
|
||||
def defStorePassword = localProperties.getProperty('storePassword')
|
||||
def defKeyAlias = localProperties.getProperty('keyAlias')
|
||||
def defKeyPassword = localProperties.getProperty('keyPassword')
|
||||
def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias != null && defKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace "com.follow.clash"
|
||||
compileSdkVersion 34
|
||||
ndkVersion "25.1.8937393"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
signingConfigs {
|
||||
if (isRelease){
|
||||
release {
|
||||
storeFile defStoreFile
|
||||
storePassword defStorePassword
|
||||
keyAlias defKeyAlias
|
||||
keyPassword defKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.follow.clash"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 34
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
applicationIdSuffix '.debug'
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
if(isRelease){
|
||||
signingConfig signingConfigs.release
|
||||
}else{
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('copyNativeLibs', Copy) {
|
||||
delete('src/main/jniLibs')
|
||||
from('../../libclash/android')
|
||||
into('src/main/jniLibs')
|
||||
}
|
||||
|
||||
tasks.withType(MergeSourceSetFolders).configureEach {
|
||||
dependsOn copyNativeLibs
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
implementation 'com.google.code.gson:gson:2.10'
|
||||
}
|
||||
|
||||
|
||||
afterEvaluate {
|
||||
assembleDebug.dependsOn copyNativeLibs
|
||||
|
||||
assembleRelease.dependsOn copyNativeLibs
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val releaseStoreFile = file("keystore.jks")
|
||||
val releaseStorePassword = localProperties.getProperty("storePassword")
|
||||
val releaseKeyAlias = localProperties.getProperty("keyAlias")
|
||||
val releaseKeyPassword = localProperties.getProperty("keyPassword")
|
||||
val hasReleaseSigning = releaseStoreFile.exists() &&
|
||||
releaseStorePassword != null &&
|
||||
releaseKeyAlias != null &&
|
||||
releaseKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
ndkVersion = libs.versions.ndkVersion.get()
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.follow.clash"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = releaseStoreFile
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
applicationIdSuffix = ".dev"
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
applicationIdSuffix = ".dev"
|
||||
}
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
|
||||
configure<CrashlyticsExtension> {
|
||||
nativeSymbolUploadEnabled = hasReleaseSigning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/app"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
|
||||
if (hasReleaseSigning) {
|
||||
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
|
||||
finalizedBy("uploadCrashlyticsSymbolFileRelease")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":service"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":core"))
|
||||
implementation(libs.core.splashscreen)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.smali.dexlib2) {
|
||||
exclude(group = "com.google.guava", module = "guava")
|
||||
}
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "dev"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash.debug"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
|
||||
-keep class com.follow.clash.models.**{ *; }
|
||||
-keep class com.follow.clash.models.** { *; }
|
||||
|
||||
-keep class com.follow.clash.service.models.** { *; }
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<application android:label="FlClash Debug" tools:replace="android:label">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash Debug"
|
||||
tools:replace="android:label">
|
||||
<service
|
||||
android:name=".services.FlClashTileService"
|
||||
android:label="FlClash Debug"
|
||||
tools:replace="android:label">
|
||||
</service>
|
||||
android:name=".TileService"
|
||||
android:label="FlClash Debug"
|
||||
tools:replace="android:label" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,48 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="com.android.permission.GET_INSTALLED_APPS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name="${applicationName}"
|
||||
android:name=".FlClashApplication"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:extractNativeLibs="true"
|
||||
android:label="FlClash">
|
||||
android:label="@string/app_name">
|
||||
<activity
|
||||
android:name="com.follow.clash.MainActivity"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
@@ -53,28 +61,38 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="clash"/>
|
||||
<data android:scheme="clashmeta"/>
|
||||
<data android:scheme="flclash"/>
|
||||
<data android:scheme="clash" />
|
||||
<data android:scheme="clashmeta" />
|
||||
<data android:scheme="flclash" />
|
||||
|
||||
<data android:host="install-config"/>
|
||||
<data android:host="install-config" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="io.flutter.embedding.android.EnableImpeller"-->
|
||||
<!-- android:value="true" />-->
|
||||
|
||||
<activity
|
||||
android:name=".TempActivity"
|
||||
android:theme="@style/TransparentTheme" />
|
||||
android:name=".QuickActionActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@style/TransparentTheme">
|
||||
<intent-filter>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="${applicationId}.action.START" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="${applicationId}.action.STOP" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="${applicationId}.action.TOGGLE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".services.FlClashTileService"
|
||||
android:name=".TileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/icon"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="FlClash"
|
||||
android:icon="@drawable/ic"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
@@ -83,18 +101,20 @@
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".services.FlClashVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
|
||||
<receiver
|
||||
android:name=".ServiceBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,105 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val ICON_TTL_DAYS = 1L
|
||||
|
||||
val Application.sharedState: SharedState
|
||||
get() = try {
|
||||
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val json = preferences.getString("flutter.sharedState", null)
|
||||
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
|
||||
} catch (_: Exception) {
|
||||
SharedState()
|
||||
}
|
||||
|
||||
private var lastToast: Toast? = null
|
||||
|
||||
fun Application.showToast(text: String?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val cacheDir = GlobalState.application.cacheDir
|
||||
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
|
||||
return@withContext try {
|
||||
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
|
||||
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
|
||||
if (iconFile.exists() && !isExpired(iconFile)) {
|
||||
return@withContext iconFile.absolutePath
|
||||
}
|
||||
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
|
||||
|
||||
val icon = getApplicationIcon(packageName)
|
||||
saveDrawableToFile(icon, iconFile)
|
||||
iconFile.absolutePath
|
||||
} catch (_: Exception) {
|
||||
val defaultIconFile = File(iconDir, "default_icon.webp")
|
||||
if (!defaultIconFile.exists()) {
|
||||
saveDrawableToFile(defaultActivityIcon, defaultIconFile)
|
||||
}
|
||||
defaultIconFile.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
|
||||
val bitmap = withContext(Dispatchers.Default) {
|
||||
drawable.toBitmap(width = 128, height = 128)
|
||||
}
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
FileOutputStream(file).use { fos ->
|
||||
bitmap.compress(format, 90, fos)
|
||||
}
|
||||
} finally {
|
||||
if (!bitmap.isRecycled) bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExpired(file: File): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val age = now - file.lastModified()
|
||||
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
||||
}
|
||||
|
||||
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
|
||||
return plugins.get(T::class.java) as T?
|
||||
}
|
||||
|
||||
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invokeMethod(method, arguments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.follow.clash.common.GlobalState
|
||||
|
||||
class FlClashApplication : Application() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
GlobalState.init(this)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import java.util.Date
|
||||
|
||||
enum class RunState {
|
||||
START,
|
||||
PENDING,
|
||||
STOP
|
||||
}
|
||||
|
||||
class GlobalState {
|
||||
companion object {
|
||||
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
|
||||
var runTime: Date? = null
|
||||
var flutterEngine: FlutterEngine? = null
|
||||
fun getCurrentTilePlugin(): TilePlugin? =
|
||||
flutterEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
|
||||
|
||||
fun getCurrentAppPlugin(): AppPlugin? =
|
||||
flutterEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
package com.follow.clash
|
||||
|
||||
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.ProxyPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
GlobalState.flutterEngine?.destroy()
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(AppPlugin())
|
||||
flutterEngine.plugins.add(ProxyPlugin())
|
||||
flutterEngine.plugins.add(ServicePlugin())
|
||||
flutterEngine.plugins.add(TilePlugin())
|
||||
GlobalState.flutterEngine = flutterEngine
|
||||
ServiceState.attachFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
GlobalState.flutterEngine = null
|
||||
flutterEngine?.let(ServiceState::detachFlutterEngine)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QuickActionActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
|
||||
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
|
||||
QuickAction.TOGGLE.action -> {
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
|
||||
GlobalState.launch { ServiceState.handleToggleAction() }
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHORTCUT_ID = "toggle"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.BroadcastLease
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ServiceBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
val pendingResult = goAsync()
|
||||
val lease = BroadcastLease { pendingResult.finish() }
|
||||
val timeout = Runnable {
|
||||
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
|
||||
}
|
||||
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
|
||||
GlobalState.launch {
|
||||
try {
|
||||
handleAction(action)
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Unable to handle service broadcast $action: $error")
|
||||
} finally {
|
||||
mainHandler.removeCallbacks(timeout)
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAction(action: String) {
|
||||
when (action) {
|
||||
BroadcastAction.VPN_START_REQUESTED.action -> {
|
||||
GlobalState.log("System requested VPN service start")
|
||||
ServiceState.handleStartAction()
|
||||
}
|
||||
|
||||
BroadcastAction.VPN_REVOKED.action -> {
|
||||
GlobalState.log("VPN permission revoked")
|
||||
ServiceState.handleVpnRevokeAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.intent
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.ManagedService
|
||||
import com.follow.clash.service.ProxyService
|
||||
import com.follow.clash.service.ServiceConfig
|
||||
import com.follow.clash.service.VpnService
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
object ServiceController {
|
||||
private val lock = Mutex()
|
||||
private var binding: ManagedServiceBinding? = null
|
||||
@Volatile
|
||||
private var runTimeMillis = 0L
|
||||
|
||||
suspend fun unbind() = lock.withLock {
|
||||
clearBinding()
|
||||
}
|
||||
|
||||
private fun clearBinding() {
|
||||
binding?.unbind()
|
||||
binding = null
|
||||
}
|
||||
|
||||
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickSetup(
|
||||
initParams: String,
|
||||
setupParams: String,
|
||||
): Result<String> = runCatching {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
Core.quickSetup(initParams, setupParams) { result ->
|
||||
continuation.resume(result.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
|
||||
Core.updateEventListener(callback)
|
||||
}
|
||||
|
||||
suspend fun start(options: VpnOptions): Long = lock.withLock {
|
||||
ServiceConfig.updateVpnOptions(options)
|
||||
val nextIntent = if (options.enable) {
|
||||
VpnService::class.intent
|
||||
} else {
|
||||
ProxyService::class.intent
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
if (binding != null) {
|
||||
tearDownServices()
|
||||
}
|
||||
lateinit var nextBinding: ManagedServiceBinding
|
||||
nextBinding = ManagedServiceBinding(nextIntent) { message ->
|
||||
handleServiceDisconnected(nextBinding, message)
|
||||
}
|
||||
binding = nextBinding
|
||||
nextBinding.bind().onFailure { error ->
|
||||
GlobalState.log("Unable to bind background service: $error")
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
}
|
||||
}
|
||||
|
||||
val currentBinding = binding ?: return@withLock 0L
|
||||
val result = currentBinding.useService { service -> service.start() }
|
||||
if (result.isFailure) {
|
||||
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
|
||||
currentBinding.stopIfConnected()
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to clean up failed background service start: $error")
|
||||
}
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
}
|
||||
|
||||
if (runTimeMillis == 0L) {
|
||||
runTimeMillis = System.currentTimeMillis()
|
||||
}
|
||||
runTimeMillis
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
runTimeMillis = 0L
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
stopServices()
|
||||
}
|
||||
|
||||
// A service the system started itself — always-on VPN, or the sticky restart
|
||||
// after the process was killed — outlives every binding this process holds, so
|
||||
// unbinding alone leaves the tunnel up while the app reports it stopped.
|
||||
private fun stopServices() {
|
||||
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
|
||||
runCatching { GlobalState.application.stopService(intent) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
|
||||
runTimeMillis != 0L && binding?.component == VpnService::class.intent.component
|
||||
}
|
||||
|
||||
fun getRunTimeMillis(): Long = runTimeMillis
|
||||
|
||||
private fun handleServiceDisconnected(
|
||||
disconnectedBinding: ManagedServiceBinding,
|
||||
message: String,
|
||||
) {
|
||||
val token = ServiceState.captureRequestToken()
|
||||
GlobalState.launch {
|
||||
val wasCurrent = lock.withLock {
|
||||
if (binding !== disconnectedBinding) {
|
||||
return@withLock false
|
||||
}
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
true
|
||||
}
|
||||
if (wasCurrent) {
|
||||
ServiceState.handleServiceLost(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ManagedServiceBinding(
|
||||
private val intent: Intent,
|
||||
private val onDisconnected: (String) -> Unit,
|
||||
) : ServiceConnection {
|
||||
val component: ComponentName?
|
||||
get() = intent.component
|
||||
|
||||
private val serviceState = MutableStateFlow<Result<ManagedService>?>(null)
|
||||
|
||||
@Volatile
|
||||
private var isBound = false
|
||||
|
||||
suspend fun bind(): Result<Unit> = runCatching {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
serviceState.value = null
|
||||
isBound = GlobalState.application.bindService(
|
||||
intent,
|
||||
this@ManagedServiceBinding,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
check(isBound) { "bindService() failed" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <R> useService(
|
||||
connectionTimeoutMillis: Long = 5_000,
|
||||
block: suspend (ManagedService) -> R,
|
||||
): Result<R> = runCatching {
|
||||
val service = withTimeout(connectionTimeoutMillis) {
|
||||
serviceState.filterNotNull().first().getOrThrow()
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
block(service)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopIfConnected(): Result<Unit> = runCatching {
|
||||
val service = serviceState.value?.getOrNull() ?: return@runCatching
|
||||
withContext(Dispatchers.Default) {
|
||||
service.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
serviceState.value = null
|
||||
if (!isBound) return
|
||||
isBound = false
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
runCatching {
|
||||
GlobalState.application.unbindService(this)
|
||||
}.onFailure { error ->
|
||||
GlobalState.log("Unable to unbind background service: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
runCatching {
|
||||
when (binder) {
|
||||
is VpnService.LocalBinder -> binder.service
|
||||
is ProxyService.LocalBinder -> binder.service
|
||||
null -> error("Binder is empty")
|
||||
else -> error("Unsupported service binder: ${binder.javaClass.name}")
|
||||
}
|
||||
}.onSuccess { service ->
|
||||
serviceState.value = Result.success(service)
|
||||
}.onFailure { error ->
|
||||
disconnect(error.message.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
disconnect("Service disconnected")
|
||||
}
|
||||
|
||||
override fun onBindingDied(name: ComponentName?) {
|
||||
disconnect("Service binding died")
|
||||
}
|
||||
|
||||
override fun onNullBinding(name: ComponentName?) {
|
||||
disconnect("Service returned an empty binder")
|
||||
}
|
||||
|
||||
private fun disconnect(message: String) {
|
||||
serviceState.value = Result.failure(IllegalStateException(message))
|
||||
onDisconnected(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.models.SharedState
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
object ServiceState {
|
||||
private val machine = ServiceStateMachine(AndroidServiceStateHost)
|
||||
|
||||
val runState = machine.runState
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.detachFlutterEngine(engine)
|
||||
|
||||
suspend fun handleToggleAction() = machine.handleToggleAction()
|
||||
|
||||
suspend fun handleStartAction() = machine.handleStartAction()
|
||||
|
||||
suspend fun handleStopAction() = machine.handleStopAction()
|
||||
|
||||
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
|
||||
|
||||
suspend fun refresh(): Long = machine.refresh()
|
||||
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
|
||||
fun requestStop(): Deferred<Boolean> = machine.requestStop()
|
||||
|
||||
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
|
||||
|
||||
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
|
||||
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.net.VpnService
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import com.follow.clash.service.ServiceConfig
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
|
||||
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
|
||||
*/
|
||||
internal interface ServiceStateHost {
|
||||
val scope: CoroutineScope
|
||||
val runTimeMillis: Long
|
||||
val homeDirPath: String
|
||||
val sdkInt: Int
|
||||
|
||||
fun log(message: String)
|
||||
|
||||
fun showToast(message: String)
|
||||
|
||||
fun setCrashlytics(enabled: Boolean)
|
||||
|
||||
fun updateNotificationParams(params: NotificationParams)
|
||||
|
||||
fun loadSharedState(): SharedState
|
||||
|
||||
fun isVpnPermissionGranted(): Boolean
|
||||
|
||||
fun tile(): TileGateway?
|
||||
|
||||
fun app(): AppGateway?
|
||||
|
||||
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
|
||||
|
||||
suspend fun startService(options: VpnOptions): Long
|
||||
|
||||
suspend fun stopService()
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean
|
||||
}
|
||||
|
||||
internal interface TileGateway {
|
||||
fun handleStart()
|
||||
|
||||
fun handleStop()
|
||||
}
|
||||
|
||||
internal interface AppGateway {
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit)
|
||||
|
||||
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
|
||||
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
|
||||
}
|
||||
|
||||
internal object AndroidServiceStateHost : ServiceStateHost {
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
|
||||
override val scope: CoroutineScope
|
||||
get() = GlobalState
|
||||
|
||||
override val runTimeMillis: Long
|
||||
get() = ServiceController.getRunTimeMillis()
|
||||
|
||||
override val homeDirPath: String
|
||||
get() = GlobalState.application.filesDir.path
|
||||
|
||||
override val sdkInt: Int
|
||||
get() = android.os.Build.VERSION.SDK_INT
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
||||
flutterEngine = engine
|
||||
}
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
||||
if (flutterEngine === engine) {
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun log(message: String) = GlobalState.log(message)
|
||||
|
||||
override fun showToast(message: String) = GlobalState.application.showToast(message)
|
||||
|
||||
override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled)
|
||||
|
||||
override fun updateNotificationParams(params: NotificationParams) =
|
||||
ServiceConfig.updateNotificationParams(params)
|
||||
|
||||
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
|
||||
|
||||
override fun isVpnPermissionGranted(): Boolean =
|
||||
VpnService.prepare(GlobalState.application) == null
|
||||
|
||||
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
|
||||
object : TileGateway {
|
||||
override fun handleStart() = plugin.handleStart()
|
||||
|
||||
override fun handleStop() = plugin.handleStop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
|
||||
object : AppGateway {
|
||||
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
||||
plugin.requestNotificationPermission(callback)
|
||||
|
||||
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
|
||||
plugin.prepareVpn(enable, callback)
|
||||
|
||||
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
|
||||
plugin.cancelVpnPreparation(callback)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
|
||||
ServiceController.quickSetup(initParams, setupParams)
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Long =
|
||||
ServiceController.start(options)
|
||||
|
||||
override suspend fun stopService() = ServiceController.stop()
|
||||
|
||||
override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive()
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.RunIntentArbiter
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
enum class RunState {
|
||||
STARTED,
|
||||
STARTING,
|
||||
STOPPING,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
internal typealias RunRequest = RunIntentArbiter.Token
|
||||
|
||||
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
|
||||
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
|
||||
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
|
||||
internal const val START_FAILED_MESSAGE = "Failed to start service."
|
||||
|
||||
/**
|
||||
* Callers request a transition; the newest request always wins. Every step that outlives its own
|
||||
* suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken
|
||||
* by a stop cannot report itself as started.
|
||||
*/
|
||||
internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
private val transitionLock = Mutex()
|
||||
private val startPreparationLock = Mutex()
|
||||
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
|
||||
private val arbiter = RunIntentArbiter()
|
||||
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
|
||||
@Volatile
|
||||
private var pendingVpnPreparation: (() -> Unit)? = null
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
private val runTimeMillis: Long
|
||||
get() = host.runTimeMillis
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh(): Long = transitionLock.withLock {
|
||||
val current = runTimeMillis
|
||||
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
|
||||
current
|
||||
}
|
||||
|
||||
fun captureRequestToken(): RunRequest = arbiter.current()
|
||||
|
||||
/**
|
||||
* Settles the state after the bound service was lost. [token] is the request that was current
|
||||
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
|
||||
*/
|
||||
suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
|
||||
if (runTimeMillis != 0L) {
|
||||
return@withLock
|
||||
}
|
||||
if (!arbiter.resetToStopped(token)) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStart()
|
||||
return
|
||||
}
|
||||
loadPreferencesAndStart()
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStop()
|
||||
return
|
||||
}
|
||||
host.showToast(sharedState.stopTip)
|
||||
requestStop().await()
|
||||
}
|
||||
|
||||
suspend fun handleVpnRevokeAction() {
|
||||
if (!host.isVpnServiceActive()) {
|
||||
return
|
||||
}
|
||||
handleStopAction()
|
||||
}
|
||||
|
||||
fun requestStart(): Deferred<Boolean> {
|
||||
val request = createRequest(running = true)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
val launchRequest: (Boolean) -> Unit = { shouldStart ->
|
||||
if (!shouldStart) {
|
||||
fail(request)
|
||||
result.complete(false)
|
||||
} else {
|
||||
host.scope.launch {
|
||||
result.complete(
|
||||
runCatching { start(request) }
|
||||
.onFailure { error ->
|
||||
host.log("Unable to process service start request: $error")
|
||||
fail(request)
|
||||
reconcileStopped()
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val app = host.app()
|
||||
if (app != null) {
|
||||
app.requestNotificationPermission(launchRequest)
|
||||
} else {
|
||||
launchRequest(true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun requestStop(): Deferred<Boolean> {
|
||||
val request = createRequest(running = false)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
host.scope.launch {
|
||||
result.complete(
|
||||
runCatching { stop(request) }
|
||||
.onFailure { error ->
|
||||
host.log("Unable to process service stop request: $error")
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
sharedState = state
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
private suspend fun loadPreferencesAndStart() {
|
||||
sharedState = host.loadSharedState()
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
host.showToast(MISSING_CONFIG_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (setupCore()) {
|
||||
if (!requestStart().await()) {
|
||||
host.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySharedState() {
|
||||
host.setCrashlytics(sharedState.crashlytics)
|
||||
host.updateNotificationParams(notificationParams(sharedState))
|
||||
}
|
||||
|
||||
private suspend fun setupCore(): Boolean {
|
||||
applySharedState()
|
||||
host.showToast(sharedState.startTip)
|
||||
return host.quickSetup(
|
||||
initParams(host.homeDirPath, host.sdkInt),
|
||||
Gson().toJson(sharedState.setupParams),
|
||||
).fold(
|
||||
onSuccess = { message ->
|
||||
if (message.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
host.log("Unable to set up core: $message")
|
||||
showConfigError(message)
|
||||
false
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
host.log("Unable to set up core: $error")
|
||||
showConfigError(error.message)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun showConfigError(message: String?) {
|
||||
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
|
||||
}
|
||||
|
||||
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
|
||||
val started = runStart(request)
|
||||
if (!started) {
|
||||
reconcileStopped()
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
private suspend fun runStart(request: RunRequest): Boolean {
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
val options = sharedState.vpnOptions
|
||||
if (options == null) {
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
if (!prepareVpn(options)) {
|
||||
if (host.app() == null && isCurrent(request)) {
|
||||
host.showToast(VPN_PERMISSION_MESSAGE)
|
||||
}
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return transitionLock.withLock transition@{
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) {
|
||||
mutableRunState.value = RunState.STARTED
|
||||
return@transition true
|
||||
}
|
||||
mutableRunState.value = RunState.STARTING
|
||||
val startedAtMillis = host.startService(options)
|
||||
if (startedAtMillis == 0L) {
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
fail(request)
|
||||
return@transition false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
mutableRunState.value = RunState.STARTED
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reconcileStopped() = transitionLock.withLock {
|
||||
if (isRunningRequested() || runTimeMillis == 0L) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
abandonVpnPreparation()
|
||||
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
|
||||
return@withLock true
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
isCurrent(request)
|
||||
}
|
||||
|
||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
||||
val app = host.app()
|
||||
?: return !options.enable || host.isVpnPermissionGranted()
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback: (Boolean) -> Unit = { granted ->
|
||||
pendingVpnPreparation = null
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(granted)
|
||||
}
|
||||
}
|
||||
pendingVpnPreparation = {
|
||||
app.cancelVpnPreparation(callback)
|
||||
callback(false)
|
||||
}
|
||||
continuation.invokeOnCancellation {
|
||||
pendingVpnPreparation = null
|
||||
app.cancelVpnPreparation(callback)
|
||||
}
|
||||
app.prepareVpn(options.enable, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonVpnPreparation() {
|
||||
val abandon = pendingVpnPreparation ?: return
|
||||
pendingVpnPreparation = null
|
||||
abandon()
|
||||
}
|
||||
|
||||
private fun createRequest(running: Boolean): RunRequest = arbiter.request(running)
|
||||
|
||||
private fun isRunningRequested(): Boolean = arbiter.isRunningRequested
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
|
||||
|
||||
private fun fail(request: RunRequest) {
|
||||
arbiter.resetToStopped(request)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
/**
|
||||
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
|
||||
* not an implementation detail.
|
||||
*/
|
||||
fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson(
|
||||
mapOf(
|
||||
"home-dir" to homeDirPath,
|
||||
"version" to sdkInt,
|
||||
),
|
||||
)
|
||||
|
||||
fun notificationParams(state: SharedState): NotificationParams = NotificationParams(
|
||||
title = state.currentProfileName,
|
||||
stopText = state.stopText,
|
||||
onlyStatisticsProxy = state.onlyStatisticsProxy,
|
||||
showStopAction = state.showStopAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
|
||||
class TempActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.toPendingIntent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TileService : android.service.quicksettings.TileService() {
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
scope?.cancel()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
|
||||
scope.launch {
|
||||
ServiceState.refresh()
|
||||
ServiceState.runState.collect(::updateTile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always route through QuickActionActivity: startActivityAndCollapse is
|
||||
// what closes the shade, so toggling the service in place would leave
|
||||
// the panel open after a tap.
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
openQuickAction()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
super.onStopListening()
|
||||
}
|
||||
|
||||
private fun updateTile(runState: RunState) {
|
||||
qsTile?.apply {
|
||||
state = when (runState) {
|
||||
RunState.STARTED -> Tile.STATE_ACTIVE
|
||||
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOPPED -> Tile.STATE_INACTIVE
|
||||
}
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun openQuickAction() {
|
||||
val intent = QuickAction.TOGGLE.quickIntent
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(intent.toPendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.follow.clash.extensions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.system.OsConstants.IPPROTO_TCP
|
||||
import android.system.OsConstants.IPPROTO_UDP
|
||||
import android.util.Base64
|
||||
import java.net.URL
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.follow.clash.models.Metadata
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
|
||||
suspend fun Drawable.getBase64(): String {
|
||||
val drawable = this
|
||||
return withContext(Dispatchers.IO) {
|
||||
val bitmap = drawable.toBitmap()
|
||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
|
||||
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
}
|
||||
|
||||
fun Metadata.getProtocol(): Int? {
|
||||
if (network.startsWith("tcp")) return IPPROTO_TCP
|
||||
if (network.startsWith("udp")) return IPPROTO_UDP
|
||||
return null
|
||||
}
|
||||
|
||||
fun String.getInetSocketAddress(): InetSocketAddress {
|
||||
val url = URL("https://$this")
|
||||
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
data class InstalledPackage(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val system: Boolean,
|
||||
val internet: Boolean,
|
||||
val lastUpdateTime: Long,
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
data class Package(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val isSystem:Boolean
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
data class Process(
|
||||
val id: Int,
|
||||
val metadata: Metadata,
|
||||
)
|
||||
|
||||
data class Metadata(
|
||||
val network: String,
|
||||
val sourceIP: String,
|
||||
val sourcePort: Int,
|
||||
val destinationIP: String,
|
||||
val destinationPort: Int,
|
||||
val host: String
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
enum class AccessControlMode {
|
||||
acceptSelected,
|
||||
rejectSelected,
|
||||
}
|
||||
|
||||
data class AccessControl(
|
||||
val mode: AccessControlMode,
|
||||
val acceptList: List<String>,
|
||||
val rejectList: List<String>,
|
||||
)
|
||||
|
||||
data class Props(
|
||||
val accessControl: AccessControl?,
|
||||
val allowBypass: Boolean?,
|
||||
val systemProxy: Boolean?,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class SharedState(
|
||||
val startTip: String = "Starting VPN...",
|
||||
val stopTip: String = "Stopping VPN...",
|
||||
val crashlytics: Boolean = true,
|
||||
val currentProfileName: String = "FlClash",
|
||||
val stopText: String = "Stop",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
val vpnOptions: VpnOptions? = null,
|
||||
val setupParams: SetupParams? = null,
|
||||
)
|
||||
|
||||
data class SetupParams(
|
||||
@SerializedName("test-url")
|
||||
val testUrl: String,
|
||||
@SerializedName("selected-map")
|
||||
val selectedMap: Map<String, String>,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
/**
|
||||
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
|
||||
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
|
||||
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
|
||||
* separator would turn those into misses.
|
||||
*
|
||||
* The price is that unrelated names starting with the same letters match too.
|
||||
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
|
||||
* boundary, because there the entries are whole package roots.
|
||||
*/
|
||||
internal object ChinaPackageMatcher {
|
||||
|
||||
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
|
||||
packageName == it || packageName.startsWith("$it.")
|
||||
}
|
||||
|
||||
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
|
||||
|
||||
fun classNameOf(descriptor: String): String = descriptor
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
|
||||
private val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
// Caught by the loose "com.mx" prefix, which targets Maxthon's
|
||||
// com.mx.browser. MX Player is unrelated.
|
||||
"com.mxtech",
|
||||
// Caught by the loose "com.stub" prefix, which targets the
|
||||
// com.stub.StubApp packer. StubHub is unrelated.
|
||||
"com.stubhub",
|
||||
)
|
||||
|
||||
private val CHINA_PACKAGE_REGEX = listOf(
|
||||
"com.tencent",
|
||||
"com.alibaba",
|
||||
"com.umeng",
|
||||
"com.qihoo",
|
||||
"com.ali",
|
||||
"com.alipay",
|
||||
"com.amap",
|
||||
"com.sina",
|
||||
"com.weibo",
|
||||
"com.vivo",
|
||||
"com.xiaomi",
|
||||
"com.huawei",
|
||||
"com.taobao",
|
||||
"com.secneo",
|
||||
"s.h.e.l.l",
|
||||
"com.stub",
|
||||
"com.kiwisec",
|
||||
"com.secshell",
|
||||
"com.wrapper",
|
||||
"cn.securitystack",
|
||||
"com.mogosec",
|
||||
"com.secoen",
|
||||
"com.netease",
|
||||
"com.mx",
|
||||
"com.qq.e",
|
||||
"com.baidu",
|
||||
"com.bytedance",
|
||||
"com.bugly",
|
||||
"com.miui",
|
||||
"com.oppo",
|
||||
"com.coloros",
|
||||
"com.iqoo",
|
||||
"com.meizu",
|
||||
"com.gionee",
|
||||
"cn.nubia",
|
||||
"com.oplus",
|
||||
"andes.oplus",
|
||||
"com.unionpay",
|
||||
"cn.wps",
|
||||
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
|
||||
Regex.escape(prefix)
|
||||
}.toRegex()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import com.follow.clash.models.InstalledPackage
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
internal class PackageResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val appPackageName: String,
|
||||
) {
|
||||
private val cacheLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cachedPackages: List<InstalledPackage>? = null
|
||||
|
||||
val installedPackages: List<InstalledPackage>
|
||||
get() = cachedPackages ?: synchronized(cacheLock) {
|
||||
cachedPackages ?: loadPackages().also { cachedPackages = it }
|
||||
}
|
||||
|
||||
val isInstalledAppsPermissionSupported: Boolean by lazy {
|
||||
runCatching { packageManager.getPermissionInfo(GET_INSTALLED_APPS, 0) }.isSuccess
|
||||
}
|
||||
|
||||
fun hasInstalledAppsPermission(): Boolean = !isInstalledAppsPermissionSupported ||
|
||||
packageManager.checkPermission(GET_INSTALLED_APPS, appPackageName) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun invalidate() {
|
||||
synchronized(cacheLock) { cachedPackages = null }
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
.map { it.packageName }
|
||||
.filter(::isChinaPackage)
|
||||
|
||||
private fun loadPackages(): List<InstalledPackage> {
|
||||
val flags = PackageManager.GET_PERMISSIONS
|
||||
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getInstalledPackages(
|
||||
PackageManager.PackageInfoFlags.of(flags.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstalledPackages(flags)
|
||||
}
|
||||
return packages.asSequence()
|
||||
.filter { info ->
|
||||
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
|
||||
}
|
||||
.map { info ->
|
||||
InstalledPackage(
|
||||
packageName = info.packageName,
|
||||
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
|
||||
?: info.packageName,
|
||||
system = info.applicationInfo?.let { applicationInfo ->
|
||||
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
} == true,
|
||||
internet = info.requestedPermissions
|
||||
?.contains(Manifest.permission.INTERNET) == true,
|
||||
lastUpdateTime = info.lastUpdateTime,
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
if (ChinaPackageMatcher.isSkipped(packageName)) {
|
||||
return false
|
||||
}
|
||||
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun getPackageInfo(packageName: String) = if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName,
|
||||
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
|
||||
}
|
||||
|
||||
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
|
||||
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
}
|
||||
|
||||
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
|
||||
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
|
||||
return false
|
||||
}
|
||||
archive.entries().asSequence()
|
||||
.filter { entry ->
|
||||
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
|
||||
}.any { entry ->
|
||||
if (entry.size > MAX_DEX_SIZE_BYTES) {
|
||||
return@any true
|
||||
}
|
||||
val dexFile = archive.getInputStream(entry).buffered().use { input ->
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
}
|
||||
dexFile.classes.any { clazz ->
|
||||
ChinaPackageMatcher.matchesKnownPrefix(
|
||||
ChinaPackageMatcher.classNameOf(clazz.type),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
|
||||
|
||||
private const val ANDROID_PACKAGE_NAME = "android"
|
||||
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
private val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or
|
||||
PackageManager.GET_PROVIDERS
|
||||
}
|
||||
}
|
||||
@@ -1,216 +1,467 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.follow.clash.extensions.getBase64
|
||||
import com.follow.clash.extensions.getInetSocketAddress
|
||||
import com.follow.clash.extensions.getProtocol
|
||||
import com.follow.clash.models.Process
|
||||
import com.follow.clash.models.Package
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.follow.clash.R
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.PendingCallback
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.registerReceiverCompat
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||
|
||||
private var activity: Activity? = null
|
||||
|
||||
private var toast: Toast? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private var context: Context? = null
|
||||
private val activityResultListener =
|
||||
PluginRegistry.ActivityResultListener(::onActivityResult)
|
||||
|
||||
private val permissionsResultListener =
|
||||
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private var connectivity: ConnectivityManager? = null
|
||||
private val vpnPrepareCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val iconMap = mutableMapOf<String, String?>()
|
||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = flutterPluginBinding.applicationContext;
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
|
||||
channel.setMethodCallHandler(this)
|
||||
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var isRequestingNotificationPermission = false
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val packageResolver by lazy {
|
||||
PackageResolver(
|
||||
GlobalState.application.packageManager,
|
||||
GlobalState.application.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
private var packageChangeContext: Context? = null
|
||||
|
||||
private fun tip(message: String?) {
|
||||
if (toast != null) {
|
||||
toast!!.cancel()
|
||||
private val packageChangeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
|
||||
return
|
||||
}
|
||||
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
|
||||
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
if (addedByUpdate) {
|
||||
return
|
||||
}
|
||||
packageResolver.invalidate()
|
||||
channel.invokeMethod("packagesChanged", null)
|
||||
}
|
||||
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
|
||||
toast!!.show()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
private var skipNotificationPermissionRequest = false
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* The permission and consent hops below touch the Activity — starting an
|
||||
* activity for result, raising a permission prompt — and read the state that
|
||||
* tracks whether one is already up. Their callers are coroutines on
|
||||
* [Dispatchers.Default], while the answers come back on the main thread
|
||||
* through the ActivityAware listeners, so main is the one thread both ends
|
||||
* can agree on. A plain main-looper post rather than the plugin scope: the
|
||||
* scope is cancelled when the engine detaches, and a request dropped there
|
||||
* would leave its caller waiting for a callback that can no longer run.
|
||||
*/
|
||||
private fun onMainThread(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, rawResult: Result) {
|
||||
val result = MainThreadResult(rawResult)
|
||||
when (call.method) {
|
||||
"moveTaskToBack" -> {
|
||||
activity?.moveTaskToBack(true)
|
||||
result.success(true);
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"getPackages" -> {
|
||||
scope.launch {
|
||||
result.success(getPackages())
|
||||
"updateExcludeFromRecents" -> {
|
||||
val value = call.argument<Boolean>("value")
|
||||
updateExcludeFromRecents(value)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"initShortcuts" -> {
|
||||
val label = call.arguments as? String
|
||||
if (label == null) {
|
||||
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
|
||||
} else {
|
||||
initShortcuts(label)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
"getPackages" -> reply(result) {
|
||||
gson.toJson(packageResolver.installedPackages)
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> reply(result) {
|
||||
gson.toJson(packageResolver.getChinaPackageNames())
|
||||
}
|
||||
|
||||
"isInstalledAppsPermissionGranted" -> reply(result) {
|
||||
packageResolver.hasInstalledAppsPermission()
|
||||
}
|
||||
|
||||
"requestInstalledAppsPermission" -> {
|
||||
requestInstalledAppsPermission { granted -> result.success(granted) }
|
||||
}
|
||||
|
||||
"getPackageIcon" -> {
|
||||
scope.launch {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.success(null)
|
||||
return@launch
|
||||
}
|
||||
val packageIcon = getPackageIcon(packageName)
|
||||
packageIcon.let {
|
||||
if (it != null) {
|
||||
result.success(it)
|
||||
return@launch
|
||||
}
|
||||
if (iconMap["default"] == null) {
|
||||
iconMap["default"] =
|
||||
context?.packageManager?.defaultActivityIcon?.getBase64()
|
||||
}
|
||||
result.success(iconMap["default"])
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"resolverProcess" -> {
|
||||
val data = call.argument<String>("data")
|
||||
val process =
|
||||
if (data != null) Gson().fromJson(
|
||||
data,
|
||||
Process::class.java
|
||||
) else null
|
||||
val metadata = process?.metadata
|
||||
val protocol = metadata?.getProtocol()
|
||||
if (protocol == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q){
|
||||
result.success(null)
|
||||
return@withContext
|
||||
}
|
||||
if (context == null) {
|
||||
result.success(null)
|
||||
return@withContext
|
||||
}
|
||||
if (connectivity == null) {
|
||||
connectivity = context!!.getSystemService<ConnectivityManager>()
|
||||
}
|
||||
val src = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
|
||||
val dst = InetSocketAddress(
|
||||
metadata.destinationIP.ifEmpty { metadata.host },
|
||||
metadata.destinationPort
|
||||
)
|
||||
val uid = try {
|
||||
connectivity?.getConnectionOwnerUid(protocol, src, dst)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (uid == null || uid == -1) {
|
||||
result.success(null)
|
||||
return@withContext
|
||||
}
|
||||
val packages = context?.packageManager?.getPackagesForUid(uid)
|
||||
result.success(packages?.first())
|
||||
}
|
||||
}
|
||||
handleGetPackageIcon(call, result)
|
||||
}
|
||||
|
||||
"tip" -> {
|
||||
val message = call.argument<String>("message")
|
||||
tip(message)
|
||||
GlobalState.application.showToast(message)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"isBatteryOptimizationDisabled" -> {
|
||||
result.success(isBatteryOptimizationDisabled())
|
||||
}
|
||||
|
||||
"openBatteryOptimizationSettings" -> {
|
||||
result.success(openBatteryOptimizationSettings())
|
||||
}
|
||||
|
||||
"openAppSettings" -> {
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> reply(result) {
|
||||
GlobalState.didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented();
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getPackageIcon(packageName: String): String? {
|
||||
val packageManager = context?.packageManager
|
||||
if (iconMap[packageName] == null) {
|
||||
iconMap[packageName] = try {
|
||||
packageManager?.getApplicationIcon(packageName)?.getBase64()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
|
||||
val packageName = call.argument<String>("packageName") ?: ""
|
||||
GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
}
|
||||
|
||||
// A throw inside the plugin scope has no handler and ends the process;
|
||||
// the Dart caller gets a PlatformException to handle instead.
|
||||
private fun reply(result: Result, block: suspend () -> Any?) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
result.success(block())
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Platform call failed: $error")
|
||||
result.error("PLATFORM_ERROR", error.toString(), null)
|
||||
}
|
||||
|
||||
}
|
||||
return iconMap[packageName]
|
||||
}
|
||||
|
||||
private suspend fun getPackages(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val packageManager = context?.packageManager
|
||||
val packages: List<Package>? =
|
||||
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA)?.filter {
|
||||
it.packageName != context?.packageName
|
||||
|| it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||
|| it.packageName == "android"
|
||||
|
||||
}?.map {
|
||||
Package(
|
||||
packageName = it.packageName,
|
||||
label = it.applicationInfo.loadLabel(packageManager).toString(),
|
||||
isSystem = (it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) == 1
|
||||
)
|
||||
}
|
||||
Gson().toJson(packages)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestGc() {
|
||||
channel.invokeMethod("gc", null)
|
||||
private fun initShortcuts(label: String) {
|
||||
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
|
||||
setShortLabel(label)
|
||||
setIcon(
|
||||
IconCompat.createWithResource(
|
||||
GlobalState.application,
|
||||
R.mipmap.ic_launcher_round,
|
||||
),
|
||||
)
|
||||
setIntent(QuickAction.TOGGLE.quickIntent)
|
||||
build()
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(
|
||||
GlobalState.application,
|
||||
listOf(shortcut),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationDisabled(): Boolean {
|
||||
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
|
||||
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
|
||||
?: false
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings(): Boolean {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(): Boolean {
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun updateExcludeFromRecents(value: Boolean?) {
|
||||
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
|
||||
val task = am?.appTasks?.firstOrNull {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
it.taskInfo.taskId == activity?.taskId
|
||||
} else {
|
||||
it.taskInfo.id == activity?.taskId
|
||||
}
|
||||
}
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
}
|
||||
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestNotificationCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permission = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
||||
invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (isRequestingNotificationPermission) {
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingNotificationPermission = true
|
||||
activity?.let {
|
||||
ActivityCompat.requestPermissions(
|
||||
it,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
} ?: invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
invokeRequestNotificationCallback(true)
|
||||
}
|
||||
|
||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
||||
isRequestingNotificationPermission = false
|
||||
requestNotificationCallback.resolve(shouldStart)
|
||||
}
|
||||
|
||||
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestInstalledAppsCallback.replace(callback, supersededValue = false)
|
||||
val activity = activity
|
||||
if (packageResolver.hasInstalledAppsPermission()) {
|
||||
invokeRequestInstalledAppsCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (activity == null) {
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(PackageResolver.GET_INSTALLED_APPS),
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
|
||||
if (granted) {
|
||||
packageResolver.invalidate()
|
||||
}
|
||||
requestInstalledAppsCallback.resolve(granted)
|
||||
}
|
||||
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.replace(callback, supersededValue = false)
|
||||
if (!needPrepare) {
|
||||
invokeVpnPrepareCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
invokeVpnPrepareCallback(false)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
return@onMainThread
|
||||
}
|
||||
invokeVpnPrepareCallback(true)
|
||||
}
|
||||
|
||||
// Posted rather than run where the cancellation lands, so it stays ordered
|
||||
// behind the prepareVpn that installed the callback it is cancelling.
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.cancel(callback)
|
||||
}
|
||||
|
||||
private fun invokeVpnPrepareCallback(granted: Boolean) {
|
||||
vpnPrepareCallback.resolve(granted)
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||
channel.setMethodCallHandler(this)
|
||||
watchPackageChanges(flutterPluginBinding.applicationContext)
|
||||
}
|
||||
|
||||
private fun watchPackageChanges(context: Context) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiverCompat(packageChangeReceiver, filter)
|
||||
packageChangeContext = context
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
|
||||
packageChangeContext = null
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity;
|
||||
scope = CoroutineScope(Dispatchers.Default)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
private fun attachToActivity(binding: ActivityPluginBinding) {
|
||||
detachFromActivity()
|
||||
activityBinding = binding
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
binding.addRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
activity = null
|
||||
val binding = activityBinding ?: return
|
||||
activityBinding = null
|
||||
binding.removeActivityResultListener(activityResultListener)
|
||||
binding.removeRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null;
|
||||
detachFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity;
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
scope.cancel()
|
||||
activity = null;
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
}
|
||||
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onRequestPermissionsResultListener(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean = when (requestCode) {
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
true
|
||||
}
|
||||
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
|
||||
invokeRequestInstalledAppsCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
|
||||
* requires it, from handlers that finish on a background dispatcher.
|
||||
*
|
||||
* The hop goes through the main looper rather than the plugin's coroutine scope:
|
||||
* that scope is cancelled when the engine detaches, and a reply dropped by a
|
||||
* cancelled scope leaves the Dart future waiting forever. A reply posted after
|
||||
* the engine is gone is the messenger's problem to ignore, and it does.
|
||||
*/
|
||||
internal class MainThreadResult(
|
||||
private val delegate: MethodChannel.Result,
|
||||
) : MethodChannel.Result {
|
||||
override fun success(result: Any?) = post { delegate.success(result) }
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) =
|
||||
post { delegate.error(code, message, details) }
|
||||
|
||||
override fun notImplemented() = post { delegate.notImplemented() }
|
||||
|
||||
private fun post(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build.VERSION
|
||||
import android.os.Build.VERSION_CODES
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.follow.clash.GlobalState
|
||||
import com.follow.clash.RunState
|
||||
import com.follow.clash.models.Props
|
||||
import com.follow.clash.services.FlClashVpnService
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.Date
|
||||
|
||||
|
||||
class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||
|
||||
|
||||
val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
|
||||
private lateinit var flutterMethodChannel: MethodChannel
|
||||
|
||||
private var activity: Activity? = null
|
||||
private var context: Context? = null
|
||||
private var flClashVpnService: FlClashVpnService? = null
|
||||
private var isBound = false
|
||||
private var port: Int? = null
|
||||
private var props: Props? = null
|
||||
private lateinit var title: String
|
||||
private lateinit var content: String
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as FlClashVpnService.LocalBinder
|
||||
flClashVpnService = binder.getService()
|
||||
port?.let { startVpn(it) }
|
||||
isBound = true
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
flClashVpnService = null
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = flutterPluginBinding.applicationContext
|
||||
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "proxy")
|
||||
flutterMethodChannel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
flutterMethodChannel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
||||
"StartProxy" -> {
|
||||
port = call.argument<Int>("port")
|
||||
val args = call.argument<String>("args")
|
||||
props =
|
||||
if (args != null) Gson().fromJson(args, Props::class.java) else null
|
||||
handleStartVpn()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"StopProxy" -> {
|
||||
stopVpn()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"SetProtect" -> {
|
||||
val fd = call.argument<Int>("fd")
|
||||
if (fd != null) {
|
||||
flClashVpnService?.protect(fd)
|
||||
result.success(true)
|
||||
} else {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
"GetRunTimeStamp" -> {
|
||||
result.success(GlobalState.runTime?.time)
|
||||
}
|
||||
|
||||
"startForeground" -> {
|
||||
title = call.argument<String>("title") as String
|
||||
content = call.argument<String>("content") as String
|
||||
requestNotificationsPermission()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartVpn() {
|
||||
val intent = VpnService.prepare(context)
|
||||
if (intent != null) {
|
||||
activity?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
} else {
|
||||
bindService()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startVpn(port: Int) {
|
||||
if (GlobalState.runState.value == RunState.START) return;
|
||||
flClashVpnService?.start(port, props)
|
||||
GlobalState.runState.value = RunState.START
|
||||
GlobalState.runTime = Date()
|
||||
startAfter()
|
||||
}
|
||||
|
||||
private fun stopVpn() {
|
||||
if (GlobalState.runState.value == RunState.STOP) return
|
||||
flClashVpnService?.stop()
|
||||
unbindService()
|
||||
GlobalState.runState.value = RunState.STOP;
|
||||
GlobalState.runTime = null;
|
||||
}
|
||||
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForeground() {
|
||||
if (GlobalState.runState.value != RunState.START) return
|
||||
flClashVpnService?.startForeground(title, content)
|
||||
}
|
||||
|
||||
private fun requestNotificationsPermission() {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
|
||||
val permission = context?.let {
|
||||
ContextCompat.checkSelfPermission(
|
||||
it,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
}
|
||||
if (permission == PackageManager.PERMISSION_GRANTED) {
|
||||
startForeground()
|
||||
} else {
|
||||
activity?.let {
|
||||
ActivityCompat.requestPermissions(
|
||||
it,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startForeground()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAfter() {
|
||||
flutterMethodChannel.invokeMethod("startAfter", flClashVpnService?.fd)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(::onActivityResult)
|
||||
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
||||
if (resultCode == FlutterActivity.RESULT_OK) {
|
||||
bindService()
|
||||
} else {
|
||||
stopVpn()
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private fun onRequestPermissionsResultListener(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
): Boolean {
|
||||
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
startForeground()
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null;
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
stopVpn()
|
||||
activity = null
|
||||
}
|
||||
|
||||
private fun bindService() {
|
||||
val intent = Intent(context, FlClashVpnService::class.java)
|
||||
context?.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
private fun unbindService() {
|
||||
if (isBound) {
|
||||
context?.unbindService(connection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import com.follow.clash.ServiceController
|
||||
import com.follow.clash.ServiceState
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private lateinit var channel: MethodChannel
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val gson = Gson()
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
ServiceController.setEventListener(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
|
||||
// Most handlers below reply from a scope worker on Dispatchers.Default,
|
||||
// but a MethodChannel.Result has to be answered on the platform thread.
|
||||
// Wrapping once here covers every branch, including notImplemented.
|
||||
val result = MainThreadResult(rawResult)
|
||||
when (call.method) {
|
||||
"init" -> initialize(result)
|
||||
"shutdown" -> shutdown(result)
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
"getRunTime" -> getRunTime(result)
|
||||
"syncState" -> syncState(call, result)
|
||||
"start" -> start(result)
|
||||
"stop" -> stop(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialize(result: MethodChannel.Result) {
|
||||
ServiceController.setEventListener(::sendEvent)
|
||||
.onSuccess { result.success("") }
|
||||
.onFailure { error -> result.success(error.message.orEmpty()) }
|
||||
}
|
||||
|
||||
private fun shutdown(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
ServiceController.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
if (data == null) {
|
||||
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRunTime(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
result.success(ServiceState.refresh())
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
val state = runCatching {
|
||||
gson.fromJson(data, SharedState::class.java)
|
||||
}.getOrNull()
|
||||
if (state == null) {
|
||||
result.success("Invalid shared state")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceState.syncSharedState(state)
|
||||
result.success("")
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(result: MethodChannel.Result) {
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun stop(result: MethodChannel.Result) {
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun sendEvent(value: String?) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
channel.invokeMethod("event", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,33 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.invokeMethodOnMainThread
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class TilePlugin(private val onStart: (() -> Unit)? = null, private val onStop: (() -> Unit)? = null) : FlutterPlugin,
|
||||
MethodChannel.MethodCallHandler {
|
||||
|
||||
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "tile")
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/tile")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
handleDetached()
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
fun handleStart() {
|
||||
onStart?.let { it() }
|
||||
channel.invokeMethod("start", null)
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethod("stop", null)
|
||||
onStop?.let { it() }
|
||||
channel.invokeMethodOnMainThread("stop")
|
||||
}
|
||||
|
||||
private fun handleDetached() {
|
||||
channel.invokeMethod("detached", null)
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.follow.clash.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.lifecycle.Observer
|
||||
import com.follow.clash.GlobalState
|
||||
import com.follow.clash.RunState
|
||||
import com.follow.clash.TempActivity
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.ProxyPlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
|
||||
|
||||
class FlClashTileService : TileService() {
|
||||
|
||||
private val observer = Observer<RunState> { runState ->
|
||||
updateTile(runState)
|
||||
}
|
||||
|
||||
private fun updateTile(runState: RunState) {
|
||||
if (qsTile != null) {
|
||||
qsTile.state = when (runState) {
|
||||
RunState.START -> Tile.STATE_ACTIVE
|
||||
RunState.PENDING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOP -> Tile.STATE_INACTIVE
|
||||
}
|
||||
qsTile.updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
GlobalState.runState.value?.let { updateTile(it) }
|
||||
GlobalState.runState.observeForever(observer)
|
||||
}
|
||||
|
||||
private fun activityTransfer() {
|
||||
val intent = Intent(this, TempActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
} else {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
}else{
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private var flutterEngine: FlutterEngine? = null;
|
||||
|
||||
private fun initFlutterEngine() {
|
||||
flutterEngine = FlutterEngine(this)
|
||||
flutterEngine?.plugins?.add(ProxyPlugin())
|
||||
flutterEngine?.plugins?.add(TilePlugin())
|
||||
flutterEngine?.plugins?.add(AppPlugin())
|
||||
GlobalState.flutterEngine = flutterEngine
|
||||
if (flutterEngine?.dartExecutor?.isExecutingDart != true) {
|
||||
val vpnService = DartExecutor.DartEntrypoint(
|
||||
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
|
||||
"vpnService"
|
||||
)
|
||||
flutterEngine?.dartExecutor?.executeDartEntrypoint(vpnService)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
activityTransfer()
|
||||
val currentTilePlugin = GlobalState.getCurrentTilePlugin()
|
||||
if (GlobalState.runState.value == RunState.STOP) {
|
||||
GlobalState.runState.value = RunState.PENDING
|
||||
if(currentTilePlugin == null){
|
||||
initFlutterEngine()
|
||||
}else{
|
||||
currentTilePlugin.handleStart()
|
||||
}
|
||||
} else if(GlobalState.runState.value == RunState.START){
|
||||
GlobalState.runState.value = RunState.PENDING
|
||||
currentTilePlugin?.handleStop()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
GlobalState.runState.removeObserver(observer)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package com.follow.clash.services
|
||||
|
||||
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
import android.net.ProxyInfo
|
||||
import android.net.VpnService
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.follow.clash.GlobalState
|
||||
import com.follow.clash.MainActivity
|
||||
import com.follow.clash.R
|
||||
import com.follow.clash.models.AccessControlMode
|
||||
import com.follow.clash.models.Props
|
||||
|
||||
|
||||
class FlClashVpnService : VpnService() {
|
||||
|
||||
|
||||
private val CHANNEL = "FlClash"
|
||||
|
||||
var fd: Int? = null
|
||||
private val notificationId: Int = 1
|
||||
|
||||
private val passList = listOf(
|
||||
"*zhihu.com",
|
||||
"*zhimg.com",
|
||||
"*jd.com",
|
||||
"100ime-iat-api.xfyun.cn",
|
||||
"*360buyimg.com",
|
||||
"localhost",
|
||||
"*.local",
|
||||
"127.*",
|
||||
"10.*",
|
||||
"172.16.*",
|
||||
"172.17.*",
|
||||
"172.18.*",
|
||||
"172.19.*",
|
||||
"172.2*",
|
||||
"172.30.*",
|
||||
"172.31.*",
|
||||
"192.168.*"
|
||||
)
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
fun start(port: Int, props: Props?) {
|
||||
fd = with(Builder()) {
|
||||
addAddress("172.16.0.1", 30)
|
||||
setMtu(9000)
|
||||
addRoute("0.0.0.0", 0)
|
||||
props?.accessControl?.let { accessControl ->
|
||||
when (accessControl.mode) {
|
||||
AccessControlMode.acceptSelected -> {
|
||||
(accessControl.acceptList + packageName).forEach {
|
||||
addAllowedApplication(it)
|
||||
}
|
||||
}
|
||||
|
||||
AccessControlMode.rejectSelected -> {
|
||||
(accessControl.rejectList - packageName).forEach {
|
||||
addDisallowedApplication(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
addDnsServer("172.16.0.2")
|
||||
setSession("FlClash")
|
||||
setBlocking(false)
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
setMetered(false)
|
||||
}
|
||||
if (props?.allowBypass == true) {
|
||||
allowBypass()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && props?.systemProxy == true) {
|
||||
setHttpProxy(
|
||||
ProxyInfo.buildDirectProxy(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
passList
|
||||
)
|
||||
)
|
||||
}
|
||||
establish()?.detachFd()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopSelf()
|
||||
stopForeground()
|
||||
}
|
||||
|
||||
|
||||
private val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
|
||||
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
} else {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
with(NotificationCompat.Builder(this, CHANNEL)) {
|
||||
setSmallIcon(R.drawable.ic_stat_name)
|
||||
setContentTitle("FlClash")
|
||||
setContentIntent(pendingIntent)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
priority = NotificationCompat.PRIORITY_MIN
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||
}
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setAutoCancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
GlobalState.getCurrentAppPlugin()?.requestGc()
|
||||
}
|
||||
|
||||
fun startForeground(title: String, content: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
var channel = manager?.getNotificationChannel(CHANNEL)
|
||||
if (channel == null) {
|
||||
channel =
|
||||
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
val notification =
|
||||
notificationBuilder.setContentTitle(title).setContentText(content).build()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopForeground() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): FlClashVpnService = this@FlClashVpnService
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
GlobalState.getCurrentTilePlugin()?.handleStop();
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stop()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 763 B |
|
Before Width: | Height: | Size: 520 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,25 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group android:scaleX="0.924"
|
||||
android:scaleY="0.924"
|
||||
android:translateX="9.12"
|
||||
android:translateY="9.12">
|
||||
<group android:scaleX="0.63461536"
|
||||
android:scaleY="0.63461536"
|
||||
android:translateX="45.96154"
|
||||
android:translateY="43.846153">
|
||||
<path
|
||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,21 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9"
|
||||
android:translateX="15.23"
|
||||
android:translateY="12.22">
|
||||
<path
|
||||
android:fillColor="#6666FB"
|
||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z" />
|
||||
<path
|
||||
android:fillColor="#336AB6"
|
||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z" />
|
||||
<path
|
||||
android:fillColor="#5CA8E9"
|
||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 118 KiB |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 886 B |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_tv" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 782 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.3 KiB |