Skip to content

feat(tailscale): add opt-in Tailscale outbound nodes and bypass Inject Tailscale proxies, route rules, and fake-IP filters into the generated Clash config when enabled - #2270

Open
joei4cm wants to merge 14 commits into
chen08209:devfrom
joei4cm:feat/tailscale

Conversation

@joei4cm

@joei4cm joei4cm commented Jul 30, 2026

Copy link
Copy Markdown

This pull request introduces full Tailscale outbound node support, including configuration, routing, and localization updates. The main changes add Tailscale-related strings to all supported languages, update the configuration generation logic to inject Tailscale proxy and rule settings, and enhance the English message lookup to support new UI elements.

Tailscale feature support and configuration:

  • Added logic in _makeRealProfileTask (lib/common/task.dart) to inject Tailscale fake-IP DNS filters and prepend Tailscale rules, ensuring Tailscale traffic is handled correctly and with priority. Also merges Tailscale proxies into the final config. [1] [2]

Localization and UI strings:

  • Added all Tailscale-related strings (labels, hints, descriptions, and guides) to English, Simplified Chinese, Japanese, and Russian ARB files to support the new feature in the UI. [1] [2] [3] [4]
  • Updated the English message lookup (lib/l10n/intl/messages_en.dart) to include new Tailscale UI strings and pluralization logic for nodes and routes. [1] [2] [3] [4] [5] [6]

These changes collectively enable Tailscale outbound node management in the app, with proper configuration merging and full localization support.

@cursor
cursor Bot force-pushed the feat/tailscale branch 3 times, most recently from f6ed70e to 11c4838 Compare July 30, 2026 03:34
@joei4cm
joei4cm changed the base branch from main to dev July 30, 2026 07:00
@cursor
cursor Bot force-pushed the feat/tailscale branch 2 times, most recently from 9b2bac7 to f39c538 Compare July 30, 2026 07:14
@wen0531

wen0531 commented Jul 31, 2026

Copy link
Copy Markdown

Workaround: Tailscale works on FlClash 0.8.94 via direct config edit

Confirming that does work on FlClash 0.8.94 (Android), but only when the Tailscale proxy is defined in the main config file directly — NOT via the script override (覆写脚本).

What doesn't work

  • Script override (): Tailscale node gets injected into config but Mihomo never initializes it. No Tailscale logs appear. Likely the script runs too late in the lifecycle. Related: [BUG] tailscale无法使用 #2065

What works

Edit the active config file directly (), add the Tailscale proxy alongside your existing proxies:

proxies:
  # ... your existing proxies ...
  - name: TAILSCALE
    type: tailscale
    hostname: my-phone
    auth-key: tskey-auth-xxxx  # generate at tailscale.com admin > Keys
    control-url: https://controlplane.tailscale.com
    state-dir: ./tailscale
    ephemeral: true
    udp: true
    accept-routes: true
    ip-version: ipv4-prefer

proxy-groups:
  - name: PROXY
    type: select
    proxies:
      - TAILSCALE
      - YourExistingProxy
      - DIRECT

rules:
  - IP-CIDR,100.64.0.0/10,PROXY,no-resolve
  - IP-CIDR,100.100.100.100/32,PROXY,no-resolve
  - DOMAIN-SUFFIX,ts.net,PROXY
  - MATCH,PROXY

Verified

  • Device appears in Tailscale admin console as Connected
  • Version shows as mihomo 1.10.0 (Android)
  • Ephemeral key works correctly
  • VPN mode (FlClash) runs as the sole VPN, no conflict with Tailscale

Caveat

This workaround requires editing the main config directly, which gets overwritten on subscription sync. Looking forward to #2270 landing so it can be configured via the GUI natively!

@wen0531

wen0531 commented Jul 31, 2026

Copy link
Copy Markdown

Workaround: Tailscale works on FlClash 0.8.94 via direct config edit

Confirming that type: tailscale does work on FlClash 0.8.94 (Android), but only when the Tailscale proxy is defined in the main config file directly — NOT via the script override (覆写脚本).

