fix(core): keep very large profiles from crashing the app
Changelog: Large profiles no longer crash; downloads trust core CAs
This commit is contained in:
@@ -14,6 +14,8 @@ concurrency:
|
||||
|
||||
env:
|
||||
IS_STABLE: ${{ !contains(github.ref, '-') }}
|
||||
# See CI Caches in .agents/commands.md.
|
||||
BRANCH_CACHE: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
FLUTTER_VERSION: '3.47.6'
|
||||
GO_VERSION: '1.27.1'
|
||||
NDK_VERSION: r28c
|
||||
@@ -40,13 +42,14 @@ jobs:
|
||||
with:
|
||||
workspaces: plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
save-if: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
@@ -84,7 +87,7 @@ jobs:
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Validate local Flutter packages
|
||||
run: bash tool/check_plugins.sh
|
||||
@@ -98,7 +101,7 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: Desktop ${{ matrix.platform }}
|
||||
if: *checks
|
||||
if: (github.event_name == 'workflow_dispatch' || !github.event.repository.private) && !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -120,7 +123,9 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
plugins/setup/setup_hooks/lib/src/go_builder.dart
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -176,6 +181,7 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
@@ -218,14 +224,17 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
android/core/build.gradle.kts
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
@@ -245,6 +254,8 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Run Android unit tests
|
||||
working-directory: android
|
||||
@@ -279,6 +290,7 @@ jobs:
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
save-if: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Check format
|
||||
run: |
|
||||
@@ -302,6 +314,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: services/helper
|
||||
save-if: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Check format
|
||||
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
@@ -312,7 +325,6 @@ jobs:
|
||||
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:
|
||||
@@ -334,9 +346,11 @@ jobs:
|
||||
- platform: windows
|
||||
os: windows-11-arm
|
||||
arch: arm64
|
||||
flutter-channel: master
|
||||
- platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
flutter-channel: master
|
||||
|
||||
steps:
|
||||
- name: Setup rust
|
||||
@@ -368,15 +382,9 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
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
|
||||
@@ -390,9 +398,9 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: master
|
||||
channel: ${{ matrix.flutter-channel || 'stable' }}
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Setup Xcode
|
||||
if: startsWith(matrix.platform,'macos')
|
||||
@@ -419,6 +427,18 @@ jobs:
|
||||
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
|
||||
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
|
||||
|
||||
# The core embeds this bundle and FlClashHttpOverrides.trustCoreRoots loads it for Dart.
|
||||
# Pinned by hash, so a new bundle from https://curl.se/docs/caextract.html is a reviewed bump.
|
||||
- name: Fetch CA certificates
|
||||
shell: bash
|
||||
env:
|
||||
CA_BUNDLE: cacert-2026-09-25.pem
|
||||
CA_BUNDLE_SHA256: a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505
|
||||
run: |
|
||||
bundle=core/Clash.Meta/component/ca/ca-certificates.crt
|
||||
curl -fsSL --retry 3 -o "$bundle" "https://curl.se/ca/$CA_BUNDLE"
|
||||
echo "$CA_BUNDLE_SHA256 $bundle" | sha256sum -c -
|
||||
|
||||
- name: Setup
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
|
||||
@@ -434,7 +454,7 @@ jobs:
|
||||
upload:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [ build ]
|
||||
needs: [ build, dart, plugins, go, android, rust, windows-helper-test ]
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
telegram-bot-api:
|
||||
@@ -462,7 +482,7 @@ jobs:
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
+15
-2
@@ -63,6 +63,7 @@ func handleSetupConfig(params *SetupParams) error {
|
||||
}
|
||||
|
||||
func handleGetConfig(path string) (*config.RawConfig, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -100,11 +101,23 @@ func loadConfig(path string) (*config.Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return withPlatformLimits(cfg), nil
|
||||
}
|
||||
|
||||
func withPlatformLimits(cfg *config.Config) *config.Config {
|
||||
if cfg.NTP.WriteToSystem && !systemTimeWritable {
|
||||
log.Warnln("ntp write-to-system ignored: Android does not let apps set the system time")
|
||||
cfg.NTP.WriteToSystem = false
|
||||
}
|
||||
return cfg, nil
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadSetupConfig(params *SetupParams) (*config.Config, error) {
|
||||
path := filepath.Join(constant.Path.HomeDir(), "config.yaml")
|
||||
if params.Base != nil {
|
||||
return loadConfigWithBase(params.Base, path)
|
||||
}
|
||||
return loadConfig(path)
|
||||
}
|
||||
|
||||
func applyConfig(params *SetupParams) error {
|
||||
@@ -114,7 +127,7 @@ func applyConfig(params *SetupParams) error {
|
||||
|
||||
setTestURL(params.TestURL)
|
||||
skipCertVerify.Store(params.SkipCertVerify)
|
||||
cfg, err := loadConfig(filepath.Join(constant.Path.HomeDir(), "config.yaml"))
|
||||
cfg, err := loadSetupConfig(params)
|
||||
if err != nil {
|
||||
// The fallback is what keeps the listeners serving while the host
|
||||
// reports the error, but it applies a config with no proxies in it.
|
||||
|
||||
@@ -31,6 +31,7 @@ func handleInitClash(params *InitParams) bool {
|
||||
sdkVersion.Store(int32(params.Version))
|
||||
constant.SetHomeDir(params.HomeDir)
|
||||
initOwnership(params.HomeDir)
|
||||
clearSpilledResponses()
|
||||
isInit.Store(true)
|
||||
return true
|
||||
}
|
||||
|
||||
+5
-1
@@ -29,6 +29,7 @@ type MethodResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Result any `json:"result"`
|
||||
Error *MethodError `json:"error,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
}
|
||||
|
||||
var methodCallSeq atomic.Uint64
|
||||
@@ -75,7 +76,7 @@ func handleMethodCall(call *MethodCall) (data []byte) {
|
||||
if err != nil {
|
||||
return encodeResponse(MethodResponse{ID: call.ID, Error: asMethodError(err)})
|
||||
}
|
||||
return encodeResponse(MethodResponse{ID: call.ID, Result: result})
|
||||
return spillResponse(call.ID, encodeResponse(MethodResponse{ID: call.ID, Result: result}))
|
||||
}
|
||||
|
||||
func encodeResponse(response MethodResponse) []byte {
|
||||
@@ -189,6 +190,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
validateConfigMethod: withMessage(handleValidateConfig),
|
||||
decryptConfigMethod: withFallible(handleDecryptConfig),
|
||||
getConfigMethod: withFallible(handleGetConfig),
|
||||
getProfileBaseMethod: withFallible(handleGetProfileBase),
|
||||
getProfileOutboundsMethod: withFallible(handleGetProfileOutbounds),
|
||||
buildProfileMethod: withFallible(handleBuildProfile),
|
||||
setupConfigMethod: withMessage(handleSetupConfig),
|
||||
updateConfigMethod: withMessage(updateConfig),
|
||||
validateProxiesMethod: withArguments(handleValidateProxies),
|
||||
|
||||
@@ -5,3 +5,5 @@ package core
|
||||
func initOwnership(homeDir string) {}
|
||||
|
||||
func scheduleReclaimOwnership() {}
|
||||
|
||||
func reclaimDirOwnership(path string) {}
|
||||
|
||||
@@ -153,6 +153,22 @@ func reclaimOwnership(homeDir string) {
|
||||
log.Infoln("[APP] reclaimed %d of %d elevated entries under %s", reclaimed, len(targets), homeDir)
|
||||
}
|
||||
|
||||
func reclaimDirOwnership(path string) {
|
||||
homeDir, _ := reclaimHomeDir.Load().(string)
|
||||
if !isElevated() || !canReclaim(homeDir, realUid) {
|
||||
return
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.IsDir() || !shouldReclaimEntry(info, realUid) {
|
||||
return
|
||||
}
|
||||
stat := info.Sys().(*syscall.Stat_t)
|
||||
target := reclaimTarget{path: path, dev: uint64(stat.Dev), ino: uint64(stat.Ino)}
|
||||
if _, err := reclaimEntry(target, realUid, realGid); err != nil {
|
||||
log.Warnln("[APP] reclaim %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func initOwnership(homeDir string) {
|
||||
reclaimHomeDir.Store(homeDir)
|
||||
reclaimOwnership(homeDir)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/metacubex/mihomo/component/age"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// What makes a profile large stays in the Core; the overlay the host sends
|
||||
// back replaces every other section.
|
||||
var profileBaseSections = []string{"proxies", "proxy-groups", "sub-rules"}
|
||||
|
||||
func handleGetProfileBase(path string) (*ProfileBase, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256(buf)
|
||||
raw, err := unmarshalRawConfig(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := &ProfileBase{
|
||||
Digest: hex.EncodeToString(digest[:]),
|
||||
MatchRules: []string{},
|
||||
Proxies: len(raw.Proxy),
|
||||
ProxyGroups: len(raw.ProxyGroup),
|
||||
Rules: len(raw.Rule),
|
||||
}
|
||||
for _, rule := range raw.Rule {
|
||||
if isMatchRule(rule) {
|
||||
base.MatchRules = append(base.MatchRules, rule)
|
||||
}
|
||||
}
|
||||
raw.Proxy, raw.ProxyGroup, raw.Rule, raw.SubRules = nil, nil, nil, nil
|
||||
base.Config = raw
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func handleGetProfileOutbounds(path string) (*ProfileOutbounds, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := unmarshalRawConfig(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileOutbounds{Proxies: outboundsOf(raw.Proxy), ProxyGroups: outboundsOf(raw.ProxyGroup)}, nil
|
||||
}
|
||||
|
||||
func outboundsOf(mappings []map[string]any) []ProfileOutbound {
|
||||
outbounds := make([]ProfileOutbound, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
name, _ := mapping["name"].(string)
|
||||
kind, _ := mapping["type"].(string)
|
||||
outbounds = append(outbounds, ProfileOutbound{Name: name, Type: kind})
|
||||
}
|
||||
return outbounds
|
||||
}
|
||||
|
||||
// The merged config goes to the spill directory, so the host reads it as text
|
||||
// and never decodes it.
|
||||
func handleBuildProfile(base *SetupBase) (string, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
document, err := mergeProfileBase(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir, err := makeSpilledResponseDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := writeDocumentFile(dir, "*.yaml", document)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheduleReclaimOwnership()
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Mirrors Rule.parse in Dart, which reads the type from the first field.
|
||||
func isMatchRule(rule string) bool {
|
||||
kind, _, _ := strings.Cut(rule, ",")
|
||||
return strings.EqualFold(strings.TrimSpace(kind), "MATCH")
|
||||
}
|
||||
|
||||
func loadConfigWithBase(base *SetupBase, configPath string) (*config.Config, error) {
|
||||
document, err := mergeProfileBase(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := config.DefaultRawConfig()
|
||||
if err := document.Decode(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeConfigDocument(configPath, document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := config.ParseRawConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return withPlatformLimits(cfg), nil
|
||||
}
|
||||
|
||||
func mergeProfileBase(base *SetupBase) (*yaml.Node, error) {
|
||||
buf, err := os.ReadFile(base.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buf, err = age.DecryptBytes(buf); err != nil {
|
||||
return nil, fmt.Errorf("decrypt config error: %w", err)
|
||||
}
|
||||
retag := bytes.Contains(buf, []byte("short-id"))
|
||||
var profileDocument, overlayDocument yaml.Node
|
||||
if err := yaml.Unmarshal(buf, &profileDocument); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(base.Overlay), &overlayDocument); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile, err := documentMapping(&profileDocument)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := documentMapping(&overlayDocument)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range profileBaseSections {
|
||||
if value := mappingValue(profile, key); value != nil {
|
||||
setMappingValue(root, key, value)
|
||||
}
|
||||
}
|
||||
rules, err := appendRules(mappingValue(root, "rules"), mappingValue(profile, "rules"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rules != nil {
|
||||
setMappingValue(root, "rules", rules)
|
||||
}
|
||||
if retag {
|
||||
retagShortIDs(root)
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}, nil
|
||||
}
|
||||
|
||||
func documentMapping(document *yaml.Node) (*yaml.Node, error) {
|
||||
node := document
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = resolveAlias(node.Content[0])
|
||||
}
|
||||
switch {
|
||||
case node.Kind == yaml.MappingNode:
|
||||
return node, nil
|
||||
case node.Kind == 0, node.Kind == yaml.DocumentNode, isNull(node):
|
||||
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("line %d: a config must be a mapping", node.Line)
|
||||
}
|
||||
|
||||
// A key the mapping lacks is looked up in its merge keys, as the decoder does.
|
||||
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
|
||||
var sources []*yaml.Node
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
name, value := mapping.Content[i], mapping.Content[i+1]
|
||||
if name.Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
if name.ShortTag() == "!!merge" {
|
||||
sources = append(sources, value)
|
||||
} else if name.Value == key {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, source := range sources {
|
||||
source = resolveAlias(source)
|
||||
candidates := []*yaml.Node{source}
|
||||
if source.Kind == yaml.SequenceNode {
|
||||
candidates = source.Content
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate = resolveAlias(candidate); candidate.Kind == yaml.MappingNode {
|
||||
if value := mappingValue(candidate, key); value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setMappingValue(mapping *yaml.Node, key string, value *yaml.Node) {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if name := mapping.Content[i]; name.Kind == yaml.ScalarNode && name.Value == key {
|
||||
mapping.Content[i+1] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
mapping.Content = append(
|
||||
mapping.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
// The overlay's rules are the ones the host adds in front of the profile's.
|
||||
func appendRules(added, profile *yaml.Node) (*yaml.Node, error) {
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
added, profile = resolveAlias(added), resolveAlias(profile)
|
||||
if added == nil || isNull(added) || (added.Kind == yaml.SequenceNode && len(added.Content) == 0) {
|
||||
return profile, nil
|
||||
}
|
||||
if isNull(profile) {
|
||||
return added, nil
|
||||
}
|
||||
if added.Kind != yaml.SequenceNode || profile.Kind != yaml.SequenceNode {
|
||||
return nil, fmt.Errorf("line %d: rules must be a list", profile.Line)
|
||||
}
|
||||
content := make([]*yaml.Node, 0, len(added.Content)+len(profile.Content))
|
||||
content = append(append(content, added.Content...), profile.Content...)
|
||||
return &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: content}, nil
|
||||
}
|
||||
|
||||
func resolveAlias(node *yaml.Node) *yaml.Node {
|
||||
for node != nil && node.Kind == yaml.AliasNode && node.Alias != nil {
|
||||
node = node.Alias
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func isNull(node *yaml.Node) bool {
|
||||
return node.Kind == yaml.ScalarNode && node.ShortTag() == "!!null"
|
||||
}
|
||||
|
||||
// config.yaml is read again without this process: by the Android service
|
||||
// starting on its own and by mihomo's own reload through the controller.
|
||||
func writeConfigDocument(path string, document *yaml.Node) error {
|
||||
temporary, err := writeDocumentFile(filepath.Dir(path), ".config-*.yaml", document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
_ = os.Remove(temporary)
|
||||
return err
|
||||
}
|
||||
scheduleReclaimOwnership()
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDocumentFile(dir, pattern string, document *yaml.Node) (string, error) {
|
||||
inlineAliases(document)
|
||||
file, err := os.CreateTemp(dir, pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
writer := bufio.NewWriterSize(file, 1<<20)
|
||||
err = errors.Join(
|
||||
encodeConfigMapping(writer, document.Content[0]),
|
||||
writer.Flush(),
|
||||
file.Close(),
|
||||
os.Chmod(file.Name(), 0o644),
|
||||
)
|
||||
if err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return "", err
|
||||
}
|
||||
return file.Name(), nil
|
||||
}
|
||||
|
||||
// One Encode call keeps every event it emitted until it returns, several times
|
||||
// the size of a large profile, so a long top-level list goes out in slices.
|
||||
const encodeSliceLength = 4096
|
||||
|
||||
// The mapping is drained as it is written, so the parse that follows does not
|
||||
// share the heap with the whole tree.
|
||||
func encodeConfigMapping(w io.Writer, mapping *yaml.Node) error {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
key, value := mapping.Content[i], mapping.Content[i+1]
|
||||
mapping.Content[i+1] = nil
|
||||
if value.Kind != yaml.SequenceNode || len(value.Content) <= encodeSliceLength {
|
||||
pair := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{key, value}}
|
||||
if err := encodeYamlPiece(w, pair, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := encodeYamlPiece(w, key, []byte(":\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
for start := 0; start < len(value.Content); start += encodeSliceLength {
|
||||
items := value.Content[start:min(start+encodeSliceLength, len(value.Content))]
|
||||
slice := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: items}
|
||||
if err := encodeYamlPiece(w, slice, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
clear(items)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Each piece is a document of its own; an end marker left between two of them
|
||||
// would end config.yaml there.
|
||||
func encodeYamlPiece(w io.Writer, node *yaml.Node, suffix []byte) error {
|
||||
var buffer bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buffer)
|
||||
encoder.SetIndent(2)
|
||||
if err := errors.Join(encoder.Encode(node), encoder.Close()); err != nil {
|
||||
return err
|
||||
}
|
||||
piece := bytes.TrimSuffix(buffer.Bytes(), []byte("...\n"))
|
||||
if suffix != nil {
|
||||
piece = append(bytes.TrimSuffix(piece, []byte("\n")), suffix...)
|
||||
}
|
||||
_, err := w.Write(piece)
|
||||
return err
|
||||
}
|
||||
|
||||
// An anchor may sit in a section the overlay replaced, so every alias is
|
||||
// written out in full, as the host's own encoder always did.
|
||||
func inlineAliases(root *yaml.Node) {
|
||||
visited := map[*yaml.Node]struct{}{}
|
||||
var walk func(node *yaml.Node)
|
||||
walk = func(node *yaml.Node) {
|
||||
if _, seen := visited[node]; seen {
|
||||
return
|
||||
}
|
||||
if node.Anchor != "" {
|
||||
visited[node] = struct{}{}
|
||||
node.Anchor = ""
|
||||
}
|
||||
for i, child := range node.Content {
|
||||
child = resolveAlias(child)
|
||||
node.Content[i] = child
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const baseProfile = `x-defaults: &socks {type: socks5, server: 127.0.0.1, port: 1080}
|
||||
mixed-port: 7000
|
||||
dns:
|
||||
enable: true
|
||||
nameserver: [223.5.5.5]
|
||||
proxies:
|
||||
- {<<: *socks, name: a}
|
||||
- name: b
|
||||
type: vless
|
||||
server: 127.0.0.1
|
||||
port: 443
|
||||
uuid: 2a70e3a9-b4ce-4f20-908d-e99950a0a611
|
||||
network: tcp
|
||||
tls: true
|
||||
reality-opts: {public-key: SbVKOEMjK0sIlbwg4akyBg5mL5KZwwB-ed4eEE7YnRc, short-id: 0123}
|
||||
proxy-groups:
|
||||
- {name: g, type: select, proxies: [a, b]}
|
||||
sub-rules:
|
||||
sub: ["MATCH,g"]
|
||||
rules:
|
||||
- DOMAIN-SUFFIX,example.com,g
|
||||
- match,DIRECT
|
||||
- MATCH,g
|
||||
`
|
||||
|
||||
func writeBaseProfile(t *testing.T, profile string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile.yaml")
|
||||
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// The overlay is the host's patch of what getProfileBase returned.
|
||||
func overlayOf(t *testing.T, base *ProfileBase, rules []string, patch map[string]any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(base.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal base: %v", err)
|
||||
}
|
||||
var overlay map[string]any
|
||||
if err := json.Unmarshal(data, &overlay); err != nil {
|
||||
t.Fatalf("unmarshal base: %v", err)
|
||||
}
|
||||
delete(overlay, "rule")
|
||||
overlay["rules"] = rules
|
||||
for key, value := range patch {
|
||||
overlay[key] = value
|
||||
}
|
||||
data, err = json.Marshal(overlay)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal overlay: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func ruleStrings(cfg *config.Config) []string {
|
||||
rules := make([]string, 0, len(cfg.Rules))
|
||||
for _, rule := range cfg.Rules {
|
||||
rules = append(rules, rule.RuleType().String()+","+rule.Payload()+","+rule.Adapter())
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func TestHandleGetProfileBaseLeavesTheBulkSectionsInTheCore(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
|
||||
base, err := handleGetProfileBase(path)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
raw := base.Config
|
||||
if raw.Proxy != nil || raw.ProxyGroup != nil || raw.Rule != nil || raw.SubRules != nil {
|
||||
t.Errorf("config kept a bulk section: %+v", raw)
|
||||
}
|
||||
if raw.MixedPort != 7000 || !raw.DNS.Enable {
|
||||
t.Errorf("config dropped a section the host patches: mixed-port %d, dns %t", raw.MixedPort, raw.DNS.Enable)
|
||||
}
|
||||
if base.Proxies != 2 || base.ProxyGroups != 1 || base.Rules != 3 {
|
||||
t.Errorf("counts = %d/%d/%d, want 2/1/3", base.Proxies, base.ProxyGroups, base.Rules)
|
||||
}
|
||||
if want := []string{"match,DIRECT", "MATCH,g"}; !reflect.DeepEqual(base.MatchRules, want) {
|
||||
t.Errorf("match rules = %v, want %v", base.MatchRules, want)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(baseProfile))
|
||||
if base.Digest != hex.EncodeToString(digest[:]) {
|
||||
t.Errorf("digest = %s, want the sha256 of the file", base.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetProfileOutboundsListsOnlyNamesAndTypes(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
|
||||
outbounds, err := handleGetProfileOutbounds(path)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileOutbounds = %v", err)
|
||||
}
|
||||
want := &ProfileOutbounds{
|
||||
Proxies: []ProfileOutbound{{Name: "a", Type: "socks5"}, {Name: "b", Type: "vless"}},
|
||||
ProxyGroups: []ProfileOutbound{{Name: "g", Type: "select"}},
|
||||
}
|
||||
if !reflect.DeepEqual(outbounds, want) {
|
||||
t.Errorf("outbounds = %+v, want %+v", outbounds, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBuildProfileWritesTheMergedConfigWhereTheHostReadsSpills(t *testing.T) {
|
||||
withSpillHome(t, responseSpillThreshold)
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
base, err := handleGetProfileBase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
overlay := overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, nil)
|
||||
|
||||
built, err := handleBuildProfile(&SetupBase{Path: path, Overlay: overlay})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("handleBuildProfile = %v", err)
|
||||
}
|
||||
if filepath.Dir(built) != spilledResponseDir() {
|
||||
t.Errorf("built %s, want it in %s", built, spilledResponseDir())
|
||||
}
|
||||
cfg, err := loadConfig(built)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig(built) = %v", err)
|
||||
}
|
||||
if got := ruleStrings(cfg); len(got) != 4 || got[0] != "Domain,added.test,DIRECT" {
|
||||
t.Errorf("rules = %v, want the added rule before the profile's three", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithBasePutsTheAddedRulesFirstAndTheOverlayOverTheRest(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
base, err := handleGetProfileBase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
overlay := overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, map[string]any{
|
||||
"mixed-port": 7890,
|
||||
"dns": map[string]any{"enable": true, "nameserver": []string{"1.1.1.1"}},
|
||||
})
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
cfg, err := loadConfigWithBase(&SetupBase{Path: path, Overlay: overlay}, configPath)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfigWithBase = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"Domain,added.test,DIRECT",
|
||||
"DomainSuffix,example.com,g",
|
||||
"Match,,DIRECT",
|
||||
"Match,,g",
|
||||
}
|
||||
if got := ruleStrings(cfg); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("rules = %v, want %v", got, want)
|
||||
}
|
||||
if cfg.General.MixedPort != 7890 {
|
||||
t.Errorf("mixed-port = %d, want the overlay's", cfg.General.MixedPort)
|
||||
}
|
||||
if len(cfg.DNS.NameServer) != 1 || !strings.Contains(cfg.DNS.NameServer[0].Addr, "1.1.1.1") {
|
||||
t.Errorf("nameservers = %+v, want only the overlay's", cfg.DNS.NameServer)
|
||||
}
|
||||
for _, name := range []string{"a", "b", "g"} {
|
||||
if cfg.Proxies[name] == nil {
|
||||
t.Errorf("proxy %s is missing", name)
|
||||
}
|
||||
}
|
||||
if len(cfg.SubRules["sub"]) != 1 {
|
||||
t.Errorf("sub-rules = %v, want the profile's", cfg.SubRules)
|
||||
}
|
||||
}
|
||||
|
||||
// The Android service and mihomo's controller reload config.yaml without the
|
||||
// profile, and the anchor it was written with sits in a section the overlay drops.
|
||||
func TestLoadConfigWithBaseWritesAConfigThatLoadsAlone(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
base, err := handleGetProfileBase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
cfg, err := loadConfigWithBase(
|
||||
&SetupBase{Path: path, Overlay: overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, nil)},
|
||||
configPath,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfigWithBase = %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := loadConfig(configPath)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig(config.yaml) = %v", err)
|
||||
}
|
||||
if got, want := ruleStrings(reloaded), ruleStrings(cfg); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("reloaded rules = %v, want %v", got, want)
|
||||
}
|
||||
if len(reloaded.Proxies) != len(cfg.Proxies) {
|
||||
t.Errorf("reloaded %d proxies, want %d", len(reloaded.Proxies), len(cfg.Proxies))
|
||||
}
|
||||
raw, err := handleGetConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetConfig(config.yaml) = %v", err)
|
||||
}
|
||||
for _, proxy := range raw.Proxy {
|
||||
if proxy["name"] != "b" {
|
||||
continue
|
||||
}
|
||||
if got := proxy["reality-opts"].(map[string]any)["short-id"]; got != "0123" {
|
||||
t.Errorf("short-id = %#v, want the literal 0123", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithBaseKeepsAnEmptyProfileOnTheDefaults(t *testing.T) {
|
||||
path := writeBaseProfile(t, "")
|
||||
|
||||
cfg, err := loadConfigWithBase(
|
||||
&SetupBase{Path: path, Overlay: `{"rules": ["MATCH,DIRECT"]}`},
|
||||
filepath.Join(t.TempDir(), "config.yaml"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfigWithBase = %v", err)
|
||||
}
|
||||
if got := ruleStrings(cfg); !reflect.DeepEqual(got, []string{"Match,,DIRECT"}) {
|
||||
t.Errorf("rules = %v, want the added rule alone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithBaseRejectsAProfileThatIsNotAMapping(t *testing.T) {
|
||||
path := writeBaseProfile(t, "- a\n- b\n")
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
if _, err := loadConfigWithBase(&SetupBase{Path: path, Overlay: "{}"}, configPath); err == nil {
|
||||
t.Fatal("loadConfigWithBase accepted a list as a config")
|
||||
}
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
t.Errorf("stat config.yaml = %v, want the failed apply to leave it alone", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSetupConfigReadsConfigYamlWithoutABase(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
previous := constant.Path.HomeDir()
|
||||
constant.SetHomeDir(home)
|
||||
t.Cleanup(func() { constant.SetHomeDir(previous) })
|
||||
profile := "rules:\n - MATCH,REJECT\n"
|
||||
if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadSetupConfig(defaultSetupParams())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadSetupConfig = %v", err)
|
||||
}
|
||||
if got := ruleStrings(cfg); !reflect.DeepEqual(got, []string{"Match,,REJECT"}) {
|
||||
t.Errorf("rules = %v, want config.yaml's", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeConfigMappingKeepsEveryKeyAfterAnOpenEndedScalar(t *testing.T) {
|
||||
rules := make([]string, encodeSliceLength*2+1)
|
||||
for i := range rules {
|
||||
rules[i] = fmt.Sprintf("DOMAIN,%d.test,DIRECT", i)
|
||||
}
|
||||
var mapping yaml.Node
|
||||
if err := mapping.Encode(map[string]any{"rules": rules}); err != nil {
|
||||
t.Fatalf("encode rules: %v", err)
|
||||
}
|
||||
note := []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "a-note"},
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "kept\n\n", Style: yaml.LiteralStyle},
|
||||
}
|
||||
mapping.Content = append(note, mapping.Content...)
|
||||
var out bytes.Buffer
|
||||
|
||||
if err := encodeConfigMapping(&out, &mapping); err != nil {
|
||||
t.Fatalf("encodeConfigMapping = %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Note string `yaml:"a-note"`
|
||||
Rules []string `yaml:"rules"`
|
||||
}
|
||||
if err := yaml.Unmarshal(out.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode = %v", err)
|
||||
}
|
||||
if decoded.Note != "kept\n\n" {
|
||||
t.Errorf("note = %q, want its trailing lines kept", decoded.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded.Rules, rules) {
|
||||
t.Errorf("decoded %d rules, want all %d in order", len(decoded.Rules), len(rules))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/metacubex/http"
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
P "github.com/metacubex/mihomo/component/process"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
@@ -56,6 +57,9 @@ const (
|
||||
crashMethod CoreMethod = "crash"
|
||||
setupConfigMethod CoreMethod = "setupConfig"
|
||||
getConfigMethod CoreMethod = "getConfig"
|
||||
getProfileBaseMethod CoreMethod = "getProfileBase"
|
||||
getProfileOutboundsMethod CoreMethod = "getProfileOutbounds"
|
||||
buildProfileMethod CoreMethod = "buildProfile"
|
||||
dumpRuleSetMethod CoreMethod = "dumpRuleSet"
|
||||
compileRuleSetMethod CoreMethod = "compileRuleSet"
|
||||
clearEffectMethod CoreMethod = "clearEffect"
|
||||
@@ -95,6 +99,31 @@ type SetupParams struct {
|
||||
SelectedMap map[string]string `json:"selected-map"`
|
||||
TestURL string `json:"test-url"`
|
||||
SkipCertVerify bool `json:"skip-cert-verify"`
|
||||
Base *SetupBase `json:"base,omitempty"`
|
||||
}
|
||||
|
||||
type SetupBase struct {
|
||||
Path string `json:"path"`
|
||||
Overlay string `json:"overlay"`
|
||||
}
|
||||
|
||||
type ProfileBase struct {
|
||||
Config *config.RawConfig `json:"config"`
|
||||
Digest string `json:"digest"`
|
||||
MatchRules []string `json:"match-rules"`
|
||||
Proxies int `json:"proxies"`
|
||||
ProxyGroups int `json:"proxy-groups"`
|
||||
Rules int `json:"rules"`
|
||||
}
|
||||
|
||||
type ProfileOutbounds struct {
|
||||
Proxies []ProfileOutbound `json:"proxies"`
|
||||
ProxyGroups []ProfileOutbound `json:"proxy-groups"`
|
||||
}
|
||||
|
||||
type ProfileOutbound struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type UpdateParams struct {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
)
|
||||
|
||||
// Android copies a reply into the Java heap three times and the desktop IPC
|
||||
// frame stops at maxIPCFrameSize. The directory name is mirrored in Dart.
|
||||
const (
|
||||
responseSpillThreshold = 1 << 20
|
||||
spilledResponseDirName = "responses"
|
||||
)
|
||||
|
||||
var spillThreshold = responseSpillThreshold
|
||||
|
||||
func spilledResponseDir() string {
|
||||
return filepath.Join(constant.Path.HomeDir(), spilledResponseDirName)
|
||||
}
|
||||
|
||||
// The host deletes each file as soon as it reads it, which a directory an
|
||||
// elevated Core just made would refuse until the debounced reclaim.
|
||||
func makeSpilledResponseDir() (string, error) {
|
||||
dir := spilledResponseDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
reclaimDirOwnership(dir)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func spillResponse(id string, data []byte) []byte {
|
||||
if len(data) <= spillThreshold {
|
||||
return data
|
||||
}
|
||||
path, err := writeSpilledResponse(data)
|
||||
if err != nil {
|
||||
logError("spill response %s: %v", id, err)
|
||||
return data
|
||||
}
|
||||
return encodeResponse(MethodResponse{ID: id, File: path})
|
||||
}
|
||||
|
||||
func writeSpilledResponse(data []byte) (path string, err error) {
|
||||
dir, err := makeSpilledResponseDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
file, err := os.CreateTemp(dir, "*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = file.Name()
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
// An elevated Core still answers a host running as the app user.
|
||||
if err := file.Chmod(0o644); err != nil {
|
||||
_ = file.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheduleReclaimOwnership()
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func clearSpilledResponses() {
|
||||
entries, err := os.ReadDir(spilledResponseDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
_ = os.Remove(filepath.Join(spilledResponseDir(), entry.Name()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
)
|
||||
|
||||
func withSpillHome(t *testing.T, threshold int) string {
|
||||
t.Helper()
|
||||
previousHome := constant.Path.HomeDir()
|
||||
previousThreshold := spillThreshold
|
||||
home := t.TempDir()
|
||||
constant.SetHomeDir(home)
|
||||
spillThreshold = threshold
|
||||
t.Cleanup(func() {
|
||||
constant.SetHomeDir(previousHome)
|
||||
spillThreshold = previousThreshold
|
||||
})
|
||||
return home
|
||||
}
|
||||
|
||||
func getConfigCall(t *testing.T, id string) *MethodCall {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile.yaml")
|
||||
if err := os.WriteFile(path, []byte("mode: rule\nrules:\n - MATCH,DIRECT\n"), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
arguments, _ := json.Marshal(path)
|
||||
return &MethodCall{ID: id, Method: getConfigMethod, Arguments: arguments}
|
||||
}
|
||||
|
||||
func TestHandleMethodCallSpillsALargeResultToAFile(t *testing.T) {
|
||||
home := withSpillHome(t, 64)
|
||||
|
||||
frame := handleMethodCall(getConfigCall(t, "7"))
|
||||
|
||||
var envelope MethodResponse
|
||||
if err := json.Unmarshal(frame, &envelope); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if envelope.ID != "7" || envelope.Result != nil || envelope.Error != nil {
|
||||
t.Fatalf("envelope = %+v, want only the id and the file", envelope)
|
||||
}
|
||||
if filepath.Dir(envelope.File) != filepath.Join(home, spilledResponseDirName) {
|
||||
t.Fatalf("file = %s, want it under the home directory's spill directory", envelope.File)
|
||||
}
|
||||
data, err := os.ReadFile(envelope.File)
|
||||
if err != nil {
|
||||
t.Fatalf("read spilled response: %v", err)
|
||||
}
|
||||
var spilled struct {
|
||||
ID string `json:"id"`
|
||||
Result map[string]any `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &spilled); err != nil {
|
||||
t.Fatalf("spilled response is not valid JSON: %v", err)
|
||||
}
|
||||
if spilled.ID != "7" || spilled.Result["mode"] != "rule" {
|
||||
t.Errorf("spilled response = %+v, want the full answer to call 7", spilled)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, _ := os.Stat(envelope.File)
|
||||
if info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode = %v, want the host to be able to read it", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMethodCallAnswersInlineUnderTheThreshold(t *testing.T) {
|
||||
home := withSpillHome(t, responseSpillThreshold)
|
||||
|
||||
frame := handleMethodCall(getConfigCall(t, "8"))
|
||||
|
||||
var envelope MethodResponse
|
||||
if err := json.Unmarshal(frame, &envelope); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if envelope.File != "" || envelope.Result == nil {
|
||||
t.Errorf("envelope = %+v, want the result inline", envelope)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, spilledResponseDirName)); !os.IsNotExist(err) {
|
||||
t.Errorf("stat spill directory = %v, want nothing written", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSpilledResponsesKeepsTheDirectory(t *testing.T) {
|
||||
home := withSpillHome(t, 64)
|
||||
handleMethodCall(getConfigCall(t, "9"))
|
||||
|
||||
clearSpilledResponses()
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(home, spilledResponseDirName))
|
||||
if err != nil {
|
||||
t.Fatalf("read spill directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("entries = %v, want none left", entries)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FlClashHttpOverrides extends HttpOverrides {
|
||||
@@ -12,6 +13,25 @@ class FlClashHttpOverrides extends HttpOverrides {
|
||||
|
||||
FlClashHttpOverrides(this._container);
|
||||
|
||||
static const _coreRootsAsset =
|
||||
'core/Clash.Meta/component/ca/ca-certificates.crt';
|
||||
|
||||
/// The bundle the core embeds; only release CI fills it.
|
||||
static Future<void> trustCoreRoots() async {
|
||||
try {
|
||||
final data = await rootBundle.load(_coreRootsAsset);
|
||||
if (data.lengthInBytes == 0) return;
|
||||
SecurityContext.defaultContext.setTrustedCertificatesBytes(
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
|
||||
);
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'trustCoreRoots error ${compactError(e)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String findProxyFor(ProviderContainer container, Uri url) {
|
||||
return findProxyForReader(container.read, url);
|
||||
}
|
||||
|
||||
+34
-61
@@ -17,14 +17,6 @@ Future<T> _decodeJSON<T>(String content) async {
|
||||
return json.decode(content);
|
||||
}
|
||||
|
||||
Future<String> encodeJSONTask<T>(T data) async {
|
||||
return compute<T, String>(_encodeJSON, data);
|
||||
}
|
||||
|
||||
Future<String> _encodeJSON<T>(T content) async {
|
||||
return json.encode(content);
|
||||
}
|
||||
|
||||
Future<String> encodeYamlTask<T>(T data) async {
|
||||
return compute<T, String>(_encodeYaml, data);
|
||||
}
|
||||
@@ -33,14 +25,6 @@ Future<String> _encodeYaml<T>(T content) async {
|
||||
return yaml.encode(content);
|
||||
}
|
||||
|
||||
Future<String> encodeMD5Task(String data) async {
|
||||
return compute<String, String>(_encodeMD5, data);
|
||||
}
|
||||
|
||||
Future<String> _encodeMD5<T>(String content) async {
|
||||
return content.toMd5();
|
||||
}
|
||||
|
||||
/// Throws [FormatException] when the file is not UTF-8.
|
||||
Future<String?> readTextFileTask(String path) {
|
||||
return compute(_readTextFile, path);
|
||||
@@ -90,34 +74,45 @@ Future<List<Group>> buildGroups(ComputeGroupsState state) async {
|
||||
}
|
||||
|
||||
Future<ClashConfig> clashConfigTask(Map<String, dynamic> data) async {
|
||||
return compute<Map<String, dynamic>, ClashConfig>(buildClashConfig, data);
|
||||
return compute<Map<String, dynamic>, ClashConfig>(_buildClashConfig, data);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
ClashConfig buildClashConfig(Map<String, dynamic> configMap) {
|
||||
final clashConfig = ClashConfig.fromJson(configMap);
|
||||
final proxyTypeMap = <String, String>{};
|
||||
for (final proxy in clashConfig.proxies) {
|
||||
proxyTypeMap[proxy.name] = proxy.type;
|
||||
}
|
||||
for (final proxyGroup in clashConfig.proxyGroups) {
|
||||
proxyTypeMap[proxyGroup.name] = proxyGroup.type.value;
|
||||
}
|
||||
return clashConfig.copyWith(proxyTypeMap: proxyTypeMap);
|
||||
ClashConfig _buildClashConfig(Map<String, dynamic> configMap) {
|
||||
return ClashConfig.fromJson(configMap);
|
||||
}
|
||||
|
||||
Future<({String yaml, String md5})> makeRealProfileTask(
|
||||
MakeRealProfileState data,
|
||||
) async {
|
||||
return compute<MakeRealProfileState, ({String yaml, String md5})>(
|
||||
_makeRealProfileTask,
|
||||
data,
|
||||
typedef ConfigCounts = ({int groups, int proxies, int rules});
|
||||
|
||||
typedef RealProfile = ({String yaml, String md5, ConfigCounts counts});
|
||||
|
||||
/// Counts what the Core was handed, not what the profile file declares.
|
||||
ConfigCounts configCountsOf(Map<Object?, Object?> config) {
|
||||
int lengthOf(Object? value) => value is List ? value.length : 0;
|
||||
return (
|
||||
groups: lengthOf(config['proxy-groups']),
|
||||
proxies: lengthOf(config['proxies']),
|
||||
rules: lengthOf(config['rules']),
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String yaml, String md5})> _makeRealProfileTask(
|
||||
MakeRealProfileState data,
|
||||
) async {
|
||||
String? lastMatchTarget(List<String> rules) {
|
||||
for (int i = rules.length - 1; i >= 0; i--) {
|
||||
final parsed = Rule.parse(rules[i]);
|
||||
if (parsed.ruleAction == RuleAction.MATCH) {
|
||||
final target = parsed.ruleTarget;
|
||||
if (target != null && target.isNotEmpty) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<RealProfile> makeRealProfileTask(MakeRealProfileState data) async {
|
||||
return compute<MakeRealProfileState, RealProfile>(_makeRealProfileTask, data);
|
||||
}
|
||||
|
||||
Future<RealProfile> _makeRealProfileTask(MakeRealProfileState data) async {
|
||||
final rawConfig = Map.from(data.rawConfig);
|
||||
final realPatchConfig = data.realPatchConfig;
|
||||
final profilesPath = data.profilesPath;
|
||||
@@ -334,16 +329,7 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
|
||||
}
|
||||
|
||||
if (hasMatchPlaceholder && replacementTarget == null) {
|
||||
for (int i = rules.length - 1; i >= 0; i--) {
|
||||
final parsed = Rule.parse(rules[i]);
|
||||
if (parsed.ruleAction == RuleAction.MATCH) {
|
||||
final target = parsed.ruleTarget;
|
||||
if (target != null && target.isNotEmpty) {
|
||||
replacementTarget = target;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
replacementTarget = lastMatchTarget(rules);
|
||||
}
|
||||
final List<String> finalAddedRules;
|
||||
|
||||
@@ -374,7 +360,7 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
|
||||
}
|
||||
rawConfig['rules'] = rules;
|
||||
final yaml = await _encodeYaml(Map<String, dynamic>.from(rawConfig));
|
||||
return (yaml: yaml, md5: yaml.toMd5());
|
||||
return (yaml: yaml, md5: yaml.toMd5(), counts: configCountsOf(rawConfig));
|
||||
}
|
||||
|
||||
typedef ShakingStoreArgs = ({
|
||||
@@ -621,16 +607,3 @@ Future<MigrationData> migrateLegacyConfig({
|
||||
disabledRules: disabledRules,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<T>> mapListTask<T, S>(List<S> results, T Function(S) mapper) async {
|
||||
return compute<({List<S> results, T Function(S) mapper}), List<T>>(
|
||||
_mapListTask,
|
||||
(results: results, mapper: mapper),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<T>> _mapListTask<T, S>(
|
||||
({List<S> results, T Function(S) mapper}) args,
|
||||
) async {
|
||||
return args.results.map((item) => args.mapper(item)).toList();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/core.dart';
|
||||
import 'package:fl_clash/core/interface.dart';
|
||||
import 'package:fl_clash/core/spill.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
@@ -290,10 +291,6 @@ class CoreController {
|
||||
return _readConfig(await appPath.getProfilePath(id.toString()));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getAppliedConfig() async {
|
||||
return _readConfig(await appPath.configFilePath);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _readConfig(String path) async {
|
||||
final data = Map<String, dynamic>.from(await _interface.getConfig(path));
|
||||
data['rules'] = data['rule'];
|
||||
@@ -301,6 +298,23 @@ class CoreController {
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getProfileOutbounds(int id) async {
|
||||
return _interface.getProfileOutbounds(
|
||||
await appPath.getProfilePath(id.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> buildProfile(SetupBase base) async {
|
||||
return readSpilledText(await _interface.buildProfile(base));
|
||||
}
|
||||
|
||||
Future<({ProfileBase base, String path})> getProfileBase(int id) async {
|
||||
final path = await appPath.getProfilePath(id.toString());
|
||||
final base = await _interface.getProfileBase(path);
|
||||
final config = Map<String, dynamic>.from(base.config)..remove('rule');
|
||||
return (base: base.copyWith(config: config), path: path);
|
||||
}
|
||||
|
||||
Future<Traffic> getTraffic(bool onlyStatisticsProxy) async {
|
||||
return _interface.getTraffic(onlyStatisticsProxy);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/event.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/core/spill.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
@@ -210,6 +211,22 @@ final class CoreRpcClient implements CoreRpcChannel {
|
||||
final id = response.id;
|
||||
final completer = id == null ? null : _pending.remove(id);
|
||||
if (completer == null || completer.isCompleted) {
|
||||
unawaited(discardSpilledResponse(response));
|
||||
return;
|
||||
}
|
||||
if (response.file != null) {
|
||||
unawaited(
|
||||
resolveSpilledResponse(
|
||||
response,
|
||||
).then((resolved) => _complete(completer, resolved)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_complete(completer, response);
|
||||
}
|
||||
|
||||
void _complete(Completer<Object?> completer, CoreMethodResponse response) {
|
||||
if (completer.isCompleted) {
|
||||
return;
|
||||
}
|
||||
final error = response.error;
|
||||
|
||||
@@ -38,6 +38,12 @@ mixin CoreInterface {
|
||||
|
||||
Future<Map<String, dynamic>> getConfig(String path);
|
||||
|
||||
Future<ProfileBase> getProfileBase(String path);
|
||||
|
||||
Future<Map<String, dynamic>> getProfileOutbounds(String path);
|
||||
|
||||
Future<String> buildProfile(SetupBase base);
|
||||
|
||||
Future<String> dumpRuleSet(String path);
|
||||
|
||||
Future<RuleSetInfo> compileRuleSet(String name, {String url = ''});
|
||||
@@ -295,6 +301,51 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProfileBase> getProfileBase(String path) async {
|
||||
final result = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getProfileBase,
|
||||
arguments: path,
|
||||
);
|
||||
if (result == null) {
|
||||
throw const CoreMethodException(
|
||||
code: 'empty_result',
|
||||
message: 'Core returned an empty profile base',
|
||||
);
|
||||
}
|
||||
return ProfileBase.fromJson(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getProfileOutbounds(String path) async {
|
||||
final result = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getProfileOutbounds,
|
||||
arguments: path,
|
||||
);
|
||||
if (result == null) {
|
||||
throw const CoreMethodException(
|
||||
code: 'empty_result',
|
||||
message: 'Core returned no outbounds',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> buildProfile(SetupBase base) async {
|
||||
final result = await _invokeMethod<String>(
|
||||
method: CoreMethod.buildProfile,
|
||||
arguments: base.toJson(),
|
||||
);
|
||||
if (result == null || result.isEmpty) {
|
||||
throw const CoreMethodException(
|
||||
code: 'empty_result',
|
||||
message: 'Core returned no built profile',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> setupConfig(SetupParams setupParams) async {
|
||||
return _invokeMessage(
|
||||
|
||||
+15
-9
@@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'desktop/model.dart';
|
||||
import 'interface.dart';
|
||||
import 'method.dart';
|
||||
import 'spill.dart';
|
||||
|
||||
class CoreLib extends CoreHandlerInterface {
|
||||
static CoreLib? _instance;
|
||||
@@ -111,18 +112,19 @@ class CoreLib extends CoreHandlerInterface {
|
||||
return _stop(allowClosed: true);
|
||||
}
|
||||
|
||||
// The service opens and closes the Core's listeners itself and reports a run
|
||||
// that ends, so once it takes the command the Core's own answer is moot.
|
||||
@override
|
||||
Future<bool> startListener() async {
|
||||
final listenerStarted = await super.startListener();
|
||||
final serviceStarted = await _service?.start() ?? false;
|
||||
return listenerStarted && serviceStarted;
|
||||
await super.startListener();
|
||||
return await _service?.start() ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> stopListener() async {
|
||||
final serviceStopped = await _service?.stop() ?? false;
|
||||
final listenerStopped = await super.stopListener();
|
||||
return serviceStopped && listenerStopped;
|
||||
await super.stopListener();
|
||||
return serviceStopped;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -131,10 +133,14 @@ class CoreLib extends CoreHandlerInterface {
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) {
|
||||
return _invokeMethod<T>(
|
||||
method: method,
|
||||
arguments: arguments,
|
||||
).withTimeout(timeout: timeout, onTimeout: () => null);
|
||||
final call = _invokeMethod<T>(method: method, arguments: arguments);
|
||||
return call.withTimeout(
|
||||
timeout: timeout,
|
||||
onTimeout: () {
|
||||
unawaited(call.then(discardSpilledFile, onError: (_) {}));
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<T?> _invokeMethod<T>({
|
||||
|
||||
@@ -17,6 +17,9 @@ enum CoreMethod {
|
||||
decodeShareLinks,
|
||||
updateConfig,
|
||||
getConfig,
|
||||
getProfileBase,
|
||||
getProfileOutbounds,
|
||||
buildProfile,
|
||||
dumpRuleSet,
|
||||
compileRuleSet,
|
||||
getProxies,
|
||||
@@ -103,7 +106,10 @@ class CoreMethodResponse {
|
||||
final Object? result;
|
||||
final CoreMethodError? error;
|
||||
|
||||
const CoreMethodResponse({this.id, this.result, this.error});
|
||||
/// Where the Core wrote the whole response instead; see `spill.dart`.
|
||||
final String? file;
|
||||
|
||||
const CoreMethodResponse({this.id, this.result, this.error, this.file});
|
||||
|
||||
factory CoreMethodResponse.fromJson(Map<String, Object?> json) {
|
||||
final error = json['error'];
|
||||
@@ -113,6 +119,7 @@ class CoreMethodResponse {
|
||||
error: error is Map
|
||||
? CoreMethodError.fromJson(Map<String, Object?>.from(error))
|
||||
: null,
|
||||
file: json['file'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +128,7 @@ class CoreMethodResponse {
|
||||
if (id != null) 'id': id,
|
||||
'result': result,
|
||||
if (error != null) 'error': error!.toJson(),
|
||||
if (file != null) 'file': file,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
import 'method.dart';
|
||||
|
||||
/// Mirrors spilledResponseDirName in core/spill.go.
|
||||
const coreSpillDirectoryName = 'responses';
|
||||
|
||||
Future<bool> _isSpilled(String file) async {
|
||||
final directory = join(await appPath.homeDirPath, coreSpillDirectoryName);
|
||||
return equals(dirname(file), directory);
|
||||
}
|
||||
|
||||
Future<CoreMethodResponse> resolveSpilledResponse(
|
||||
CoreMethodResponse response,
|
||||
) async {
|
||||
final file = response.file;
|
||||
if (file == null) {
|
||||
return response;
|
||||
}
|
||||
if (!await _isSpilled(file)) {
|
||||
return CoreMethodResponse(
|
||||
id: response.id,
|
||||
error: CoreMethodError(
|
||||
code: 'spill_error',
|
||||
message: 'Core answered with a file outside its spill directory: $file',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return CoreMethodResponse.fromJson(
|
||||
await compute(_readSpilledResponse, file),
|
||||
);
|
||||
} catch (error) {
|
||||
return CoreMethodResponse(
|
||||
id: response.id,
|
||||
error: CoreMethodError(code: 'spill_error', message: '$error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _readSpilledResponse(String path) {
|
||||
final file = File(path);
|
||||
try {
|
||||
final decoded = utf8.decoder
|
||||
.fuse(json.decoder)
|
||||
.convert(file.readAsBytesSync());
|
||||
return Map<String, Object?>.from(decoded as Map);
|
||||
} finally {
|
||||
try {
|
||||
file.deleteSync();
|
||||
} on FileSystemException catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> readSpilledText(String path) async {
|
||||
if (!await _isSpilled(path)) {
|
||||
throw CoreMethodException(
|
||||
code: 'spill_error',
|
||||
message: 'Core wrote a file outside its spill directory: $path',
|
||||
);
|
||||
}
|
||||
return compute(_readSpilledText, path);
|
||||
}
|
||||
|
||||
String _readSpilledText(String path) {
|
||||
final file = File(path);
|
||||
try {
|
||||
return file.readAsStringSync();
|
||||
} finally {
|
||||
try {
|
||||
file.deleteSync();
|
||||
} on FileSystemException catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> discardSpilledResponse(CoreMethodResponse response) async {
|
||||
await discardSpilledFile(response.file);
|
||||
await discardSpilledFile(response.result);
|
||||
}
|
||||
|
||||
Future<void> discardSpilledFile(Object? file) async {
|
||||
if (file is! String || !await _isSpilled(file)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(file).delete();
|
||||
} on FileSystemException catch (_) {}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ void main(List<String> args) {
|
||||
await RustLib.init();
|
||||
final version = await system.init();
|
||||
final container = await bootstrap.init(version);
|
||||
await FlClashHttpOverrides.trustCoreRoots();
|
||||
HttpOverrides.global = FlClashHttpOverrides(container);
|
||||
request.attach(container.read);
|
||||
runApp(
|
||||
|
||||
+25
-31
@@ -11,12 +11,37 @@ abstract class SetupParams with _$SetupParams {
|
||||
@JsonKey(name: 'selected-map') required Map<String, String> selectedMap,
|
||||
@JsonKey(name: 'test-url') required String testUrl,
|
||||
@Default(false) @JsonKey(name: 'skip-cert-verify') bool skipCertVerify,
|
||||
@JsonKey(includeIfNull: false) SetupBase? base,
|
||||
}) = _SetupParams;
|
||||
|
||||
factory SetupParams.fromJson(Map<String, dynamic> json) =>
|
||||
_$SetupParamsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SetupBase with _$SetupBase {
|
||||
const factory SetupBase({required String path, required String overlay}) =
|
||||
_SetupBase;
|
||||
|
||||
factory SetupBase.fromJson(Map<String, dynamic> json) =>
|
||||
_$SetupBaseFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ProfileBase with _$ProfileBase {
|
||||
const factory ProfileBase({
|
||||
required Map<String, dynamic> config,
|
||||
required String digest,
|
||||
@Default([]) @JsonKey(name: 'match-rules') List<String> matchRules,
|
||||
@Default(0) int proxies,
|
||||
@Default(0) @JsonKey(name: 'proxy-groups') int proxyGroups,
|
||||
@Default(0) int rules,
|
||||
}) = _ProfileBase;
|
||||
|
||||
factory ProfileBase.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileBaseFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class UpdateParams with _$UpdateParams {
|
||||
const factory UpdateParams({
|
||||
@@ -143,17 +168,6 @@ abstract class RouteSnapshot with _$RouteSnapshot {
|
||||
_$RouteSnapshotFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class UpdateGeoDataParams with _$UpdateGeoDataParams {
|
||||
const factory UpdateGeoDataParams({
|
||||
@JsonKey(name: 'geo-type') required String geoType,
|
||||
@JsonKey(name: 'geo-name') required String geoName,
|
||||
}) = _UpdateGeoDataParams;
|
||||
|
||||
factory UpdateGeoDataParams.fromJson(Map<String, Object?> json) =>
|
||||
_$UpdateGeoDataParamsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CoreEvent with _$CoreEvent {
|
||||
const factory CoreEvent({required CoreEventType type, dynamic data}) =
|
||||
@@ -278,26 +292,6 @@ extension CoreMemoryStatsExt on CoreMemoryStats {
|
||||
int get runtimeTotal => heapInuse + heapIdle + stackInuse + runtimeOther;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class Now with _$Now {
|
||||
const factory Now({required String name, required String value}) = _Now;
|
||||
|
||||
factory Now.fromJson(Map<String, Object?> json) => _$NowFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ProviderSubscriptionInfo with _$ProviderSubscriptionInfo {
|
||||
const factory ProviderSubscriptionInfo({
|
||||
@JsonKey(name: 'UPLOAD') @Default(0) int upload,
|
||||
@JsonKey(name: 'DOWNLOAD') @Default(0) int download,
|
||||
@JsonKey(name: 'TOTAL') @Default(0) int total,
|
||||
@JsonKey(name: 'EXPIRE') @Default(0) int expire,
|
||||
}) = _ProviderSubscriptionInfo;
|
||||
|
||||
factory ProviderSubscriptionInfo.fromJson(Map<String, Object?> json) =>
|
||||
_$ProviderSubscriptionInfoFromJson(json);
|
||||
}
|
||||
|
||||
SubscriptionInfo? subscriptionInfoFormCore(Map<String, Object?>? json) {
|
||||
if (json == null) return null;
|
||||
return SubscriptionInfo(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,9 @@ _SetupParams _$SetupParamsFromJson(Map<String, dynamic> json) => _SetupParams(
|
||||
selectedMap: Map<String, String>.from(json['selected-map'] as Map),
|
||||
testUrl: json['test-url'] as String,
|
||||
skipCertVerify: json['skip-cert-verify'] as bool? ?? false,
|
||||
base: json['base'] == null
|
||||
? null
|
||||
: SetupBase.fromJson(json['base'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SetupParamsToJson(_SetupParams instance) =>
|
||||
@@ -17,6 +20,38 @@ Map<String, dynamic> _$SetupParamsToJson(_SetupParams instance) =>
|
||||
'selected-map': instance.selectedMap,
|
||||
'test-url': instance.testUrl,
|
||||
'skip-cert-verify': instance.skipCertVerify,
|
||||
'base': ?instance.base,
|
||||
};
|
||||
|
||||
_SetupBase _$SetupBaseFromJson(Map<String, dynamic> json) => _SetupBase(
|
||||
path: json['path'] as String,
|
||||
overlay: json['overlay'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SetupBaseToJson(_SetupBase instance) =>
|
||||
<String, dynamic>{'path': instance.path, 'overlay': instance.overlay};
|
||||
|
||||
_ProfileBase _$ProfileBaseFromJson(Map<String, dynamic> json) => _ProfileBase(
|
||||
config: json['config'] as Map<String, dynamic>,
|
||||
digest: json['digest'] as String,
|
||||
matchRules:
|
||||
(json['match-rules'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const [],
|
||||
proxies: (json['proxies'] as num?)?.toInt() ?? 0,
|
||||
proxyGroups: (json['proxy-groups'] as num?)?.toInt() ?? 0,
|
||||
rules: (json['rules'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProfileBaseToJson(_ProfileBase instance) =>
|
||||
<String, dynamic>{
|
||||
'config': instance.config,
|
||||
'digest': instance.digest,
|
||||
'match-rules': instance.matchRules,
|
||||
'proxies': instance.proxies,
|
||||
'proxy-groups': instance.proxyGroups,
|
||||
'rules': instance.rules,
|
||||
};
|
||||
|
||||
_UpdateParams _$UpdateParamsFromJson(Map<String, dynamic> json) =>
|
||||
@@ -187,19 +222,6 @@ Map<String, dynamic> _$RouteSnapshotToJson(_RouteSnapshot instance) =>
|
||||
'picks': instance.picks,
|
||||
};
|
||||
|
||||
_UpdateGeoDataParams _$UpdateGeoDataParamsFromJson(Map<String, dynamic> json) =>
|
||||
_UpdateGeoDataParams(
|
||||
geoType: json['geo-type'] as String,
|
||||
geoName: json['geo-name'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UpdateGeoDataParamsToJson(
|
||||
_UpdateGeoDataParams instance,
|
||||
) => <String, dynamic>{
|
||||
'geo-type': instance.geoType,
|
||||
'geo-name': instance.geoName,
|
||||
};
|
||||
|
||||
_CoreEvent _$CoreEventFromJson(Map<String, dynamic> json) => _CoreEvent(
|
||||
type: $enumDecode(_$CoreEventTypeEnumMap, json['type']),
|
||||
data: json['data'],
|
||||
@@ -387,32 +409,6 @@ Map<String, dynamic> _$CoreMemoryStatsToJson(_CoreMemoryStats instance) =>
|
||||
'runtimeOther': instance.runtimeOther,
|
||||
};
|
||||
|
||||
_Now _$NowFromJson(Map<String, dynamic> json) =>
|
||||
_Now(name: json['name'] as String, value: json['value'] as String);
|
||||
|
||||
Map<String, dynamic> _$NowToJson(_Now instance) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'value': instance.value,
|
||||
};
|
||||
|
||||
_ProviderSubscriptionInfo _$ProviderSubscriptionInfoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ProviderSubscriptionInfo(
|
||||
upload: (json['UPLOAD'] as num?)?.toInt() ?? 0,
|
||||
download: (json['DOWNLOAD'] as num?)?.toInt() ?? 0,
|
||||
total: (json['TOTAL'] as num?)?.toInt() ?? 0,
|
||||
expire: (json['EXPIRE'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProviderSubscriptionInfoToJson(
|
||||
_ProviderSubscriptionInfo instance,
|
||||
) => <String, dynamic>{
|
||||
'UPLOAD': instance.upload,
|
||||
'DOWNLOAD': instance.download,
|
||||
'TOTAL': instance.total,
|
||||
'EXPIRE': instance.expire,
|
||||
};
|
||||
|
||||
_ExternalProvider _$ExternalProviderFromJson(Map<String, dynamic> json) =>
|
||||
_ExternalProvider(
|
||||
name: json['name'] as String,
|
||||
|
||||
@@ -2,7 +2,19 @@ part of '../action.dart';
|
||||
|
||||
enum _SetupTaskResult { completed, handoffToCoreRestart, failed }
|
||||
|
||||
typedef _SetupInputs = (SetupState, PatchClashConfig, NetworkProps);
|
||||
typedef _SetupInputs = (
|
||||
SetupState,
|
||||
PatchClashConfig,
|
||||
NetworkProps,
|
||||
SetupParams,
|
||||
);
|
||||
|
||||
typedef BuiltProfile = ({
|
||||
String yaml,
|
||||
String md5,
|
||||
ConfigCounts counts,
|
||||
SetupBase? base,
|
||||
});
|
||||
|
||||
class _RunRequest {
|
||||
final bool running;
|
||||
@@ -56,6 +68,10 @@ class SetupAction extends _$SetupAction {
|
||||
);
|
||||
}
|
||||
|
||||
// The selection reaches the Core through changeProxy, not through an apply.
|
||||
SetupParams get _appliedParams =>
|
||||
_setupParams.copyWith(selectedMap: const {});
|
||||
|
||||
Future<bool> fullSetup() async {
|
||||
if (!ref.read(initProvider)) return true;
|
||||
ref.read(proxiesActionProvider.notifier).cancelDelayTests();
|
||||
@@ -167,7 +183,7 @@ class SetupAction extends _$SetupAction {
|
||||
try {
|
||||
applied = await applyProfile(
|
||||
force: true,
|
||||
preloadInvoke: () => _setCoreRunning(request),
|
||||
preloadInvoke: () => _startCoreRunning(request),
|
||||
);
|
||||
} catch (_) {
|
||||
applied = false;
|
||||
@@ -179,20 +195,28 @@ class SetupAction extends _$SetupAction {
|
||||
}
|
||||
|
||||
try {
|
||||
await _setCoreRunning(request);
|
||||
await _startCoreRunning(request);
|
||||
} catch (_) {
|
||||
_rollbackRunning(request);
|
||||
rethrow;
|
||||
}
|
||||
if (_isCurrent(request)) {
|
||||
final inputsApplied = await _inputsApplied();
|
||||
if (_isCurrent(request) && !inputsApplied) {
|
||||
applyProfileDebounce(force: true, silence: true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _startCoreRunning(_RunRequest request) async {
|
||||
if (!await _setCoreRunning(request) && _isCurrent(request)) {
|
||||
throw _unanswered(CoreMethod.startListener);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _stop(_RunRequest request) async {
|
||||
final bool answered;
|
||||
try {
|
||||
await _setCoreRunning(request);
|
||||
answered = await _setCoreRunning(request);
|
||||
} catch (_) {
|
||||
_rollbackRunning(request);
|
||||
rethrow;
|
||||
@@ -203,32 +227,42 @@ class SetupAction extends _$SetupAction {
|
||||
resetCoreTraffic();
|
||||
ref.read(trafficsProvider.notifier).clear();
|
||||
ref.read(totalTrafficProvider.notifier).value = const Traffic();
|
||||
// A silent Core is gone or hung; running again would proxy traffic to it.
|
||||
if (!answered) {
|
||||
throw _unanswered(CoreMethod.stopListener);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _setCoreRunning(_RunRequest request) {
|
||||
Future<bool> _setCoreRunning(_RunRequest request) {
|
||||
return _listenerScheduler.run(() async {
|
||||
if (!_isCurrent(request)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (request.running && ref.read(suspendProvider)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (ref.read(safeModeProvider)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
await _setListening(request.running);
|
||||
return _setListening(request.running);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setListening(bool running) async {
|
||||
Future<bool> _setListening(bool running) async {
|
||||
final vpnOptions = await ref.read(vpnOptionsProvider.future);
|
||||
await setCoreRunning(running);
|
||||
ref.read(runningVpnOptionsProvider.notifier).value = running
|
||||
final answered = await setCoreRunning(running);
|
||||
ref.read(runningVpnOptionsProvider.notifier).value = running && answered
|
||||
? vpnOptions
|
||||
: null;
|
||||
return answered;
|
||||
}
|
||||
|
||||
CoreMethodException _unanswered(CoreMethod method) => CoreMethodException(
|
||||
code: 'no_response',
|
||||
message: 'Core did not answer ${method.name}',
|
||||
);
|
||||
|
||||
/// An excluded SSID parks the listener without ending the run.
|
||||
void syncSuspend() {
|
||||
debouncer.call(FunctionTag.suspend, () {
|
||||
@@ -341,6 +375,7 @@ class SetupAction extends _$SetupAction {
|
||||
setupState,
|
||||
_effectivePatchConfig(ref.read(patchClashConfigProvider)),
|
||||
ref.read(networkSettingProvider),
|
||||
_appliedParams,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,12 +430,15 @@ class SetupAction extends _$SetupAction {
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String yaml, String md5})> getProfile({
|
||||
/// With a base, [BuiltProfile.yaml] is only the overlay the Core applies.
|
||||
Future<BuiltProfile> getProfile({
|
||||
required SetupState setupState,
|
||||
required PatchClashConfig patchConfig,
|
||||
}) async {
|
||||
final profileId = setupState.profileId;
|
||||
if (profileId == null) return (yaml: '', md5: '');
|
||||
if (profileId == null) {
|
||||
return (yaml: '', md5: '', counts: configCountsOf(const {}), base: null);
|
||||
}
|
||||
final defaultUA = globalState.packageInfo.ua;
|
||||
final networkSetting = ref.read(
|
||||
networkSettingProvider.select(
|
||||
@@ -413,6 +451,14 @@ class SetupAction extends _$SetupAction {
|
||||
);
|
||||
final appendSystemDns = networkSetting.appendSystemDns;
|
||||
final isCustom = setupState.profileType == ProfileType.custom;
|
||||
final scriptContent =
|
||||
!isCustom && setupState.extendType == ExtendType.script
|
||||
? await setupState.script?.content
|
||||
: null;
|
||||
final hasScript = scriptContent?.isNotEmpty == true;
|
||||
final profileBase = !isCustom && !hasScript
|
||||
? await _core.getProfileBase(profileId)
|
||||
: null;
|
||||
Map<String, dynamic> rawConfig = isCustom
|
||||
? {
|
||||
'proxies': appProxiesPayload(
|
||||
@@ -421,14 +467,10 @@ class SetupAction extends _$SetupAction {
|
||||
groups: setupState.proxyGroups,
|
||||
),
|
||||
}
|
||||
: await _core.getConfig(profileId);
|
||||
: profileBase?.base.config ?? await _core.getConfig(profileId);
|
||||
final proxyGroups = isCustom ? setupState.proxyGroups : <ProxyGroup>[];
|
||||
final rules = isCustom ? setupState.rules : <Rule>[];
|
||||
final scriptContent =
|
||||
!isCustom && setupState.extendType == ExtendType.script
|
||||
? await setupState.script?.content
|
||||
: null;
|
||||
if (scriptContent?.isNotEmpty == true) {
|
||||
if (hasScript) {
|
||||
rawConfig = await handleEvaluate(scriptContent!, rawConfig);
|
||||
}
|
||||
final realPatchConfig = patchConfig.copyWith(
|
||||
@@ -442,7 +484,7 @@ class SetupAction extends _$SetupAction {
|
||||
proxyGroups: proxyGroups,
|
||||
rules: rules,
|
||||
);
|
||||
final res = makeRealProfileTask(
|
||||
final res = await makeRealProfileTask(
|
||||
MakeRealProfileState(
|
||||
rules: rules,
|
||||
proxyGroups: proxyGroups,
|
||||
@@ -458,11 +500,28 @@ class SetupAction extends _$SetupAction {
|
||||
addedRules: setupState.addedRules,
|
||||
defaultUA: defaultUA,
|
||||
authentication: networkSetting.authentication.credentials,
|
||||
matchTarget: setupState.matchTarget,
|
||||
matchTarget:
|
||||
profileBase == null ||
|
||||
setupState.matchTarget?.trim().isNotEmpty == true
|
||||
? setupState.matchTarget
|
||||
: lastMatchTarget(profileBase.base.matchRules),
|
||||
safeMode: ref.read(safeModeProvider),
|
||||
),
|
||||
);
|
||||
return res;
|
||||
if (profileBase == null) {
|
||||
return (yaml: res.yaml, md5: res.md5, counts: res.counts, base: null);
|
||||
}
|
||||
final (:base, :path) = profileBase;
|
||||
return (
|
||||
yaml: res.yaml,
|
||||
md5: '${res.md5}${base.digest}'.toMd5(),
|
||||
counts: (
|
||||
groups: res.counts.groups + base.proxyGroups,
|
||||
proxies: res.counts.proxies + base.proxies,
|
||||
rules: res.counts.rules + base.rules,
|
||||
),
|
||||
base: SetupBase(path: path, overlay: res.yaml),
|
||||
);
|
||||
}
|
||||
|
||||
/// Only what the custom profile actually references is injected, so an
|
||||
@@ -523,7 +582,10 @@ class SetupAction extends _$SetupAction {
|
||||
setupState: setupState,
|
||||
patchConfig: patchClashConfig,
|
||||
);
|
||||
return res.yaml;
|
||||
return switch (res.base) {
|
||||
final base? => await _core.buildProfile(base),
|
||||
null => res.yaml,
|
||||
};
|
||||
} catch (e) {
|
||||
dialogs.showNotifier(e.toString(), level: MessageLevel.error);
|
||||
}
|
||||
@@ -641,11 +703,13 @@ class SetupAction extends _$SetupAction {
|
||||
return getProfile(setupState: state, patchConfig: realPatchConfig);
|
||||
}, title: 'build profile');
|
||||
final profileFailed = realProfile == null;
|
||||
final setupParams = _setupParams.copyWith(base: realProfile?.base);
|
||||
final inputs = switch (setupState) {
|
||||
final state? when !profileFailed => (
|
||||
state,
|
||||
realPatchConfig,
|
||||
networkSetting,
|
||||
_appliedParams,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
@@ -669,14 +733,16 @@ class SetupAction extends _$SetupAction {
|
||||
await globalState.loadingRun(
|
||||
() async {
|
||||
try {
|
||||
final configFilePath = await appPath.configFilePath;
|
||||
await File(configFilePath).writeAsStringAtomically(yamlString);
|
||||
if (setupParams.base == null) {
|
||||
final configFilePath = await appPath.configFilePath;
|
||||
await File(configFilePath).writeAsStringAtomically(yamlString);
|
||||
}
|
||||
final profileId = profile?.id;
|
||||
if (profileId != null) {
|
||||
await appPath.ensureProviderDirs(profileId);
|
||||
}
|
||||
final message = await _core.setupConfig(
|
||||
params: _setupParams,
|
||||
params: setupParams,
|
||||
preloadInvoke: preloadInvoke,
|
||||
);
|
||||
if (message.isNotEmpty) {
|
||||
@@ -691,6 +757,8 @@ class SetupAction extends _$SetupAction {
|
||||
rethrow;
|
||||
}
|
||||
globalState.lastConfigMd5 = yamlMd5;
|
||||
globalState.lastConfigCounts =
|
||||
realProfile?.counts ?? configCountsOf(const {});
|
||||
_appliedInputs = inputs;
|
||||
await onUpdated?.call();
|
||||
},
|
||||
|
||||
@@ -37,8 +37,10 @@ Future<ClashConfig> clashConfig(Ref ref, int profileId) async {
|
||||
if (type == ProfileType.custom) {
|
||||
return const ClashConfig();
|
||||
}
|
||||
final configMap = await ref.read(coreHandlerProvider).getConfig(profileId);
|
||||
return clashConfigTask(configMap);
|
||||
final outbounds = await ref
|
||||
.read(coreHandlerProvider)
|
||||
.getProfileOutbounds(profileId);
|
||||
return clashConfigTask(outbounds);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
|
||||
+8
-9
@@ -1,25 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'common/common.dart';
|
||||
import 'enum/enum.dart';
|
||||
import 'models/models.dart';
|
||||
|
||||
import 'package:fl_clash/common/theme.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'common/common.dart';
|
||||
import 'enum/enum.dart';
|
||||
import 'models/models.dart';
|
||||
|
||||
class GlobalState {
|
||||
static GlobalState? _instance;
|
||||
GlobalKey<NavigatorState> get navigatorKey => rootNavigatorKey;
|
||||
late final String appEnv;
|
||||
late final PackageInfo packageInfo;
|
||||
Function? updateCurrentDelayDebounce;
|
||||
late String appEnv;
|
||||
late PackageInfo packageInfo;
|
||||
late Measure measure;
|
||||
late CommonTheme theme;
|
||||
late ProviderContainer container;
|
||||
bool needInitStatus = true;
|
||||
|
||||
@@ -33,6 +31,7 @@ class GlobalState {
|
||||
}
|
||||
|
||||
String? lastConfigMd5;
|
||||
ConfigCounts? lastConfigCounts;
|
||||
bool isAttach = false;
|
||||
|
||||
GlobalState._internal();
|
||||
|
||||
@@ -100,6 +100,7 @@ flutter:
|
||||
- assets/images/
|
||||
- assets/images/avatar/
|
||||
- assets/images/services/
|
||||
- core/Clash.Meta/component/ca/ca-certificates.crt
|
||||
- path: assets/images/tray/unix/
|
||||
platforms: [ linux ]
|
||||
- path: assets/images/tray/macos/
|
||||
|
||||
+18
-38
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
@@ -7,11 +8,9 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
int _double(int value) => value * 2;
|
||||
|
||||
void main() {
|
||||
test('encoding helpers round-trip structured data', () async {
|
||||
final encoded = await encodeJSONTask({
|
||||
final encoded = jsonEncode({
|
||||
'name': 'FlClash',
|
||||
'values': [1, true, null],
|
||||
});
|
||||
@@ -20,7 +19,6 @@ void main() {
|
||||
expect(decoded['name'], 'FlClash');
|
||||
expect(decoded['values'], [1, true, null]);
|
||||
expect(await encodeYamlTask({'enabled': true}), contains('enabled: true'));
|
||||
expect(await encodeMD5Task('abc'), '900150983cd24fb0d6963f7d28e17f72');
|
||||
});
|
||||
|
||||
test('a proxy name holding control characters stays readable YAML', () {
|
||||
@@ -107,32 +105,9 @@ void main() {
|
||||
expect(clashConfig.proxyProviders, ['provider']);
|
||||
expect(clashConfig.ruleProviders, ['ruleSet']);
|
||||
expect(clashConfig.subRules, ['nested']);
|
||||
expect(clashConfig.proxyTypeMap, {
|
||||
'Alpha': 'ss',
|
||||
'Beta': 'vmess',
|
||||
'Auto': 'url-test',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('buildClashConfig indexes group types by their clash value', () {
|
||||
final clashConfig = buildClashConfig(<String, dynamic>{
|
||||
'proxies': [
|
||||
{'name': 'Alpha', 'type': 'ss'},
|
||||
],
|
||||
'proxy-groups': [
|
||||
{
|
||||
'name': 'Fallback',
|
||||
'type': 'fallback',
|
||||
'proxies': ['Alpha'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(clashConfig.proxyTypeMap, {'Alpha': 'ss', 'Fallback': 'fallback'});
|
||||
expect(clashConfig.rules, isEmpty);
|
||||
});
|
||||
|
||||
test('toGroupsTask leaves the source proxy map untouched', () async {
|
||||
final proxies = <String, dynamic>{
|
||||
'Selector': {
|
||||
@@ -174,7 +149,7 @@ void main() {
|
||||
'makeRealProfileTask normalizes runtime config and added rules',
|
||||
() async {
|
||||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask({
|
||||
jsonEncode({
|
||||
'dns': {
|
||||
'enable': true,
|
||||
'nameserver': ['1.1.1.1'],
|
||||
@@ -250,14 +225,23 @@ void main() {
|
||||
'DOMAIN,existing.example,DIRECT',
|
||||
'MATCH,Original',
|
||||
]);
|
||||
expect(result.counts, (groups: 0, proxies: 0, rules: 3));
|
||||
},
|
||||
);
|
||||
|
||||
test('lastMatchTarget reads the last MATCH rule that names a target', () {
|
||||
expect(
|
||||
lastMatchTarget(['MATCH,A', 'DOMAIN,x.test,B', 'match,C', 'MATCH,']),
|
||||
'C',
|
||||
);
|
||||
expect(lastMatchTarget(['DOMAIN,x.test,B']), isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'makeRealProfileTask routes MATCH placeholders to matchTarget',
|
||||
() async {
|
||||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask({
|
||||
jsonEncode({
|
||||
'proxies': [],
|
||||
'rules': ['DOMAIN,existing.example,DIRECT', 'MATCH,Original'],
|
||||
}),
|
||||
@@ -304,10 +288,7 @@ void main() {
|
||||
'makeRealProfileTask lets the app setting own the geo updater',
|
||||
() async {
|
||||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask({
|
||||
'geo-auto-update': true,
|
||||
'geo-update-interval': 6,
|
||||
}),
|
||||
jsonEncode({'geo-auto-update': true, 'geo-update-interval': 6}),
|
||||
);
|
||||
|
||||
final result = await makeRealProfileTask(
|
||||
@@ -336,7 +317,7 @@ void main() {
|
||||
// A profile-shipped loopback skip-auth-prefixes would bypass the credentials.
|
||||
test('makeRealProfileTask lets the app own local authentication', () async {
|
||||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask({
|
||||
jsonEncode({
|
||||
'authentication': ['subscription:injected'],
|
||||
'skip-auth-prefixes': ['127.0.0.1/32'],
|
||||
}),
|
||||
@@ -406,7 +387,7 @@ void main() {
|
||||
|
||||
test('makeRealProfileTask keeps the DNS keys it cannot edit', () async {
|
||||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask({
|
||||
jsonEncode({
|
||||
'dns': {
|
||||
'enable': false,
|
||||
'direct-nameserver': ['223.5.5.5'],
|
||||
@@ -669,7 +650,7 @@ void main() {
|
||||
group('makeRealProfileTask interface-name mode', () {
|
||||
Future<YamlMap> runWith(PatchClashConfig realPatchConfig) async {
|
||||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask({'interface-name': 'en0'}),
|
||||
jsonEncode({'interface-name': 'en0'}),
|
||||
);
|
||||
|
||||
final result = await makeRealProfileTask(
|
||||
@@ -726,7 +707,7 @@ void main() {
|
||||
profilesPath: '/profiles',
|
||||
profileId: 14,
|
||||
rawConfig: await decodeJSONTask<Map<String, dynamic>>(
|
||||
await encodeJSONTask(rawConfig),
|
||||
jsonEncode(rawConfig),
|
||||
),
|
||||
realPatchConfig: realPatchConfig,
|
||||
appendSystemDns: false,
|
||||
@@ -865,6 +846,5 @@ void main() {
|
||||
|
||||
expect(encoded, contains('first'));
|
||||
expect(encoded, contains('\n'));
|
||||
expect(await mapListTask([1, 2, 3], _double), [2, 4, 6]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,9 +38,6 @@ void main() {
|
||||
registerFallbackValue(
|
||||
const ChangeProxyParams(groupName: 'G', proxyName: 'P'),
|
||||
);
|
||||
registerFallbackValue(
|
||||
const UpdateGeoDataParams(geoType: 't', geoName: 'n'),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fl_clash/core/desktop/rpc_client.dart';
|
||||
import 'package:fl_clash/core/event.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/core/spill.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
import '../../helpers/app_paths.dart';
|
||||
import 'fakes.dart';
|
||||
|
||||
Future<Map<String, Object?>> _sentRequest(
|
||||
@@ -17,6 +21,33 @@ Future<Map<String, Object?>> _sentRequest(
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('reads a response the Core spilled to a file', () async {
|
||||
final home = Directory.systemTemp.createTempSync('rpc_spill');
|
||||
addTearDown(() => home.deleteSync(recursive: true));
|
||||
await useAppPaths(home);
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport);
|
||||
final invocation = client.invoke<Map<String, dynamic>>(
|
||||
method: CoreMethod.getConfig,
|
||||
);
|
||||
final request = await _sentRequest(transport);
|
||||
final file = File(join(home.path, coreSpillDirectoryName, '1.json'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id': request['id'],
|
||||
'result': {'mode': 'rule'},
|
||||
}),
|
||||
);
|
||||
|
||||
transport.addJson({'id': request['id'], 'result': null, 'file': file.path});
|
||||
|
||||
expect(await invocation, {'mode': 'rule'});
|
||||
expect(file.existsSync(), isFalse);
|
||||
await client.close();
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test('correlates a response and ignores a late duplicate', () async {
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport);
|
||||
|
||||
+16
-1
@@ -218,7 +218,7 @@ void main() {
|
||||
});
|
||||
|
||||
group('listeners', () {
|
||||
test('a listener is started only when both sides agree', () async {
|
||||
test('a listener start is answered by the service', () async {
|
||||
await lib.start();
|
||||
service.onInvokeMethod = (_) =>
|
||||
const CoreMethodResponse(id: '1', result: true);
|
||||
@@ -227,6 +227,21 @@ void main() {
|
||||
|
||||
service.startResult = false;
|
||||
expect(await lib.startListener(), isFalse);
|
||||
|
||||
service.startResult = true;
|
||||
service.onInvokeMethod = (_) => null;
|
||||
expect(await lib.startListener(), isTrue);
|
||||
expect(service.calls.last, 'start');
|
||||
});
|
||||
|
||||
test('a listener stop is answered by the service', () async {
|
||||
await lib.start();
|
||||
service.onInvokeMethod = (_) => null;
|
||||
|
||||
expect(await lib.stopListener(), isTrue);
|
||||
|
||||
service.stopResult = false;
|
||||
expect(await lib.stopListener(), isFalse);
|
||||
});
|
||||
|
||||
test('stopListener stops the service before the core listener', () async {
|
||||
|
||||
@@ -86,6 +86,21 @@ class _RecordingCoreHandler extends CoreHandlerInterface {
|
||||
'mode': 'rule',
|
||||
'rule': ['MATCH,DIRECT'],
|
||||
},
|
||||
CoreMethod.getProfileOutbounds => {
|
||||
'proxies': [
|
||||
{'name': 'a', 'type': 'ss'},
|
||||
],
|
||||
'proxy-groups': <Object>[],
|
||||
},
|
||||
CoreMethod.buildProfile => '/home/responses/1.yaml',
|
||||
CoreMethod.getProfileBase => {
|
||||
'config': {'mode': 'rule', 'rule': null},
|
||||
'digest': 'abc',
|
||||
'match-rules': ['MATCH,DIRECT'],
|
||||
'proxies': 2,
|
||||
'proxy-groups': 1,
|
||||
'rules': 3,
|
||||
},
|
||||
CoreMethod.getMemoryStats => {
|
||||
'rss': 2048,
|
||||
'heapInuse': 1024,
|
||||
@@ -384,6 +399,60 @@ void main() {
|
||||
expect(memory?.runtimeTotal, 1024 + 256 + 64 + 32);
|
||||
});
|
||||
|
||||
test('getProfileBase sends the path and reads what the Core kept', () async {
|
||||
final handler = _RecordingCoreHandler();
|
||||
|
||||
final base = await handler.getProfileBase('/profiles/1.yaml');
|
||||
|
||||
expect(handler.calls[CoreMethod.getProfileBase], '/profiles/1.yaml');
|
||||
expect(
|
||||
base,
|
||||
const ProfileBase(
|
||||
config: {'mode': 'rule', 'rule': null},
|
||||
digest: 'abc',
|
||||
matchRules: ['MATCH,DIRECT'],
|
||||
proxies: 2,
|
||||
proxyGroups: 1,
|
||||
rules: 3,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the outbounds and the built profile cross as plain JSON', () async {
|
||||
final handler = _RecordingCoreHandler();
|
||||
const base = SetupBase(path: '/profiles/1.yaml', overlay: 'mode: rule');
|
||||
|
||||
final outbounds = await handler.getProfileOutbounds('/profiles/1.yaml');
|
||||
final built = await handler.buildProfile(base);
|
||||
|
||||
expect(handler.calls[CoreMethod.getProfileOutbounds], '/profiles/1.yaml');
|
||||
expect(outbounds['proxies'], [
|
||||
{'name': 'a', 'type': 'ss'},
|
||||
]);
|
||||
expect(handler.calls[CoreMethod.buildProfile], base.toJson());
|
||||
expect(built, '/home/responses/1.yaml');
|
||||
});
|
||||
|
||||
test('setupConfig names a base only when the Core applies one', () async {
|
||||
final handler = _RecordingCoreHandler();
|
||||
const params = SetupParams(selectedMap: {}, testUrl: 'test');
|
||||
Object? sent() =>
|
||||
json.decode(json.encode(handler.calls[CoreMethod.setupConfig]));
|
||||
|
||||
await handler.setupConfig(params);
|
||||
expect(sent(), isNot(contains('base')));
|
||||
|
||||
await handler.setupConfig(
|
||||
params.copyWith(
|
||||
base: const SetupBase(path: '/profiles/1.yaml', overlay: 'mode: rule'),
|
||||
),
|
||||
);
|
||||
expect((sent()! as Map)['base'], {
|
||||
'path': '/profiles/1.yaml',
|
||||
'overlay': 'mode: rule',
|
||||
});
|
||||
});
|
||||
|
||||
test('getConfig preserves structured core errors', () async {
|
||||
final handler = _FailingConfigCoreHandler();
|
||||
|
||||
@@ -429,6 +498,9 @@ void main() {
|
||||
final failure = CoreMethodResponse.fromJson(
|
||||
Map<String, Object?>.from(fixture['errorResponse'] as Map),
|
||||
);
|
||||
final spilled = CoreMethodResponse.fromJson(
|
||||
Map<String, Object?>.from(fixture['spilledResponse'] as Map),
|
||||
);
|
||||
|
||||
expect(success.unwrap<String>(), '');
|
||||
expect(success.toJson(), containsPair('result', ''));
|
||||
@@ -445,5 +517,9 @@ void main() {
|
||||
);
|
||||
expect(failure.toJson(), contains('error'));
|
||||
expect(failure.toJson(), isNot(contains('code')));
|
||||
expect(failure.file, isNull);
|
||||
expect(failure.toJson(), isNot(contains('file')));
|
||||
expect(spilled.file, '/home/responses/1234.json');
|
||||
expect(spilled.toJson(), containsPair('file', '/home/responses/1234.json'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/core/spill.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
import '../helpers/app_paths.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory home;
|
||||
late Directory spillDirectory;
|
||||
|
||||
setUp(() async {
|
||||
home = Directory.systemTemp.createTempSync('spill_test');
|
||||
await useAppPaths(home);
|
||||
spillDirectory = Directory(join(home.path, coreSpillDirectoryName))
|
||||
..createSync();
|
||||
});
|
||||
|
||||
tearDown(() => home.deleteSync(recursive: true));
|
||||
|
||||
File spill(Directory directory, Map<String, Object?> envelope) {
|
||||
return File(join(directory.path, '1.json'))
|
||||
..writeAsStringSync(json.encode(envelope));
|
||||
}
|
||||
|
||||
test('an answer sent inline passes through', () async {
|
||||
const response = CoreMethodResponse(id: '1', result: 'inline');
|
||||
|
||||
expect(await resolveSpilledResponse(response), same(response));
|
||||
});
|
||||
|
||||
test('reads a spilled answer and deletes its file', () async {
|
||||
final file = spill(spillDirectory, {
|
||||
'id': '2',
|
||||
'result': {'mode': 'rule'},
|
||||
});
|
||||
|
||||
final response = await resolveSpilledResponse(
|
||||
CoreMethodResponse(id: '2', file: file.path),
|
||||
);
|
||||
|
||||
expect(response.id, '2');
|
||||
expect(response.unwrap<Map<String, dynamic>>(), {'mode': 'rule'});
|
||||
expect(file.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('refuses a file outside the spill directory', () async {
|
||||
final file = spill(home, {'id': '3', 'result': 'secret'});
|
||||
|
||||
final response = await resolveSpilledResponse(
|
||||
CoreMethodResponse(id: '3', file: file.path),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => response.unwrap<Object?>(),
|
||||
throwsA(
|
||||
isA<CoreMethodException>().having((e) => e.code, 'code', 'spill_error'),
|
||||
),
|
||||
);
|
||||
expect(file.existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('a missing file fails the call instead of the transport', () async {
|
||||
final response = await resolveSpilledResponse(
|
||||
CoreMethodResponse(id: '4', file: join(spillDirectory.path, 'gone.json')),
|
||||
);
|
||||
|
||||
expect(response.error?.code, 'spill_error');
|
||||
});
|
||||
|
||||
test('reads a file the Core built there as text and deletes it', () async {
|
||||
final built = File(join(spillDirectory.path, 'built.yaml'))
|
||||
..writeAsStringSync('mode: rule\n');
|
||||
|
||||
expect(await readSpilledText(built.path), 'mode: rule\n');
|
||||
expect(built.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('refuses to read text outside the spill directory', () async {
|
||||
final outside = File(join(home.path, 'built.yaml'))
|
||||
..writeAsStringSync('secret');
|
||||
|
||||
await expectLater(
|
||||
readSpilledText(outside.path),
|
||||
throwsA(
|
||||
isA<CoreMethodException>().having((e) => e.code, 'code', 'spill_error'),
|
||||
),
|
||||
);
|
||||
expect(outside.existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('discarding deletes only a spilled file', () async {
|
||||
final spilled = spill(spillDirectory, {'id': '5', 'result': null});
|
||||
final outside = spill(home, {'id': '6', 'result': null});
|
||||
|
||||
await discardSpilledResponse(CoreMethodResponse(file: spilled.path));
|
||||
await discardSpilledResponse(CoreMethodResponse(file: outside.path));
|
||||
|
||||
expect(spilled.existsSync(), isFalse);
|
||||
expect(outside.existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('discarding a late built profile deletes the file it names', () async {
|
||||
final built = File(join(spillDirectory.path, 'built.yaml'))
|
||||
..writeAsStringSync('proxies: []');
|
||||
|
||||
await discardSpilledResponse(
|
||||
CoreMethodResponse(id: '7', result: built.path),
|
||||
);
|
||||
await discardSpilledResponse(
|
||||
const CoreMethodResponse(id: '8', result: 'not a path'),
|
||||
);
|
||||
|
||||
expect(built.existsSync(), isFalse);
|
||||
});
|
||||
}
|
||||
Vendored
+5
@@ -38,6 +38,11 @@
|
||||
"down": 34
|
||||
}
|
||||
},
|
||||
"spilledResponse": {
|
||||
"id": "getConfig#spilled",
|
||||
"result": null,
|
||||
"file": "/home/responses/1234.json"
|
||||
},
|
||||
"errorResponse": {
|
||||
"id": "getConfig#contract",
|
||||
"result": null,
|
||||
|
||||
@@ -170,15 +170,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('UpdateGeoDataParams', () {
|
||||
test('fromJson with snake-case keys', () {
|
||||
final json = {'geo-type': 'mmdb', 'geo-name': 'Country'};
|
||||
final params = UpdateGeoDataParams.fromJson(json);
|
||||
expect(params.geoType, 'mmdb');
|
||||
expect(params.geoName, 'Country');
|
||||
});
|
||||
});
|
||||
|
||||
group('Delay', () {
|
||||
test('fromJson and toJson', () {
|
||||
final json = {'name': 'P1', 'url': 'test.com', 'value': 42};
|
||||
@@ -196,14 +187,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('Now', () {
|
||||
test('fromJson and toJson', () {
|
||||
final now = Now.fromJson({'name': 'test', 'value': '123'});
|
||||
expect(now.name, 'test');
|
||||
expect(now.value, '123');
|
||||
});
|
||||
});
|
||||
|
||||
group('ProxiesData', () {
|
||||
test('fromJson with proxies and all list', () {
|
||||
final json = {
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'dart:io';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/controller.dart';
|
||||
import 'package:fl_clash/core/interface.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/core/spill.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
@@ -19,31 +21,17 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import '../helpers/app_paths.dart';
|
||||
import '../helpers/test_profiles.dart';
|
||||
|
||||
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
|
||||
|
||||
// checkAndUpdateAndCopy checks the file system before it refreshes, so its
|
||||
// failure tests need appPath to resolve to a real, writable directory.
|
||||
class _FakePathProvider extends PathProviderPlatform {
|
||||
final String root;
|
||||
|
||||
_FakePathProvider(this.root);
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => root;
|
||||
}
|
||||
|
||||
class _ListenerHandoffFailureSetupAction extends SetupAction {
|
||||
final List<bool> coreRunningCalls = [];
|
||||
|
||||
@@ -84,6 +72,7 @@ class TestSetupAction extends SetupAction {
|
||||
final List<bool> appliedForce = [];
|
||||
bool blockCoreCalls = false;
|
||||
Error? coreRunningError;
|
||||
final Set<bool> unansweredCoreCalls = {};
|
||||
int authorizeCalls = 0;
|
||||
AuthorizeCode authorizeResult = AuthorizeCode.none;
|
||||
|
||||
@@ -105,7 +94,7 @@ class TestSetupAction extends SetupAction {
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
return !unansweredCoreCalls.contains(running);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -128,6 +117,7 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
registerFallbackValue(const SetupParams(selectedMap: {}, testUrl: ''));
|
||||
registerFallbackValue(const SetupBase(path: '', overlay: ''));
|
||||
registerFallbackValue(
|
||||
const UpdateParams(
|
||||
tun: Tun(),
|
||||
@@ -164,6 +154,7 @@ void main() {
|
||||
tearDown(() async {
|
||||
action.blockCoreCalls = false;
|
||||
action.coreRunningError = null;
|
||||
action.unansweredCoreCalls.clear();
|
||||
for (final gate in action.pendingCoreCalls) {
|
||||
if (!gate.isCompleted) {
|
||||
gate.complete();
|
||||
@@ -268,6 +259,56 @@ void main() {
|
||||
expect(container.read(runTimeProvider), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
final unanswered = isA<CoreMethodException>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
'no_response',
|
||||
);
|
||||
|
||||
test('a start the core leaves unanswered fails and is undone', () async {
|
||||
markInitialized();
|
||||
action.unansweredCoreCalls.add(true);
|
||||
|
||||
await expectLater(
|
||||
container.read(setupActionProvider.notifier).setRunning(true),
|
||||
throwsA(unanswered),
|
||||
);
|
||||
|
||||
expect(container.read(isStartProvider), isFalse);
|
||||
expect(container.read(runTimeProvider), isNull);
|
||||
expect(container.read(runningVpnOptionsProvider), isNull);
|
||||
});
|
||||
|
||||
test('a stop the core leaves unanswered fails but stays stopped', () async {
|
||||
markInitialized();
|
||||
final setup = container.read(setupActionProvider.notifier);
|
||||
await setup.setRunning(true);
|
||||
action.unansweredCoreCalls.add(false);
|
||||
|
||||
await expectLater(setup.setRunning(false), throwsA(unanswered));
|
||||
|
||||
expect(container.read(isStartProvider), isFalse);
|
||||
expect(container.read(runTimeProvider), isNull);
|
||||
expect(container.read(runningVpnOptionsProvider), isNull);
|
||||
expect(action.trafficResets, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'an initial start the core leaves unanswered is stopped again',
|
||||
() async {
|
||||
action.unansweredCoreCalls.add(true);
|
||||
|
||||
final applied = await container
|
||||
.read(setupActionProvider.notifier)
|
||||
.setRunning(true, initialize: true);
|
||||
|
||||
expect(applied, isFalse);
|
||||
expect(action.coreRunningCalls, [true, false]);
|
||||
expect(container.read(isStartProvider), isFalse);
|
||||
expect(container.read(runningVpnOptionsProvider), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('stop cleanup', () {
|
||||
@@ -550,6 +591,32 @@ void main() {
|
||||
expect(container.read(runTimeProvider), isNotNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'a superseded start the core leaves unanswered does not fail',
|
||||
() async {
|
||||
markInitialized();
|
||||
final notifier = container.read(setupActionProvider.notifier);
|
||||
action.blockCoreCalls = true;
|
||||
action.unansweredCoreCalls.add(true);
|
||||
|
||||
final starting = notifier.setRunning(true);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final stopping = notifier.setRunning(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
action.blockCoreCalls = false;
|
||||
for (final gate in action.pendingCoreCalls) {
|
||||
if (!gate.isCompleted) {
|
||||
gate.complete();
|
||||
}
|
||||
}
|
||||
|
||||
expect(await Future.wait([starting, stopping]), [true, true]);
|
||||
expect(action.coreRunningCalls, [true, false]);
|
||||
expect(container.read(isStartProvider), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('the newest request wins the local running state', () async {
|
||||
markInitialized();
|
||||
final notifier = container.read(setupActionProvider.notifier);
|
||||
@@ -936,7 +1003,7 @@ void main() {
|
||||
|
||||
setUpAll(() async {
|
||||
tempDir = Directory.systemTemp.createTempSync('setup_action_test');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
|
||||
await useAppPaths(tempDir);
|
||||
await AppLocalizations.load(const Locale('en'));
|
||||
originalLastConfigMd5 = globalState.lastConfigMd5;
|
||||
globalState.packageInfo = PackageInfo(
|
||||
@@ -1008,7 +1075,9 @@ void main() {
|
||||
() async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
when(() => core.getConfig(any())).thenThrow(Exception('broken yaml'));
|
||||
when(
|
||||
() => core.getProfileBase(any()),
|
||||
).thenThrow(Exception('broken yaml'));
|
||||
String? pushedConfig;
|
||||
when(() => core.setupConfig(any())).thenAnswer((_) async {
|
||||
pushedConfig = await File(
|
||||
@@ -1081,12 +1150,13 @@ void main() {
|
||||
() async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
when(() => core.getConfig(any())).thenAnswer((_) async => {});
|
||||
when(
|
||||
() => core.getProfileBase(any()),
|
||||
).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd'));
|
||||
String? pushedConfig;
|
||||
when(() => core.setupConfig(any())).thenAnswer((_) async {
|
||||
pushedConfig = await File(
|
||||
await appPath.configFilePath,
|
||||
).readAsString();
|
||||
when(() => core.setupConfig(any())).thenAnswer((invocation) async {
|
||||
final params = invocation.positionalArguments.single as SetupParams;
|
||||
pushedConfig = params.base?.overlay;
|
||||
return '';
|
||||
});
|
||||
final scoped = ProviderContainer(
|
||||
@@ -1125,7 +1195,9 @@ void main() {
|
||||
'differ from the applied config', () async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
when(() => core.getConfig(any())).thenAnswer((_) async => {});
|
||||
when(
|
||||
() => core.getProfileBase(any()),
|
||||
).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd'));
|
||||
when(() => core.setupConfig(any())).thenAnswer((_) async => '');
|
||||
var setupState = nullProfileSetupState.copyWith(profileId: profile.id);
|
||||
final scoped = ProviderContainer(
|
||||
@@ -1148,15 +1220,15 @@ void main() {
|
||||
}
|
||||
|
||||
await setupAction.applyProfile(force: true);
|
||||
verify(() => core.getConfig(any())).called(1);
|
||||
verify(() => core.getProfileBase(any())).called(1);
|
||||
|
||||
await leaveEditor();
|
||||
verifyNever(() => core.getConfig(any()));
|
||||
verifyNever(() => core.getProfileBase(any()));
|
||||
|
||||
setupState = setupState.copyWith(matchTarget: 'DIRECT');
|
||||
scoped.invalidate(setupStateProvider);
|
||||
await leaveEditor();
|
||||
verify(() => core.getConfig(any())).called(1);
|
||||
verify(() => core.getProfileBase(any())).called(1);
|
||||
// Queued behind the rebuild: one that outlasts the pump would compare
|
||||
// against the md5 tearDown resets and push into a disposed container.
|
||||
await setupAction.applyProfile();
|
||||
@@ -1194,7 +1266,9 @@ void main() {
|
||||
);
|
||||
var rejecting = false;
|
||||
var coreProxies = profileProxies;
|
||||
when(() => core.getConfig(any())).thenAnswer((_) async => {});
|
||||
when(
|
||||
() => core.getProfileBase(any()),
|
||||
).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd'));
|
||||
when(() => core.getExternalProviders()).thenAnswer((_) async => []);
|
||||
when(() => core.getProxies()).thenAnswer((_) async => coreProxies);
|
||||
when(() => core.setupConfig(any())).thenAnswer((_) async {
|
||||
@@ -1245,6 +1319,183 @@ void main() {
|
||||
expect(shownGroups(), ['Proxy']);
|
||||
});
|
||||
|
||||
test('a profile file leaves its bulk sections in the Core and records '
|
||||
'what the Core was handed', () async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
var digest = 'first';
|
||||
when(() => core.getProfileBase(any())).thenAnswer(
|
||||
(_) async => ProfileBase(
|
||||
config: const {'mixed-port': 1},
|
||||
digest: digest,
|
||||
matchRules: const ['MATCH,DIRECT', 'MATCH,Proxy'],
|
||||
proxies: 3,
|
||||
proxyGroups: 2,
|
||||
rules: 5,
|
||||
),
|
||||
);
|
||||
final pushed = <SetupParams>[];
|
||||
when(() => core.setupConfig(any())).thenAnswer((invocation) async {
|
||||
pushed.add(invocation.positionalArguments.single as SetupParams);
|
||||
return '';
|
||||
});
|
||||
final configFile = File(await appPath.configFilePath);
|
||||
await configFile.writeAsString('previous');
|
||||
final scoped = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
setupStateProvider.overrideWith(
|
||||
(_, profileId) => nullProfileSetupState.copyWith(
|
||||
profileId: profileId,
|
||||
addedRules: [Rule.parse('DOMAIN,added.test,MATCH')],
|
||||
),
|
||||
),
|
||||
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
||||
setupActionProvider.overrideWith(SetupAction.new),
|
||||
],
|
||||
);
|
||||
addTearDown(scoped.dispose);
|
||||
final setupAction = scoped.read(setupActionProvider.notifier);
|
||||
addTearDown(() => globalState.lastConfigCounts = null);
|
||||
|
||||
expect(await setupAction.applyProfile(force: true), isTrue);
|
||||
|
||||
final base = pushed.single.base!;
|
||||
expect(base.path, await appPath.getProfilePath(profile.id.toString()));
|
||||
final overlay = loadYaml(base.overlay) as YamlMap;
|
||||
expect(overlay['rules'], ['DOMAIN,added.test,Proxy']);
|
||||
expect(overlay['mixed-port'], isNot(1));
|
||||
expect(await configFile.readAsString(), 'previous');
|
||||
expect(globalState.lastConfigCounts, (groups: 2, proxies: 3, rules: 6));
|
||||
|
||||
await setupAction.applyProfile();
|
||||
expect(pushed, hasLength(1));
|
||||
|
||||
digest = 'second';
|
||||
await setupAction.applyProfile();
|
||||
expect(pushed, hasLength(2));
|
||||
});
|
||||
|
||||
test('the preview shows the config the Core merges', () async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
when(
|
||||
() => core.getProfileBase(any()),
|
||||
).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd'));
|
||||
final built = File(
|
||||
join(await appPath.homeDirPath, coreSpillDirectoryName, 'p.yaml'),
|
||||
);
|
||||
when(() => core.buildProfile(any())).thenAnswer((_) async {
|
||||
await built.create(recursive: true);
|
||||
await built.writeAsString('proxies: []\n');
|
||||
return built.path;
|
||||
});
|
||||
final scoped = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
setupStateProvider.overrideWith(
|
||||
(_, profileId) =>
|
||||
nullProfileSetupState.copyWith(profileId: profileId),
|
||||
),
|
||||
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
||||
setupActionProvider.overrideWith(SetupAction.new),
|
||||
],
|
||||
);
|
||||
addTearDown(scoped.dispose);
|
||||
|
||||
final yaml = await scoped
|
||||
.read(setupActionProvider.notifier)
|
||||
.getProfileWithId(profile.id);
|
||||
|
||||
verifyNever(() => core.getConfig(any()));
|
||||
expect(yaml, 'proxies: []\n');
|
||||
expect(built.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'the extend page reads only the outbounds of a profile file',
|
||||
() async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
when(() => core.getProfileOutbounds(any())).thenAnswer(
|
||||
(_) async => {
|
||||
'proxies': [
|
||||
{'name': 'a', 'type': 'ss'},
|
||||
],
|
||||
'proxy-groups': [
|
||||
{'name': 'g', 'type': 'select'},
|
||||
],
|
||||
},
|
||||
);
|
||||
final scoped = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
||||
],
|
||||
);
|
||||
addTearDown(scoped.dispose);
|
||||
|
||||
final config = await scoped.read(
|
||||
clashConfigProvider(profile.id).future,
|
||||
);
|
||||
|
||||
verifyNever(() => core.getConfig(any()));
|
||||
expect(
|
||||
{
|
||||
for (final proxy in config.proxies) proxy.name: proxy.type,
|
||||
for (final group in config.proxyGroups) group.name: group.type,
|
||||
},
|
||||
{'a': 'ss', 'g': GroupType.Selector},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a start applies the profile again only once its inputs changed',
|
||||
() async {
|
||||
final profile = Profile.normal(label: 'p');
|
||||
final core = _MockCoreHandlerInterface();
|
||||
when(
|
||||
() => core.getProfileBase(any()),
|
||||
).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd'));
|
||||
when(() => core.setupConfig(any())).thenAnswer((_) async => '');
|
||||
when(() => core.startListener()).thenAnswer((_) async => true);
|
||||
when(() => core.stopListener()).thenAnswer((_) async => true);
|
||||
final scoped = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
setupStateProvider.overrideWith(
|
||||
(_, profileId) =>
|
||||
nullProfileSetupState.copyWith(profileId: profileId),
|
||||
),
|
||||
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
||||
commonActionProvider.overrideWith(TestCommonAction.new),
|
||||
setupActionProvider.overrideWith(SetupAction.new),
|
||||
],
|
||||
);
|
||||
addTearDown(scoped.dispose);
|
||||
scoped.read(initProvider.notifier).value = true;
|
||||
final setupAction = scoped.read(setupActionProvider.notifier);
|
||||
await setupAction.applyProfile(force: true);
|
||||
verify(() => core.setupConfig(any())).called(1);
|
||||
|
||||
await setupAction.setRunning(true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
verifyNever(() => core.setupConfig(any()));
|
||||
|
||||
await setupAction.setRunning(false);
|
||||
scoped.read(appSettingProvider.notifier).value = scoped
|
||||
.read(appSettingProvider)
|
||||
.copyWith(testUrl: 'https://example.com/204');
|
||||
await setupAction.setRunning(true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
verify(() => core.setupConfig(any())).called(1);
|
||||
await setupAction.setRunning(false);
|
||||
},
|
||||
);
|
||||
|
||||
test('a custom profile declares only the proxies and providers it names, '
|
||||
'without a profile file', () async {
|
||||
final profile = Profile.custom(label: 'p');
|
||||
|
||||
Reference in New Issue
Block a user