classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its
classes and libraries (see Android Core Build).
- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter
crosses the `${packageName}/service` MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin`
rather than calling the Core directly.
crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core
directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method
calls.
- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so
main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;
a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background
task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on
the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and
`NetworkObserveModule` refreshes DNS on its own looper.
-`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.
@@ -87,16 +94,28 @@ into `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use
section reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops
it on Android, where the seccomp policy kills the process for `settimeofday`.
A custom profile's TUN page is the exception to covering the raw struct. The network settings own `tun` through
`PatchClashConfig.tun`, spread last over the profile's section and patched again on every`updateConfig`, so
`TunOverrideKey` offers only a few keys outside it that work in FlClash's TUN: `disable-icmp-forwarding`, and
`exclude-interface`, which only Linux routing reads. `test/models/tun_override_test.dart` fails if a key comes to
overlap `Tun`. Android starts its VPN from `core/tun` without the section, so TUN is listed on desktop only.
Being a part of the section, the page has no quick edit; see Quick Edit.
TUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,
`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every
`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide
(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`
rows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,
congestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless
picked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move
carries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task
appends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes
`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at
that value. A key sing-tun reads on some platforms only is listed only there
(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port
filters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their
descriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.
`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from
`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick
edit; see Quick Edit.
A custom profile's overview keeps tiles for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" tile, which opens a page listing them with their states.
Another rarely changed section joins that list rather than taking a tile of its own.
A custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" row, which opens a page listing them with their states.
Both lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins
the "more" list rather than taking a row of its own.
Every page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the
section's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the
@@ -151,7 +170,8 @@ or a remote party reaching the privileged Core's control or filesystem APIs is a
A process running as the same account is not stopped. It can ask the Helper to start the Core
against a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig` reads any path.
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,
`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.
The Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot
distinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root
or user-to-SYSTEM privilege escalation, not a complete security fix. An open external
@@ -319,6 +339,20 @@ directions. The envelope is the only JSON serialization layer: keep arguments, r
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
remain strings.
A response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`
(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a
direct buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only
from that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.
A profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without
`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.
`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`
puts the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`
for the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a
profile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`
merges into the spill directory, and the extend page only the names
and types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.
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
@@ -346,9 +380,15 @@ Dialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo
back to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is
already dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every
three seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an include-all that no
filter narrows; what filters and providers pick, including provider proxies that carry their own `dialer-proxy`, is
left to this guard.
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`
that no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies
that carry their own `dialer-proxy`, is left to this guard.
`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so
import and quick edit keep it. They take in only what the config declares, which is what some group of the profile
names: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other
proxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by
v0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.
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.
@@ -376,8 +416,8 @@ holds the group; `delayTestPageGroup` marks it, ignores a second request for the
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
The dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds its check and the
exit IP beside it, and `NetworkDetection` holds the exit IP, whose tap to retry stays inert meanwhile.
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each
tile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.
`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the
probe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;
without the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting
@@ -423,9 +463,34 @@ a new IP and an unchanged IP reuses the result. A lookup races its sources the w
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.
## UI Layer
`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the
scaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from
them that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.
`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.
`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave
`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none
may import Riverpod. A component that needs app state reads what the app provides above its navigator:
-`ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls
over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,
`isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild
when another changes; `context.isMobileView` reads it without listening.
-`MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.
- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and
the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the
scale changes; a global copy or a top-level final would keep the scale it was first read at.
-`UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`
in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.
A test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from
`test/helpers/test_app.dart` in its builder, as `TestApp` does.
## Icons
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
`lib/ui/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
@@ -451,8 +516,8 @@ some other state, like the sidebar toggle that tracks the pane's width, is a bui
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
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and
`FilledButton`in its tonal and icon forms 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
@@ -464,7 +529,7 @@ Framework widgets that draw their own icon take it from the theme: the `ActionIc
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
Empty states draw no image assets either. `NullStatus` in `lib/ui/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
@@ -482,18 +547,227 @@ button while searching) when there is one, then foldable actions, primary action
the icon actions ahead of it;
everything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a
disabled item. The bar reads as one button group of two beside a single button: the overflow menu button when
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.`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
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.
The bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in
the app floats an action button: a secondary action such as selecting every app goes into `menuItems`. Saving or
confirming, in a page, a side sheet or a form, is a check in`confirmationAction`, never among `iconActions`: it ends
the bar and never folds, and only a form sheet moves it to its foot (Forms). Bar buttons are glyphs with tooltips,
select all included; the one text button is the custom profile's Import, a filled button whose `CommonPopupBox` opens
the import menu. 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.
or clear button, so they never fold. A sheet's close button leads its bar when the bar has actions, except beside a
primary action: then the two end the bar as one button group, as the proxies sheet's delay test and close do.
Actions on what the page edits as a whole, such as renaming the custom profile or viewing its final config, go into
`titleMenuItems` rather than the bar: the title shows a chevron and opens them, as an iOS document's title menu does.
The menu opens from the title's start edge (`PopupPlacement.overAnchorStart`), since the bar's own menus, which open
from their end edge, would cover the back button there.
A bar under a sheet drops its actions and keeps its title and back or close button. `CommonScaffold` fades the actions
out with the entrance of a sheet laid over its page, or over the sheet it is a page of, so none on the layer below
reads as an action of the sheet on top; `sheetCoverOf` in `lib/widgets/sheet_navigator.dart` reads that entrance, which a sheet route hands
reads as an action of the sheet on top; `sheetCoverOf` in `lib/ui/sheet_navigator.dart` reads that entrance, which a sheet route hands
down through `SheetCoverRouteMixin`, as `SnapSheetRoute` and `ModalSideSheetRoute` do.
## Liquid Glass
`ThemeProps.liquidGlass` picks a `LiquidGlassMode`, `none` by default, and reaches widgets as `LiquidGlassScope`, on for
`standard` and `ultra` with that `LiquidGlassQuality`, from `ThemeManager`. With it on, `CommonScaffold` passes `glass`
to the bar's `TonalButtonTheme`,
which puts a `LiquidGlass` pane behind each button through `ButtonStyle.backgroundBuilder` and one behind a
`TonalButtonGroup`; the navigation dock's bar and lifted lens become panes as well, and so do a sheet's docked search,
the cards of a `CommonPopupMenu`, and a `CommonDialog`. On the dashboard only the start button is a pane: its connected
buttons and switches keep their fills, since each pane adds its area to a capture retaken whenever the dashboard
redraws, which it does often. Each still swells on its own under `ElasticPress`, and a `DashboardConnectedGroup` paints
the button pressed last over the neighbours it swells past. `lib/ui/liquid_glass.dart` holds the rest:
- A `LiquidGlassHost` over the app (`lib/ui/liquid_glass_capture.dart`, after g1455's atlas proxy) draws its panes
without reading the backdrop. As the frame is composited, before its children, it redraws onto one small image what
each pane under it would read: the layers painted before the pane, within its reach, shrunk as far as a sigma of the
pane's frost still spans two of the image's pixels, twice where g1455 records its regular finish, and never past
eight screen pixels to one, the coarsest g1455 measured, with that frost blurred in by the engine. A pane without frost, the dock's lens, keeps the screen's resolution, as g1455 keeps its clear finish.
A pane with a `haze` takes a second slot in the same image, shrunk and blurred for its far wider `hazeBlur`, and
mixes the two.
Each pane paints the shader over its part of that image as an ordinary paint, one read a sample where its frost is
even. No pane ends the frame's render pass or copies the surface, and the glass never trails the content by a frame.
A pane outside any host draws only its tint and rim.
-`ultra` draws all of the glass below. `standard` leaves out what costs most for least: a haze becomes the whole
frost, so the dock takes one shrunken slot in place of a sharp one at the screen's resolution, and its text no
longer shows faintly through; a lens's rim parts no colors; the lifted lens's copy goes unwarped, tinted with the
light its pane would add; and the dock casts no shadow, which is under 3/255. The host applies the style changes to
every pane it draws, the dock the rest. A config saved with `standard` before `ultra` existed moves to `ultra` once
(`Migration.upgradeVersion` 2), since that was its look. On the SwiftShader emulator, as raster time a frame over
glass off, median of four alternating runs, the glass before the tiers, `ultra` and `standard` added 16.4, 13.5 and
9.5 ms scrolling, 52, 40 and 17 dragging the lens, 34, 26 and 18 scrolling under an open menu, 22, 13 and 11
opening it and 17, 9 and 5 closing it; scrolling took 18.8 render passes a frame in either tier against 23.6, and
the lens 22 in `ultra` and 18 in `standard` against 30.
- A pane over one painted before it in the same root reads a second image, a level up, with the first drawn in as
glass. Over means within 2.5 sigmas of its frost and as far as it bends, where a pane takes under 1% of what it
reads, rather than the 3 sigmas and 2 dp its slot holds: the bar's buttons stand 8 dp apart, inside the slot of the
overflow button, which went up a level and cost a second image every frame content moved under the bar. A pane's
root is the host or its nearest ancestor that draws offscreen: as a backdrop filter would there, it
sees only that ancestor's content, without the ancestor's own effect, and replaces what it covers. A
`LiquidGlassFade` is no root: as Core Animation fades a layer, its panes still read what lies behind it and fade with
the rest. A level keeps its image while what it drew, the same pictures under the same transforms, clips and
effects, stays the same, so a still screen redraws its glass without a capture and content scrolling under glass
captures once a frame. Within an image each band of slots that blur alike keeps its pixels the same way, copied
from the last image rather than drawn and blurred again, and a slot without frost is a band of its own: dragging
the lens redraws only its copy's slot, not the dock's haze or the buttons' frost. Not on Skia: an image there keeps
what it was drawn from as long as it lives, so each capture copied from the last would hold every one before it,
and memory grows by a capture a frame. The dark line along the rim darkens the blurred capture there rather than the
sharp backdrop, a pixel or two wide.
- Measured on an Android emulator rendering in software (SwiftShader, 1080×2400 at density 420), as raster-thread time a
frame counting the captures that FrameTiming's raster span leaves out, median of three runs, each capture against
none in a run of its own: scrolling Tools takes 43 ms with none, 6 more with a sigma over one pixel of the capture and
11 over three; scrolling the dashboard 64, 9 and 19; switching to Proxies 34, 15 and 25; opening a menu 42, 9 and 13;
closing it 41, 1 and 3; dragging the dock's lens 39, 38 and 42. One pixel adds about half what three do while
content scrolls, and about as much while a page switches, where the capture's size is not what costs, or while the
lens moves, which both capture at full resolution. Three was measured before a menu's capture was held to eight
screen pixels to one, and two, which glass uses, has not been timed. Ultra, which read the backdrop under each pane,
added 27 to 61 ms to the same frames and was dropped. A software renderer weighs passes and texture reads unlike a
phone's GPU, so the figures rank the captures rather than price them; no phone has measured them. A still screen
draws no frames.
- Rendered under `flutter test --enable-impeller` at density 3, against a capture with a sigma over three pixels, one
pixel blurs text under the dark dock into blocks that set 16% of its pixels apart side by side (CIEDE2000 above 2),
and two sets 0.5% apart. Reading a coarse capture through a bicubic filter barely helps: the blocks are in the
capture.
- Where a pane's frost is uneven, as along a rim that softens, the shader reads what the capture does not hold as the
engine's Gaussian, in device pixels, on a Vogel disc of 16 taps up to a sigma of 2 pixels, 32 up to 3.5 and 64
past it, so no taps lie further apart than 64 leave them at the widest; rendered at density 2.625 the lens moves by
at most 12/255 on a few pixels from 64 taps throughout.
- Glass builds no pipelines ahead of use. A warm-up that rendered panes offscreen once the first frame landed was
dropped: after an install, with no pipeline cache on disk, it compiled on the raster thread just as the app was first
used, to spare the first menu and lens only a few pipelines.
- A pane is a rounded rectangle, each corner with its own radius, unless a `clipper` gives it a path, as the start
button's cookie does. The shader reads that path as its reach from the pane's center in 64 directions, so it must
meet every ray from the center once.
- Without Impeller's shader filters (Skia on the Intel macOS build, `flutter test`) a pane frosts from its capture:
blur, saturation, brightness, tint, and a rim, with no refraction; the tint is not thickened there, as it is where a
pane has no capture, since the capture holds the blur. `flutter test --enable-impeller` runs the shader, so `toImage`
shows the real glass.
- A popup grows out of its anchor on a platter (`CommonPopupRoute`). On glass the platter is a `LiquidGlassPane`
easing from the button's `regular` glass to the thicker `menu` glass, and the menu's cards stand `bare` while it
shows, opening and returning, so one pane bends the backdrop at a time. The platter takes the `menu` glass's frost
from the start and, as the cards do, paints with a `hold` over all it may sweep: the host captures that once and
keeps it while the pane moves inside, its frost neither grows nor falls by a tenth, as a folding sub menu's glass
does, and the page holds still, so a menu opening captures three times and closing twice rather than every frame.
On the SwiftShader emulator that cut a menu's frames from 17 render passes to 12.4, against 11.1 without glass, but
saved time only within noise: what glass still adds there, about half again a frame, is the shader over the
growing platter. A held pane casts its shadow after itself, over its capture rather than in it, since a shadow moving with it would
renew the capture. The anchor's snapshot is taken under `LiquidGlass.withoutPanes`: offscreen a pane has no
backdrop and would come out a flat tint. A menu opened at a point fades out under the layer a `LiquidGlassFade`
pushes, so its glass keeps reading the page as it goes.
- An alert on glass takes the `alert` glass, the `menu` glass frosted wider, under its `AlertDialog`'s surface, which
it finds as the dialog's `Material` lays it out, since a dialog takes only a color. Its route fades it under a
`LiquidGlassFade`, so the glass keeps its full blur and fades with the text, as UIKit fades an alert and its glass on
one layer. Its plain buttons and fields fill with `glassFillOf`, black or in dark mode white at 13%: iOS 27 shifts
them from the glass under them by about 30/255, darker or lighter, a blend Flutter has no mode for, which 13% meets
over the glass an alert shows on a page. Its route dims by `alertScrim`, iOS's fifth in light mode and near half in
dark mode, not Material's third, so the glass reads the backdrop iOS's reads; under Material's a light alert came
out grey. Measured against UIKit over the same page, the title sits 21 dp down, a message 6 dp under it and a field
16, the buttons 20 under either; plain button labels take the medium weight, and a light message takes
`onSurfaceVariant` at 85%, as light as iOS's vibrant secondary label. UIKit sets a title with no message centred in
the body weight, so an alert with fields (`hasFields`) does too, with room for a trailing control's width kept on
both sides to hold the title on the alert's centre. A destructive action takes `ColorScheme.destructive`, iOS's
systemRed: Material's `error` read dull in light mode and pink at tone 80 on dark glass.
- A press anywhere on an alert swells the whole alert, glass and content, as UIKit swells it, measured at 4 pt on its
320 width, and a drag past its edges stretches it about 3 pt; the button under the finger does not swell, it fades
its fill to 40%, or a dark default button's to 50%, while a dark plain button keeps its fill. `ElasticPress` takes
the swell and the pull in dp and finds the surface through `panelOf`, since an `AlertDialog` spans the screen
around it.
- The `menu` and `alert` glass follow iOS 27's menus and alerts as the simulator showed them over solid, striped and
split grounds, keeping the scheme's tint: in light mode the glass passes the backdrop at 45%, doubled in saturation
and a tenth brighter, within 2/255 of iOS; in dark mode iOS keeps each colour's hue and chroma while it compresses
white to mid grey and lifts black to 32/255, which the shader matches over solid grounds, within 4/255 and 11 at
worst, by thickening the scheme's highest container over a light backdrop. The dark fit weighs black and dark grey
most, the grounds a dark page puts behind it. That `contrast` reads the whole pane's backdrop, not each pixel, so
over half black, half white the white half shows lighter than iOS's. It reads five points, each as four taps a frost
sigma apart so that a glyph passing under one does not flicker the tint, but once under a haze or a frost of 8 dp
or more, which blur a glyph past flickering. iOS frosts an alert at a sigma of about 13 dp
and a menu at 10 to 13, with no sharp share like the dock's.
- The `menu` glass leaves its rim to a `LiquidGlassRim`, which alerts, menu cards and the platter as it lands draw
around their panes: the line on the canvas outside the pane, and the highlight in the pane's shader, band by band
and turn by turn as the fallback draws it in nine even-odd rings, within 12/255 of those. It is shaded unlike the
bars' rim: as iOS 27 draws it around both, measured in the simulator, the dark
line lies just outside the glass, darkest along the sides, and the highlight is light added to the glass, brightest
at the middle of the top and bottom, about half as bright where a corner begins, a fifth where it ends and gone
down the sides, fading over about 5 dp inward; a menu's line is lighter than an alert's. A glass menu and a glass alert cast iOS 27's shadow as
measured over white, a deep one under a wide one, half again as dark in dark mode, where a solid menu keeps its
softer one.
- The optics bend with a circular bevel whose width and depth shrink to fit a small pane, so the rim shows the content
squeezed and mirrored. The materials are iOS 27's as codenameone/CodenameOne#5906 fitted them (`pill` for the dock,
`regular` for buttons): the backdrop passes through as `offset + scale * saturated backdrop`, the tint at
`1 - scale` carrying the offset; a dark line takes the outermost pixels because UIKit draws it just outside the
bounds, on the dock as faint all around as iOS draws it along the top and bottom, since the darker sides iOS draws
read as brackets on a pale page; a highlight lights the top and bottom, fading over 1.3 dp and, as iOS 27's tab bar
fades it around its rounded ends, with the square of the normal's upward part; the lifted lens keeps a narrower band.
Buttons (`regular`, `tinted`) take the rim of iOS 27's glass and bar buttons, measured in the simulator: the line
darkens toward the sides with the cube of the normal's sideways part, from nothing along a dark button's top and
bottom, and the highlight fades over 1.2 dp with a sixth of it in a tail to 4.5 dp, dims round the ends far faster
than the tab bar's, and is partly added as light, so it lifts a pale button as iOS does.
The line and the highlight keep to the superellipse the pane is clipped to, which the shader traces after the
engine's `round_superellipse_param.cc`: the circles the optics bend by lie up to half a dp outside it where a long
side runs into its end, and a line on them breaks there. A test holds the traced curve to the engine's path.
In dark mode the tint is
the scheme's `surfaceContainerHighest` at iOS's alpha: iOS's mid grey lifts a dark page into a grey haze. The tint
thickens over content much darker than it, or with a negative `contrast` much lighter.
- The `pill`'s frost, line and shadow follow iOS 27's tab bar at rest, measured in the simulator over solid fills and a
black and white split: 28% of the backdrop shows sharp and 72% through a blur of 15.6 dp, so a stripe under the bar
spreads over its neighbours; iOS mixes the two brighter toward white, as if it blurred in linear light, which the
engine's blur cannot. Dark mode draws no line, as iOS draws none along a dark bar's top and bottom. Glass bars cast
1.3% black blurred 3.8 dp and dropped 6.5 dp, shading only below and by 3/255 at most, in both modes; the solid
dock keeps its wider shadow. The sharp part needs no blur pass of its own, so on the SwiftShader emulator the haze
added half a render pass a frame scrolling Tools, about four on the dashboard, where it no longer shares the start
button's blur, and no time beyond the noise of three alternating runs. Its colors are deliberately not iOS's: iOS's
bar takes no `contrast`, its selection platter is a color matrix of the bar and its icons are vibrant, where the
dock keeps the theme's.
- The dock and its lens follow UIKit's tab bar as codenameone/CodenameOne#5906 measured it frame by frame: 86 dp a
destination, a resting lens 8 dp wider, a lift of 16 dp that magnifies what lies inside the lens by 16%, and springs
taken from the capture; the lens's stretch and squash are a fit to its deformation tables. The lift stays up until it
has risen fully and the lens has arrived, so a tap pops it all the way, and `ElasticPress` likewise finishes a tap's
swell on the bar's press spring before it bounces back. The destinations stand on the lifted lens, as iOS draws
them, so it bends only the bar; what lies inside it is copied, tinted and magnified, into an offscreen layer where
a second clear pane warps it toward the rim on its own, as liquid_prism read UIKit's displacement inputs on iOS 27.
The lens follows what iOS 27 shows of it over ramp and line backdrops in the simulator: it reads outward along its
normal all around, by nothing at the rim and 4.3 dp a 5.5 dp bevel in, easing off toward its middle, so a band
rings it with what lies right under its rim, drawn out; red and blue part by up to 6.5 dp along its top and bottom
and about 3.9 dp around its ends, across the band's outer 7 dp. In dark mode both panes brighten what they show by
a third, as iOS does, and the lens casts a soft shadow 8 dp down. A press lights the bar through `LiquidGlass.glow`:
a wash over the whole pane, brightest at the finger, that spreads out as it fades. A `pressed` pane, as a glass
button's or a `TonalButtonGroup`'s is, hears the pointer on what it wraps and lights from the finger the same way.
## Popup Motion
`CommonPopupRoute` follows WWDC25's account of iOS 26 menus: one that grows out of a control keeps the two continuous,
so closing returns it into the control on the same platter, its content blurring out first. Apple documents only the
opening; one opened at a point (a context menu) has no control to return into and shrinks toward the point to
`_dismissScale` as it fades, as iOS context menus do.
A sub menu is a `PopupMenuCard` laid out once at full size. One spring (`_unfold` in `lib/ui/popup.dart`) moves
its surface and clip from the item that opened it to its bounds, recedes the parent, and reveals the items, so a fold
caught mid-unfold turns back with the speed it had. The parent's item hides under the sub menu's header, which draws
it again, and the sub menu is removed only once it stands on that item.
A `CommonPopupBox` opens on its button's pointer down, as an iOS menu that is a button's primary action does, and the
press carries on into the menu. `_PopupPressRecognizer` claims the pointer then, so the button's own tap and its
tooltip's long press stand down. In a scroll view, as the settings rows are, it first waits `kPressTimeout`, as iOS
scroll views hold back a touch, so a drag from the button scrolls and a quicker tap opens the popup on its release. The
pointer's moves and release keep reaching the button, so the box hands them on (`_CarriedPress`) and lets go of the
`ElasticPress` around it. The menu follows them only once they leave the slop, since it covers the button and a press
let go where it went down would pick what now lies under it, and drives its own `ElasticPress` with them through
`ElasticPress.maybeOf`. The box tells a disabled button by the `onTap` of its first `InkResponse`, and holds the popup's
`anchorShift`, since it opens before the button's `onPressed` would run.
The menu stands in an `ElasticPress` of its own with `pullPastEdges`: it swells under a press and stretches only toward
a finger that strays past it, so sliding over the items or scrolling leaves it in place. A press on a row belongs to the
menu rather than the row's `InkWell`, which keeps only hover, focus, and keys: `_RowPressRecognizer` highlights the row
under the finger as it slides, hit testing where the menu paints (`ElasticPress.paintBoundsOf`), with a selection click
on each change, and picks the one it is let go on, or none off the rows. It claims the pointer only on release, so a
menu long enough to scroll takes a drag first and drops the press. A press that rests for `_dwellDuration` on an item
with a sub menu opens it, and one that rests on an open sub menu's header folds it back, each with a click as the
highlight moves; the finger then stands on the header or on the item it folded into, and letting go there changes
nothing. A tap that opens or folds a sub menu clicks no more than a tap on any item does, as iOS menus give selection
feedback only to a sliding press.
## Row Actions And Sort Mode
A list row or card carries no trailing more button. Its secondary actions go into a `ContextMenuRegion`, which opens
@@ -536,9 +810,9 @@ few keys of a `tun` section the network settings own, so its spec does not mix i
## Where A Route Opens
On desktop each navigation destination has its own `Navigator` (`_NavigationPage` in `lib/pages/home.dart`); on mobile
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/common/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet` and`showSnapSheet`
all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/ui/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet`,`showFormSheet` and
`showSnapSheet`all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
stays in that destination's stack when the user switches away and back. `sheetNavigatorOf` lifts a sheet out of a
paged sheet's own `SheetPagesNavigator`, never past the destination.
@@ -570,14 +844,50 @@ rebuilds one row instead of the whole section. Titles and subtitles are `ConfigL
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
`ConsumerWidget` only when a row is genuinely reused across screens. 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.
In an inset grouped list (`generateSectionV3`, `generateAnimatedSection`), a row's subtitle shows its value or state,
In an inset grouped list (`generateSection`, `generateAnimatedSection`), a row's subtitle shows its value or state,
never what the setting does. As iOS settings do, the explanation goes in the group's `footer`, drawn by `ListFooter`,
one sentence per setting that names it, and a section header carries only its title. Flat `generateSection` lists keep
their descriptions on the row.
one sentence per setting that names it. A section header carries its title and at most a few actions on the group, as
`IconButtonData`: `ListHeader` draws them as 36 dp tonal buttons (`TonalButtonSize.section`), the first two grouped,
centered on the title. Every header stands as tall as one with a button, 6 dp clear of the group below, so headers
match whether or not they carry actions. A group with a selection swaps them
for delete and select all, never a text button. `generateSection` is the only section builder, and a sheet lays its groups inside the 16 dp inset the pages use.
A row that opens a page, `ListItem.open` or `ListItem.next`, ends in `AppGlyphs.chevronForward` unless it passes its own
`trailing`, as iOS settings rows do. A row that opens a dialog in place, `ListItem.input`, has no chevron. A setting
that takes one of a few values is an iOS pop-up row, `ListItem.options` or `ConfigOptionsItem`: it ends in its value and
`AppGlyphs.picker`, and a tap anywhere on it opens a `CommonPopupMenu` out of that value with the current option
checked. Its items are text alone, as an iOS picker's are; glyphs belong to menus of actions. One value is never picked
from a radio dialog or a row of selectable chips; a button that picks one, as the theme's scheme variant does, opens the
same menu through a `CommonPopupBox`. A pick from a menu that returns into its control runs only once the popup has
landed, so a setting that rebuilds the app, such as the language, never does so under the returning platter; the
control shows its value with `PopupValueText`, which blurs across to the new value while the control eases to its
width. A menu opened at a point, as a context menu or the editor's is, runs its pick at once. A menu capped by
`maxVisibleItems`, such as the proxy group tabs' more menu with every group in it, builds only the rows in view and
takes its width from its labels measured as `EmojiText` lays them out.
## Forms
A form that edits more than one value opens with `showFormSheet` (`lib/ui/sheet.dart`): a bottom sheet in the mobile
layout, and elsewhere a card `FormSheetRoute` centers over the navigator, as iPad and macOS present a form sheet, moving
as an alert does. Both give the page `SheetType.bottomSheet` or `SheetType.formSheet`, compact sheets that share
`CommonScaffold`'s sheet bar, so one page serves both: `FormPage` for a plain form, or the page a `NestedPagedSheet`
pushes for the custom profile editors. Its values sit in `FormRow`s inside `generateSection` groups, each field a
`FormRowTextField`, bare and set against the end in both, so a focused field's only outline is its row's focus ring.
`RenderEditable` ends a field's text short of its end by the caret's margin, so the field ends its hint as short and
reaches as far into the row's padding: the caret follows the hint or the text, and the text ends where another row's
value does. The
save is the scaffold's `confirmationAction`: last in a bottom sheet's bar, and in a form sheet the filled default beside
Cancel, which takes the close's place, in a row of alert buttons at the first page's foot, as SwiftUI places a
confirmation action on macOS; other actions, such as quick edit, stay in the bar. A row cannot hold an error line, so a
save the form refuses marks the row `invalid` and says why in the `ErrorBanner` above the groups, which follows each
edit from then on. A prompt for one value, such as a URL import, a rename or a password, stays an alert (`CommonDialog`,
`InputDialog`), as on every Apple platform; a key with its value, as `MapEditView` adds, is two values and a form.
An alert raised while focus is in a form sheet opens on the sheet's
navigator, centered on the card rather than on the window: `Dialogs` follows the focused route when it is an
`AlertHostRoute`, which the form sheet and every alert are.
## Keyboard Insets
@@ -587,48 +897,115 @@ which runs every one of those rebuilds inside the layout phase; in a profile tra
`BUILD`.
- A page the user is not typing in holds its bottom inset through `KeyboardInsetHold`
(`lib/widgets/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
(`lib/ui/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
not, and without it every page the app keeps relaid out, and rebuilt under its `LayoutBuilder`s, with each frame of a
keyboard raised by a dialog or sheet on top. The home route, each home tab, `CommonRoute`, `CommonDesktopRoute` and
the open-container route wrap their page in one; a new page route type does the same.
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
`_BodyPadding` in `lib/pages/home.dart` and `_SheetKeyboard` in `SnapSheet` do.
- A bottom sheet makes room for the keyboard itself: `_SheetKeyboard` lays the content out above it and removes the
bottom inset from the content's `MediaQuery`, so nothing inside a sheet, `CommonScaffold` included, adds keyboard
space. A nested sheet must not carry the keyboard through its pages: `NavigatorResizable` learns a page's new size a
frame late, and the sheet would trail the keyboard by a frame.
`_BodyPadding` in `lib/pages/home.dart`, `_KeyboardViewport` in `lib/ui/snap_sheet.dart` and the `SafeArea` around
the docked search field in `CommonScaffold` do.
- A bottom sheet makes room for the keyboard itself: the keyboard raises the sheet's foot (Bottom Sheet Motion), and
`_WithoutKeyboard` removes the bottom inset from the content's `MediaQuery`, so nothing inside a sheet,
`CommonScaffold` included, adds keyboard space. A nested sheet must not carry the keyboard through its pages:
`NavigatorResizable` learns a page's new size a frame late, and the sheet would trail the keyboard by a frame.
- The keyboard moves a bottom sheet as it is painted. `_RenderSheetViewport` lays the surface out as tall as the view
whatever the keyboard, and a fit sheet keeps the limit its short content was laid out within while the keyboard
changes the room above it, until the content reaches either (`releaseLimit`). A keyboard frame then lays out and
paints nothing inside the sheet unless what it shows changes.
- A field without a label says `floatingLabelBehavior: FloatingLabelBehavior.never`, as `FormRowTextField` and
`SearchField` do. `InputDecorator` animates the floating label of an empty field on focus even when there is none,
`InputDecoration.collapsed` included, and each frame of it lays the decorator out and, inside a sheet, the whole
form, through the first frames of the keyboard.
## Rebuild Scopes
A `LayoutBuilder` runs the rebuilds of everything under it inside its own layout, so a widget rebuilding there marks
the builder for layout, and that climbs to the nearest relayout boundary. Flutter marks every render object it lays
out for paint, so the nearest repaint boundary above that path repaints too. Under the material `Scaffold`'s body
`LayoutBuilder` (it has one with `extendBodyBehindAppBar`) that was the page and its background, and under the home
shell's `LayoutBuilder` the whole home route: a spinner turning in a page, or the dock's lens moving, relaid out and
repainted the page on every frame, and the glass over it captured every band again, the bar's included, though
nothing under the bar had changed.
So the body and the dock each take their rebuilds in a scope of their own: a `LayoutBuilder` under a repaint boundary
that is also a relayout boundary. `CommonScaffold`'s `_BodyScope` sizes itself to the most the scaffold allows, which
the scaffold never reads back, and lays the body out under it as before; `NavigationDock` sizes itself as it always
was, full width and its bar's height, and lays its boundary out tight. A rebuild inside either relays out and
repaints only that layer.
## Bottom Sheet Motion
`SnapSheetRoute` follows `UISheetPresentationController` as measured on the iOS 27 simulator (a UIKit probe app driven
by the simulator's touch injection, logging the presented view's presentation layer every frame).
- One spring moves a sheet: UIKit's mass 3, stiffness 1000, which runs critically damped (ω ≈ 18.26 rad/s, done in
about 0.5 s). Presenting, closing, a detent change and a spring back all trace it within a point. A release thrown
toward its target keeps its velocity and lands with UIKit's slight overshoot, about damping ratio 0.8 at full
strength; a release away from its target loses its velocity and lands dead.
- A release lands on the detent, or the dismissal, nearest to where `UIScrollView.DecelerationRate.fast` projects it.
With no velocity a sheet closes once it is let go past half of its shortest detent; a flick of 1000 pt/s 85 pt
down springs back on iOS, one of 1500 pt/s closes.
- The sheet follows the finger one to one below its shortest detent and stops at its tallest, with no stretch.
- On the content, a drag up grows the sheet first and scrolls the content with what is left, in one gesture. A drag
down moves the sheet only when it began with the content at its top; one that scrolls the content back to its top
stops there, as iOS bounces it rather than handing it to the sheet.
- The keyboard raises the sheet's foot: a detent keeps what it shows above the keyboard, as a custom detent does on
iOS, up to a peek below the top. iOS lifts a medium sheet to its large detent instead, which a phone's keyboard,
about as tall as the gap between them, makes nearly the same. Since the sheet's height is a function of the inset,
it moves with the keyboard on every frame and lands back where it was.
- Where the keyboard starts or stops carrying the sheet, that function rounds its corner (`_rounded`), so the sheet's
speed changes smoothly. With a sharp corner a sheet stood still while the keyboard slid over the blank below short
content and then jumped, and on the way down halted in mid-flight; the tallest detent hit the peek at the keyboard's
full speed. Short content now eases into the rise over half its blank, which can leave the sheet up to an eighth of
the blank higher than its content needs, and the tallest detent slows into the peek over as much keyboard again as
it takes to get there, up to `_peakEase`.
`_SheetModel` keeps the sheet as an activity, resting at a detent, held at a height or settling toward a detent as an
offset from it, and layout resolves the detents again every frame from the view, the keyboard and the content. A
resting or settling sheet therefore follows a page transition's size or the keyboard without being told, where a
stored height would have to be patched after each change. Below the shortest detent a drag moves the route's own
animation, so the barrier, a sheet stepped back behind this one, and the page bar under it follow the finger, and the
close continues from the release on the presentation spring (`createSimulation`).
## Page Arrival
A pushed page is built, laid out and painted through every frame of its transition, so whatever it starts on arrival
(loading and decoding images, building the rows its data brings in) lands inside the animation. Impeller has no
raster cache, and every offscreen pass is paid again each frame on a phone GPU.
A pushed page is built, laid out and painted through every frame of its transition. Impeller has no raster cache, and
every offscreen pass is paid again each frame on a phone GPU. Even so, a page loads what it shows as it mounts: content
held back until the route settles slides in as an empty page and pops in once the motion has stopped, which reads worse
than the frames a load costs. What a transition must not carry is work that repeats on every refresh or every frame.
-Work a widget would start as it mounts waits for the route to settle. `RouteSettledMixin`
(`lib/widgets/route_motion_hold.dart`) calls `didSettleRoute` once the route that brought the page in has arrived,
and at once on a page that already sits still; `whenRouteSettled` (`lib/common/navigator.dart`) does the same for a
one-off future, and both wait out the frame a `HeroController` builds a pushed route offstage with its animation
pinned to completed. `CommonTargetIcon` and `PackageIcon` show what is already in memory at once and load the rest
after that. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted through the transition only to be replaced.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)`: the rows past the viewport would
otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is most of the push.
The icon grids (`IconGridScrollView`) and the access control list do this, and so does `ListEditorPage`, whose
rows are heavy enough that only its list rebuilds as the route settles, reusing the rows already built.
- A page whose content waits on what it loads on arrival (lists read from the database, file sizes, the installed fonts)
shows its `NullStatus` illustration, with no label, inside a `NullStatusSwitcher` until all of it is in, then switches
to the finished content in one step, as the custom profile, resources and font family pages do. Never a skeleton
screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds every row
it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the dashboard's
service status. `holdsArrival` keeps the status a switcher opens on for at least 400 ms, about a route transition,
so a load that finishes just after arrival does not flash it; the connections, resources and font family pages set
it.
-Loads start as the widget mounts, never behind `whenRouteSettled`. `CommonTargetIcon` and `PackageIcon` show what is
already in memory at once and load the rest right away; the editor reads its file and mounts CodeForge as soon as the
content is in. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted only to be replaced. `whenRouteSettled`
(`lib/ui/navigator.dart`) is left for sequencing navigation, such as opening a page once the sheet above it is gone.
- A live feed holds its refreshes while its page moves and replays the latest once it is still, so a list does not
re-rank, reorder or grow under a transition. `RouteMotionHoldMixin` (`lib/ui/route_motion_hold.dart`) holds them
while `isRouteMoving`: the page's route or a sheet carrying it is pushed, popped or dragged, a page is pushed over it,
or a sheet covers it (`sheetCoverOf`). The connections, requests, logs and DNS pages and the dashboard's live
connections table use it. A feed's first content is no refresh and shows at once, as `ConnectionsView` applies its
first poll, and a held poll keeps the time it was read at, which `TrackerSpeedRanker` derives speeds from. Per-frame
work spread over frames pauses the same way: the font family page switches one name to its own family per frame and
stops while `isRouteMoving`.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)` from `RouteSettledMixin`: the rows past
the viewport would otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is
most of the push. The icon grids (`IconGridScrollView`) and the access control list do this, and so does
`ListEditorPage`, whose rows are heavy enough that only its list rebuilds as the route settles, reusing the rows
already built.
- A page whose content waits on what it loads (lists read from the database, file sizes, the installed fonts) shows
nothing until all of it is in, then the finished content in one step: `NullStatusSwitcher.isLoading`. A load that can
run long also gives the switcher a `loadingStatus`, its `NullStatus` illustration with no label, which takes the
place of a first load still running after about a route transition (400 ms); one that ends sooner goes straight to
its content, so the illustration never flashes. The custom profile, resources and font family pages set one. Never a
skeleton screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds
every row it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the
dashboard's service status.
-`GlyphPainter` paints a translucent glyph through an offscreen layer. A placeholder repeated in every tile blends its
color over the surface instead of carrying an alpha.
-`PagedSheetRoute` and `CommonRoute` share
`SharedXPageTransition` (`lib/widgets/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
`SharedXPageTransition` (`lib/ui/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
Android 14 slides pages, so a page moves alike in a sheet and on its own. The transition fades a page by painting the surface over it
and needs no opacity layer: the page on top shows only on its side of the point where the two fades cross, opaque
over the other, so neither shows through the other. A page below that does not fade with it, such as the home
@@ -690,15 +1067,17 @@ itself live in one. Code without a `Ref` reaches providers through
path that never validates never resolves the controller.
The UI layer must not reach a process-wide singleton directly.
CI flips `setup` to `false` with `yq` in its throwaway checkout before
`dart run tool/changelog.dart` and `flutter test`; no test loads the Go core. `rust_api`
`dart run tool/changelog.dart` and `dart run tool/test.dart`; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
builds; the release-notes job, which runs no tests, flips both.
@@ -154,12 +154,29 @@ flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart
```
For the whole suite, or a directory of more than a few files, use `tool/test.dart`, which is also what CI runs:
```bash
dart run tool/test.dart # all of test/
dart run tool/test.dart test/widgets test/views # some paths
dart run tool/test.dart -- --reporter expanded --coverage
```
Plain `flutter test` compiles each file into its own program and starts an engine for it, one file after another, and
that dominates a full run: 329 files took 128 s on a 12-core Mac with at most four running at once. `tool/test.dart`
imports the files into 32 generated entrypoints under `.dart_tool/test_bundles/` and runs those with one worker per
core: about 60 s for the same run, and 67 s instead of 180–213 s with `--coverage`. Files in one entrypoint share an
isolate, so a test can see state a file before it left behind; the script runs every file that failed there again on
its own, lets that run decide, and names the files that only failed together. See the Shared Test Isolate section of
`rules.md` before fixing one.
`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.
`flutter build <platform> --debug` on every branch push, and by the release matrix on a tag; `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`.
@@ -338,9 +355,11 @@ directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
and the gomobile AAR built for one ABI), `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.
Windows runner for the helper's `windows-service` feature. On branch pushes a
`desktop` job also compiles a debug build per desktop platform; a tag skips it,
since its release matrix compiles the same native code. Release builds run only
in the public repository and start alongside the checks; `upload` publishes
only once every build and every check job 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
@@ -348,6 +367,19 @@ It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
### CI Caches
A run restores only cache entries saved on its own branch or tag, or on the default branch (`main`), and the
repository holds 10 GB before evicting the least recently used entries. Every release is a new tag that no later run
reads from, so `BRANCH_CACHE` turns caching off in tag runs: one release used to write about 9 GB of such entries and
evict everything `dev` had saved. Release builds therefore start cold; branch runs, chiefly `dev`, keep the caches.
- setup-go keys its entry on the `cache-dependency-path` files alone and saves only on a miss, so jobs listing the
same files share one entry holding the build cache of whichever finished first. `desktop` and `android` each add
the file that drives their Core build to get an entry of their own.
- setup-gradle writes only on the default branch unless told otherwise, so `android` lets `dev` write as well. Its
entries are keyed by job, which is one more reason the release build gains nothing from caching.
- From other settings: `NetworkProps.authentication` and `bypassPrivateRoute`, which `Tun.getRealTun` folds into the
TUN exclusions, and `AppSettingProps.checkCertificate`, sent as `skip-cert-verify`.
`geox-url`, and the `tun` keys of`Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
`skip-cert-verify`.
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
@@ -45,15 +45,17 @@ Prefer update whenever Go has a setter for the setting, because an apply reloads
## Apply
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds two values:
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
- `NetworkProps.appendSystemDns`.
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
`route-exclude-address`.
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS override (`dns`,
`dnsOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,`redir-port`, `tproxy-port`), the dialer
options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,`routing-mark`), `geodata-loader` and
`global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
the profile build reads from another settings object has to be added to `setupPatchProvider`.
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
@@ -64,9 +66,10 @@ Profile content does not go through `setupPatchProvider`. `setupStateProvider` r
database once instead of watching them. Content is applied explicitly by whatever changes it:
- Profile editors call `autoApplyProfile` when they close.
- Subscription updates,`setProfileAndAutoApply`, scripts and app-level providers call `applyProfileDebounce`.
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
- A profile switch runs `fullSetup`.
- A start applies again with `force`.
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
`skipCertVerify` that only `SetupParams` carries included.
## Restart
@@ -116,8 +119,11 @@ options, because the service does not report the options it was started with.
Each path watches its own provider, so a single setting can take more than one of them:
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
- TUN fields and `bypassPrivateRoute`: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the
restart notice carries the change.
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
carries the stack.
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
with the profile.
- `bypassPrivateRoute`: apply on desktop, restart on Android.
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
@@ -17,6 +17,8 @@ Read these only when the task touches their area:
local plugins.
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
profile, or asks for an Android VPN restart.
- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that
remain, for any change to how a surface is colored.
- [.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.
@@ -55,7 +57,7 @@ Read these only when the task touches their area:
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`,
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/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).
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.