What doesn't work

  • Script override (进阶配置 > 脚本): Tailscale node gets injected into config but Mihomo never initializes it. No Tailscale logs appear. Likely the script runs too late in the lifecycle. Related: [BUG] tailscale无法使用 #2065

What works

Edit the active config file directly (配置 > [profile] > 编辑), add the Tailscale proxy alongside your existing proxies:

proxies:
  # ... your existing proxies ...
  - name: TAILSCALE
    type: tailscale
    hostname: my-phone
    auth-key: tskey-auth-xxxx  # generate at tailscale.com admin > Keys
    control-url: https://controlplane.tailscale.com
    state-dir: ./tailscale
    ephemeral: true
    udp: true
    accept-routes: true
    ip-version: ipv4-prefer

proxy-groups:
  - name: PROXY
    type: select
    proxies:
      - TAILSCALE
      - YourExistingProxy
      - DIRECT

rules:
  - IP-CIDR,100.64.0.0/10,PROXY,no-resolve
  - IP-CIDR,100.100.100.100/32,PROXY,no-resolve
  - DOMAIN-SUFFIX,ts.net,PROXY
  - MATCH,PROXY

Verified

  • Device appears in Tailscale admin console as Connected
  • Version shows as mihomo 1.10.0 (Android)
  • Ephemeral key works correctly
  • VPN mode (FlClash) runs as the sole VPN, no conflict with Tailscale

Caveat

This workaround requires editing the main config directly, which gets overwritten on subscription sync. Looking forward to #2270 landing so it can be configured via the GUI natively!

@chen08209
chen08209 force-pushed the dev branch 2 times, most recently from 183a213 to cc28835 Compare August 1, 2026 14:36
@cursor
cursor Bot force-pushed the feat/tailscale branch from f39c538 to f353cdf Compare August 2, 2026 00:22
@joei4cm

joei4cm commented Aug 2, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed workaround and for confirming that type: tailscale works when it lands in the profile / runtime config that Mihomo actually loads.

How this PR injects Tailscale (not via 覆写脚本)
This PR does not rely on script overwrite (OverwriteType.script / handleEvaluate). Injection happens later, in the same path FlClash already uses for a normal profile apply:

Load the profile (getConfig)
Optionally run script overwrite if that mode is selected (unchanged upstream behavior)
In makeRealProfileTask, merge app-authored Tailscale data into the final map:
proxies: type: tailscale outbounds (mergeInto)
rules: prepended route / bypass rules
dns.fake-ip-filter: control-plane / MagicDNS filters when bypass is on
Write the resulting YAML to the runtime config file
Call setupConfig so the core loads that file
So GUI Tailscale settings end up in the same final config that a successful “edit profile YAML directly → apply” path produces. That is intentionally different from “inject only inside the JS overwrite script.”

When it should work
Scenario Expected
Tools → Features → Tailscale enabled, nodes saved, profile applied / core started Should work (this PR’s path)
Profile edited directly with type: tailscale (your workaround) Still works; orthogonal to this PR
Script overwrite alone injects Tailscale (no GUI feature) Still the old limitation you hit — this PR does not claim to fix that lifecycle
Practical requirements for the GUI path
Tailscale toggle enabled, and at least one valid node (name required; auth-key / hostname / etc. as needed)
Core actually started / profile applied after changes (the UI awaits applyProfile on edits)
For “phone reaches home via FlClash’s built-in Tailscale outbound”: configure routes (or select the node in a group). Bypass rules are for the opposite case (host also runs real tailscaled)
Optional knobs (udp, ephemeral, accept-routes, …) default to off unless set in the node dialog — same fields Mihomo understands, just not auto-forced to the values in the workaround snippet
Bottom line
This PR is meant to cover the “configure Tailscale in the GUI so it survives subscription sync” case you called out, by writing into the final applied config rather than depending on script overwrite. It should work whenever a direct profile Tailscale outbound would work after setupConfig; it does not change or guarantee the script-only injection path.

Happy to adjust docs/defaults (e.g. suggest udp / ephemeral for Android) if maintainers want that.

@chen08209
chen08209 force-pushed the dev branch 5 times, most recently from 8179499 to d966b18 Compare August 15, 2026 11:41
cursor Bot pushed a commit to joei4cm/FlClash that referenced this pull request Aug 19, 2026
Rebase Tailscale outbound integration onto current upstream dev:
- Keep Tailscale profile injection in setup action and Tools menu entry
- Adopt upstream desktop lifecycle, Android service refactor, and helper v6
- Update Clash.Meta submodule to upstream dev pointer
- Regenerate Riverpod, Freezed, and l10n outputs after conflict resolution

Co-authored-by: MichaelZ <joei4cm@users.noreply.github.com>
Changelog: Rebuilt the core, provider, and application layers
Changelog-Type: feat
Changelog: Rebuilt the interface on material_ui and simplified the overwrite flow
Changelog-Type: feat
Refines the profile, provider, and configuration interactions, virtualizes
the provider lists so a large subscription no longer builds every row, syncs
updates through the path the list reads, and expands the subscription
details with full usage.

Paged sheets are scoped locally and sized to the page in view instead of the
tallest one, so a sheet no longer reserves space it never uses.

Changelog: Provider lists scroll smoothly, subscriptions show full usage details, and sheets size to the page in view
…ghten the build

Crash recovery decides from a boot sentinel rather than Crashlytics, so it
no longer depends on a reporting SDK having initialized.

The window header is built into the window manager fork instead of being
reassembled by the app, and the toolchain tightens dependency pins, plugin
analysis, CI, and the host-agnostic tests.

Changelog: Recovery after a crash no longer waits on the crash reporter to start
Changelog-Type: fix
…e when narrow

The elevated Core no longer leaves root-owned files behind on macOS, and
provider files stay inside the profile that owns them.

Cards, form rows, and narrow layouts stay usable at small widths.

Changelog: Elevated runs and profile providers stop leaving files outside their owner, and narrow windows stay usable
…d refused starts

Ownership reclaim records dev+ino during the walk and refuses to chown a
descriptor whose fstat does not match, closing the rename-and-symlink window
O_NOFOLLOW alone left open.

A half-written IPC frame that hits the write deadline now rearms and resumes
instead of closing the connection, so a suspended host no longer reads as
EOF and exits the Core.

An overtaking Android start adopts the service that is already up rather
than establishing a second interface, and the exit-reason lookup reads every
record so a sub-process crash loop cannot evict the main-process one.

setRunning and toggleRunning restore the state the request started from and
report the failure instead of leaving the app claiming it runs.

Changelog: Stalls, double starts, and refused starts no longer take the tunnel down
… tray titles

Backup names its temp files explicitly and deletes the archive once the
upload or save is done, so it no longer leaves a database copy in the OS
temp directory.

Proxy group offsets are paired with the groups they were measured from, so
jumping to a group while a search is active lands on the right row instead
of a RangeError. Map input rows key on their key rather than the MapEntry
identity, which does not implement ==, restoring element reuse.

The tray collapses titles that queued behind a blocked native call, and
putProfileDisabledRule stores into the disabled scene it names.

Changelog: Backups clean up their temp files, group jumps land right while searching, and the tray stops replaying stale speeds
…hat loaded

The tray menu, the strings the Android service shows on its VPN notification,
and the launcher shortcut label all come from Intl, and each was pushed ahead
of its own translation. The tray rebuilt on TrayState.locale and sharedState
watched the locale setting, both of which change before AppLocalizations.load
has swapped Intl.defaultLocale, so every switch sent the previous language:
the first one looked like nothing happened, and each one after it showed the
language left behind. initShortcuts was worse off, since it runs once at
startup and kept the language the app was launched in until the next restart.

LocaleManager now publishes the locale Localizations exposes, which only
advances once every delegate has loaded, into loadedLocaleProvider, and
everything derived from Intl outside the widget tree hangs off that one
signal: the tray listens to it, sharedState watches it, and Android re-pushes
the shortcut label from it. TrayState drops the locale field that was only
ever a change signal. Following the system language is covered by the same
path, and a selection that resolves to the language already loaded no longer
forces a rebuild.

Changelog: A language switch now reaches the tray menu, VPN notification, and shortcut label
…nimation

The group header shows its type beside the expand toggle, is tightened up,
and the group icon gets room inside its container.

Cards animate when their group expands rather than when the list builds
them, and they slide in from the left with a per-card delay and a distance
that grows with the index, so a wide group no longer appears as one block.

Changelog: Proxy groups show their type and their cards slide in one after another when expanded
The indicator carried its own approximation of the M3E shape sequence: seven
StarBorder specs lerped field by field, so a morph interpolated point counts
and rounding rather than the outlines themselves, and the pill and the oval
had to be faked with width and height scales.

material_new_shapes ships that sequence as RoundedPolygon and morphs between
two of them along their real outlines, so the shapes are the M3E ones and the
transition follows the path between them. The morphs are built once per
polygon list and reused across frames, since building one walks both
polygons. polygons now takes RoundedPolygon.

Changelog: skip
The popup faded and scaled in place from its top right corner while the
target it belongs to sat unchanged underneath, so the two read as unrelated
pieces of the screen.

It now starts at the anchor at the target's size, winds out to where it
settles, and grows to full size along the way. A copy of the target rides at
the anchor and fades out as the popup takes over, while the real one is
hidden behind a Visibility that keeps its size, so nothing around it shifts
while the popup is up. The route reports its dispose back to the box, which
restores the target after a barrier dismiss, a pick, or a pop from anywhere
else. Both durations grow to leave room for the travel.

Changelog: Popup menus grow out of the button that opened them
…r icon

release.sh resolves the version the same way for pre and stable, bumping the
patch when pubspec still names an already tagged one, prints the notes the tag
would ship, and pushes only with --push. The two paths differ in more than the
tag: a prerelease renders its notes in CI from build --unreleased and writes
nothing back, while a stable release needs CHANGELOG.md and changelog.json
regenerated and committed before the tag, because render release reads the
committed file for a stable tag. The pubspec rewrite has to happen before the
preview, since build --unreleased reads pubspec rather than the tag, so an EXIT
trap restores it on any exit that is not a completed release.

The tag is pushed by name instead of with --follow-tags, which carries
annotated tags only while every release tag here is lightweight, so the branch
used to land while the tag stayed local and the workflow never fired. The
confirmation reads from /dev/tty and re-asks rather than treating an empty
answer as No, since rendering the preview takes seconds and a Return pressed
while waiting sits in the terminal buffer until the prompt consumes it. The
bash that ships with macOS is 3.2, which has neither a fractional read timeout
nor a way to flush stdin, so re-asking is the portable fix. The build number is
refreshed on every release rather than only when the app version changes: it
feeds Android's versionCode, and two builds carrying the same one cannot be
told apart once installed. The app version still holds across a cycle, because
build --unreleased keys its commit range off it.

setup_icon_file loses its leading ..\, since the packager joins that value onto
Directory.current before handing it to Inno Setup and the old path landed a
level above the repository. The locale entries are copied into the generated
.iss verbatim and stay relative to dist/, where that script is written.

Changelog: skip
chen08209 and others added 2 commits August 28, 2026 12:37
Re-apply Tailscale outbound nodes and bypass on upstream/dev (5f659cd).
Resolve conflicts in setup/task injection, ARB strings, and tools entry.
Adapt tailscale view to upstream dialog and delay-test APIs.

Co-authored-by: MichaelZ <joei4cm@users.noreply.github.com>
@cursor
cursor Bot force-pushed the feat/tailscale branch 2 times, most recently from c8ca65e to 5a41848 Compare August 28, 2026 14:56
@chen08209
chen08209 force-pushed the dev branch 5 times, most recently from b0a2b3b to 7748c64 Compare August 31, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants