diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000000..bf7404eeef --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,6 @@ +[mcp_servers.codegraph] +command = "codegraph" +args = [ + "serve", + "--mcp", +] diff --git a/arb/intl_en.arb b/arb/intl_en.arb index 6951fb3a5f..cb026879ff 100644 --- a/arb/intl_en.arb +++ b/arb/intl_en.arb @@ -550,5 +550,7 @@ "geoSkipped": "{name} skipped", "geoUpdated": "{name} updated", "secondsCount": "{count} seconds", - "entriesCount": "{count} entries" + "entriesCount": "{count} entries", + "directProfileUpdate": "Use direct connection for profile updates", + "directProfileUpdateDesc": "Use DIRECT instead of the current proxy when updating profiles" } diff --git a/arb/intl_ja.arb b/arb/intl_ja.arb index a310126c0e..9d6d278388 100644 --- a/arb/intl_ja.arb +++ b/arb/intl_ja.arb @@ -550,5 +550,7 @@ "geoSkipped": "{name} スキップ済み", "geoUpdated": "{name} 更新済み", "secondsCount": "{count} 秒", - "entriesCount": "{count} エントリ" + "entriesCount": "{count} エントリ", + "directProfileUpdate": "プロファイル更新に直接接続を使用", + "directProfileUpdateDesc": "プロファイル更新時に現在のプロキシではなく DIRECT を使用します" } diff --git a/arb/intl_ru.arb b/arb/intl_ru.arb index 4cdfdf3036..f26e116658 100644 --- a/arb/intl_ru.arb +++ b/arb/intl_ru.arb @@ -550,5 +550,7 @@ "geoSkipped": "{name} пропущено", "geoUpdated": "{name} обновлено", "secondsCount": "{count} секунд", - "entriesCount": "{count} записей" + "entriesCount": "{count} записей", + "directProfileUpdate": "Прямое подключение для обновления профилей", + "directProfileUpdateDesc": "Использовать DIRECT вместо текущего прокси при обновлении профилей" } diff --git a/arb/intl_zh_CN.arb b/arb/intl_zh_CN.arb index 28435c1095..a9e7743199 100644 --- a/arb/intl_zh_CN.arb +++ b/arb/intl_zh_CN.arb @@ -550,5 +550,7 @@ "geoSkipped": "{name} 已跳过", "geoUpdated": "{name} 已更新", "secondsCount": "{count} 秒", - "entriesCount": "{count} 个条目" + "entriesCount": "{count} 个条目", + "directProfileUpdate": "订阅更新使用直连", + "directProfileUpdateDesc": "更新订阅时使用 DIRECT,不经过当前代理" } diff --git a/core/action.go b/core/action.go index a7d6c8f917..65a3828732 100644 --- a/core/action.go +++ b/core/action.go @@ -101,6 +101,12 @@ func handleAction(action *Action, result ActionResult) { result.success(value) }) return + case downloadFileMethod: + data := action.Data.(string) + handleDownloadFile(data, func(value string) { + result.success(value) + }) + return case getConnectionsMethod: result.success(handleGetConnections()) return diff --git a/core/common.go b/core/common.go index 46d9bdfdf8..1e79e4bae3 100644 --- a/core/common.go +++ b/core/common.go @@ -6,10 +6,12 @@ import ( "encoding/json" "errors" "fmt" + "net/netip" "os" "path/filepath" "runtime" "sync" + "time" "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/inbound" @@ -41,6 +43,52 @@ var ( debugError = false ) +const tunReadyPollInterval = 100 * time.Millisecond + +func waitForTunInterface(ctx context.Context) error { + return waitForTunInterfaceWithInterval(ctx, tunReadyPollInterval) +} + +func waitForTunInterfaceWithInterval(ctx context.Context, interval time.Duration) error { + if runtime.GOOS != "linux" { + return nil + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if isTunInterfaceReady() { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func isTunInterfaceReady() bool { + runLock.Lock() + needsInterface := currentConfig != nil && + currentConfig.General != nil && + currentConfig.General.Tun.Enable && + currentConfig.General.Tun.AutoDetectInterface + runLock.Unlock() + if !needsInterface { + return true + } + + finder := dialer.DefaultInterfaceFinder.Load() + if finder == nil { + return false + } + name := finder.FindInterfaceName(netip.IPv4Unspecified()) + return name != "" && name != "" +} + func getExternalProvidersRaw() map[string]cp.Provider { eps := make(map[string]cp.Provider) for n, p := range tunnel.Providers() { @@ -230,6 +278,7 @@ func updateConfig(params *UpdateParams) { if params.Tun != nil { general.Tun.Enable = params.Tun.Enable general.Tun.AutoRoute = *params.Tun.AutoRoute + general.Tun.AutoDetectInterface = *params.Tun.AutoDetectInterface general.Tun.Device = *params.Tun.Device general.Tun.RouteAddress = *params.Tun.RouteAddress general.Tun.DNSHijack = *params.Tun.DNSHijack diff --git a/core/constant.go b/core/constant.go index ed16fea2eb..75568c3e6e 100644 --- a/core/constant.go +++ b/core/constant.go @@ -39,12 +39,13 @@ type UpdateParams struct { } type tunSchema struct { - Enable bool `yaml:"enable" json:"enable"` - Device *string `yaml:"device" json:"device"` - Stack *constant.TUNStack `yaml:"stack" json:"stack"` - DNSHijack *[]string `yaml:"dns-hijack" json:"dns-hijack"` - AutoRoute *bool `yaml:"auto-route" json:"auto-route"` - RouteAddress *[]netip.Prefix `yaml:"route-address" json:"route-address,omitempty"` + Enable bool `yaml:"enable" json:"enable"` + Device *string `yaml:"device" json:"device"` + Stack *constant.TUNStack `yaml:"stack" json:"stack"` + DNSHijack *[]string `yaml:"dns-hijack" json:"dns-hijack"` + AutoRoute *bool `yaml:"auto-route" json:"auto-route"` + AutoDetectInterface *bool `yaml:"auto-detect-interface" json:"auto-detect-interface"` + RouteAddress *[]netip.Prefix `yaml:"route-address" json:"route-address,omitempty"` } type ChangeProxyParams struct { @@ -58,6 +59,18 @@ type TestDelayParams struct { Timeout int64 `json:"timeout"` } +type DownloadFileParams struct { + URL string `json:"url"` + Path string `json:"path"` + UserAgent string `json:"user-agent"` +} + +type DownloadFileResult struct { + ContentDisposition string `json:"content-disposition"` + SubscriptionUserinfo string `json:"subscription-userinfo"` + Error string `json:"error"` +} + type ExternalProvider struct { Name string `json:"name"` Type string `json:"type"` @@ -87,6 +100,7 @@ const ( getTotalTrafficMethod Method = "getTotalTraffic" resetTrafficMethod Method = "resetTraffic" asyncTestDelayMethod Method = "asyncTestDelay" + downloadFileMethod Method = "downloadFile" getConnectionsMethod Method = "getConnections" closeConnectionsMethod Method = "closeConnections" resetConnectionsMethod Method = "resetConnections" diff --git a/core/file_owner_unix.go b/core/file_owner_unix.go new file mode 100644 index 0000000000..0a15896f0e --- /dev/null +++ b/core/file_owner_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +package main + +import "os" + +func restoreFileOwnership(path string) error { + return restoreFileOwnershipAs( + path, + os.Getuid(), + os.Getgid(), + os.Geteuid(), + os.Getegid(), + os.Chown, + ) +} + +func restoreFileOwnershipAs( + path string, + uid int, + gid int, + effectiveUID int, + effectiveGID int, + chown func(string, int, int) error, +) error { + if uid == effectiveUID && gid == effectiveGID { + return nil + } + return chown(path, uid, gid) +} diff --git a/core/file_owner_unix_test.go b/core/file_owner_unix_test.go new file mode 100644 index 0000000000..2fb58fcea1 --- /dev/null +++ b/core/file_owner_unix_test.go @@ -0,0 +1,46 @@ +//go:build !windows + +package main + +import "testing" + +func TestRestoreFileOwnershipAsRestoresRealUser(t *testing.T) { + called := false + err := restoreFileOwnershipAs( + "/tmp/profile.yaml", + 1000, + 1000, + 0, + 0, + func(path string, uid int, gid int) error { + called = true + if path != "/tmp/profile.yaml" || uid != 1000 || gid != 1000 { + t.Fatalf("unexpected chown arguments: %s %d:%d", path, uid, gid) + } + return nil + }, + ) + if err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("expected chown to be called") + } +} + +func TestRestoreFileOwnershipAsSkipsMatchingUser(t *testing.T) { + err := restoreFileOwnershipAs( + "/tmp/profile.yaml", + 1000, + 1000, + 1000, + 1000, + func(string, int, int) error { + t.Fatal("chown should not be called") + return nil + }, + ) + if err != nil { + t.Fatal(err) + } +} diff --git a/core/file_owner_windows.go b/core/file_owner_windows.go new file mode 100644 index 0000000000..eaaeb90bc4 --- /dev/null +++ b/core/file_owner_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +func restoreFileOwnership(string) error { + return nil +} diff --git a/core/hub.go b/core/hub.go index aad920838f..4f10d28567 100644 --- a/core/hub.go +++ b/core/hub.go @@ -4,10 +4,22 @@ import ( "cmp" "context" "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "runtime" + "runtime/debug" + "strconv" + "time" + "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/common/observable" "github.com/metacubex/mihomo/common/utils" + mihomoHttp "github.com/metacubex/mihomo/component/http" "github.com/metacubex/mihomo/component/mmdb" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/component/updater" @@ -21,12 +33,8 @@ import ( "github.com/metacubex/mihomo/tunnel" "github.com/metacubex/mihomo/tunnel/statistic" "golang.org/x/exp/slices" - "net" - "os" - "runtime" - "runtime/debug" - "strconv" - "time" + + "github.com/metacubex/http" ) var ( @@ -245,6 +253,12 @@ func handleAsyncTestDelay(paramsString string, fn func(string)) { testUrl = params.TestUrl } delayData.Url = testUrl + if err := waitForTunInterface(ctx); err != nil { + delayData.Value = -1 + data, _ := json.Marshal(delayData) + fn(string(data)) + return false, nil + } delay, err := proxy.URLTest(ctx, testUrl, expectedStatus) if err != nil || delay == 0 { delayData.Value = -1 @@ -260,6 +274,99 @@ func handleAsyncTestDelay(paramsString string, fn func(string)) { }) } +func handleDownloadFile(paramsString string, fn func(string)) { + go func() { + result, err := downloadFile(paramsString) + if err != nil { + result.Error = err.Error() + } + data, err := json.Marshal(result) + if err != nil { + fn(`{"error":"failed to encode download result"}`) + return + } + fn(string(data)) + }() +} + +func downloadFile(paramsString string) (result *DownloadFileResult, err error) { + params := &DownloadFileParams{} + result = &DownloadFileResult{} + if err = json.Unmarshal([]byte(paramsString), params); err != nil { + return result, err + } + if params.URL == "" || params.Path == "" { + return result, fmt.Errorf("download url and path are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + headers := map[string][]string{} + if params.UserAgent != "" { + headers["User-Agent"] = []string{params.UserAgent} + } + if err = waitForTunInterface(ctx); err != nil { + return result, fmt.Errorf("direct interface is not ready: %w", err) + } + resp, err := mihomoHttp.HttpRequest( + ctx, + params.URL, + http.MethodGet, + headers, + nil, + mihomoHttp.WithSpecialProxy("DIRECT"), + ) + if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + return result, fmt.Errorf("download request failed: %w", urlErr.Err) + } + return result, fmt.Errorf("download request failed") + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return result, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + fileCreated := false + defer func() { + if err == nil || !fileCreated { + return + } + if removeErr := os.Remove(params.Path); removeErr != nil && + !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join( + err, + fmt.Errorf("remove partial download: %w", removeErr), + ) + } + }() + + file, err := os.OpenFile(params.Path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return result, err + } + fileCreated = true + _, copyErr := io.Copy(file, resp.Body) + closeErr := file.Close() + if copyErr != nil { + copyErr = fmt.Errorf("write download file: %w", copyErr) + } + if closeErr != nil { + closeErr = fmt.Errorf("close download file: %w", closeErr) + } + if err = errors.Join(copyErr, closeErr); err != nil { + return result, err + } + if err = restoreFileOwnership(params.Path); err != nil { + return result, fmt.Errorf("restore download file ownership: %w", err) + } + + result.ContentDisposition = resp.Header.Get("Content-Disposition") + result.SubscriptionUserinfo = resp.Header.Get("Subscription-Userinfo") + return result, nil +} + func handleGetConnections() string { runLock.Lock() defer runLock.Unlock() diff --git a/core/hub_test.go b/core/hub_test.go new file mode 100644 index 0000000000..4605b7a5d3 --- /dev/null +++ b/core/hub_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownloadFile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); got != "FlClash/Test" { + t.Errorf("unexpected User-Agent: %q", got) + } + w.Header().Set("Content-Disposition", "attachment; filename=profile.yaml") + w.Header().Set("Subscription-Userinfo", "upload=1; total=10") + _, _ = w.Write([]byte("proxies: []\n")) + })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "profile.yaml") + params, err := json.Marshal(&DownloadFileParams{ + URL: server.URL, + Path: path, + UserAgent: "FlClash/Test", + }) + if err != nil { + t.Fatal(err) + } + + result, err := downloadFile(string(params)) + if err != nil { + t.Fatalf("unexpected download error: %s", err) + } + if result.ContentDisposition != "attachment; filename=profile.yaml" { + t.Errorf("unexpected Content-Disposition: %q", result.ContentDisposition) + } + if result.SubscriptionUserinfo != "upload=1; total=10" { + t.Errorf("unexpected Subscription-Userinfo: %q", result.SubscriptionUserinfo) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != "proxies: []\n" { + t.Errorf("unexpected file content: %q", content) + } +} + +func TestDownloadFileDoesNotLeaveFileOnHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "profile.yaml") + params, err := json.Marshal(&DownloadFileParams{URL: server.URL, Path: path}) + if err != nil { + t.Fatal(err) + } + + if _, err := downloadFile(string(params)); err == nil { + t.Fatal("expected download error") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("download file should not exist after an HTTP error: %v", err) + } +} + +func TestDownloadFilePreservesExistingTarget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("replacement")) + })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "profile.yaml") + if err := os.WriteFile(path, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + params, err := json.Marshal(&DownloadFileParams{URL: server.URL, Path: path}) + if err != nil { + t.Fatal(err) + } + + if _, err := downloadFile(string(params)); err == nil { + t.Fatal("expected download error") + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != "original" { + t.Fatalf("existing target was modified: %q", content) + } +} diff --git a/core/tun_ready_test.go b/core/tun_ready_test.go new file mode 100644 index 0000000000..f757507fec --- /dev/null +++ b/core/tun_ready_test.go @@ -0,0 +1,143 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "net/netip" + "testing" + "time" + + "github.com/metacubex/mihomo/component/dialer" + "github.com/metacubex/mihomo/config" + listenerConfig "github.com/metacubex/mihomo/listener/config" +) + +type sequenceInterfaceFinder struct { + names []string + index int +} + +type signalingInterfaceFinder struct { + called chan struct{} +} + +func (f *sequenceInterfaceFinder) FindInterfaceName(netip.Addr) string { + if f.index >= len(f.names) { + return f.names[len(f.names)-1] + } + name := f.names[f.index] + f.index++ + return name +} + +func (f *signalingInterfaceFinder) FindInterfaceName(netip.Addr) string { + select { + case f.called <- struct{}{}: + default: + } + return "" +} + +func TestWaitForTunInterfaceWaitsUntilReady(t *testing.T) { + restoreTunReadyGlobals(t) + currentConfig = tunReadyTestConfig(true) + dialer.DefaultInterfaceFinder.Store(&sequenceInterfaceFinder{ + names: []string{"", "eth0"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := waitForTunInterfaceWithInterval(ctx, time.Millisecond); err != nil { + t.Fatal(err) + } +} + +func TestWaitForTunInterfaceHonorsTimeout(t *testing.T) { + restoreTunReadyGlobals(t) + currentConfig = tunReadyTestConfig(true) + dialer.DefaultInterfaceFinder.Store(&sequenceInterfaceFinder{ + names: []string{""}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + err := waitForTunInterfaceWithInterval(ctx, time.Millisecond) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline exceeded, got %v", err) + } +} + +func TestWaitForTunInterfaceDoesNotHoldRunLock(t *testing.T) { + restoreTunReadyGlobals(t) + currentConfig = tunReadyTestConfig(true) + finder := &signalingInterfaceFinder{called: make(chan struct{}, 1)} + dialer.DefaultInterfaceFinder.Store(finder) + + ctx, cancel := context.WithCancel(context.Background()) + waitDone := make(chan error, 1) + go func() { + waitDone <- waitForTunInterfaceWithInterval(ctx, time.Millisecond) + }() + + select { + case <-finder.called: + case <-time.After(time.Second): + cancel() + <-waitDone + t.Fatal("interface readiness check did not start") + } + + lockAcquired := make(chan struct{}) + go func() { + runLock.Lock() + close(lockAcquired) + runLock.Unlock() + }() + select { + case <-lockAcquired: + case <-time.After(time.Second): + cancel() + <-waitDone + t.Fatal("TUN readiness wait blocked runLock") + } + + cancel() + if err := <-waitDone; !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled wait, got %v", err) + } +} + +func TestWaitForTunInterfaceSkipsDisabledTun(t *testing.T) { + restoreTunReadyGlobals(t) + currentConfig = tunReadyTestConfig(false) + dialer.DefaultInterfaceFinder.Store(nil) + + if err := waitForTunInterfaceWithInterval(context.Background(), time.Millisecond); err != nil { + t.Fatal(err) + } +} + +func restoreTunReadyGlobals(t *testing.T) { + t.Helper() + previousConfig := currentConfig + previousFinder := dialer.DefaultInterfaceFinder.Load() + t.Cleanup(func() { + currentConfig = previousConfig + dialer.DefaultInterfaceFinder.Store(previousFinder) + }) +} + +func tunReadyTestConfig(enabled bool) *config.Config { + return &config.Config{ + General: &config.General{ + Inbound: config.Inbound{ + Tun: listenerConfig.Tun{ + Enable: enabled, + AutoDetectInterface: enabled, + }, + }, + }, + } +} diff --git a/lib/common/task.dart b/lib/common/task.dart index ea29017a88..71872d711d 100644 --- a/lib/common/task.dart +++ b/lib/common/task.dart @@ -140,6 +140,8 @@ Future> _makeRealProfileTask( rawConfig['tun']['stack'] = realPatchConfig.tun.stack.name; rawConfig['tun']['route-address'] = realPatchConfig.tun.routeAddress; rawConfig['tun']['auto-route'] = realPatchConfig.tun.autoRoute; + rawConfig['tun']['auto-detect-interface'] = + realPatchConfig.tun.autoDetectInterface; rawConfig['geodata-loader'] = realPatchConfig.geodataLoader.name; if (rawConfig['sniffer']?['sniff'] != null) { for (final value in (rawConfig['sniffer']?['sniff'] as Map).values) { diff --git a/lib/core/controller.dart b/lib/core/controller.dart index c6b3bd545b..e3caeb926f 100644 --- a/lib/core/controller.dart +++ b/lib/core/controller.dart @@ -100,6 +100,18 @@ class CoreController { return _interface.updateConfig(updateParams); } + Future downloadFile(DownloadFileParams params) async { + final data = await _interface.downloadFile(params); + if (data.isEmpty) { + throw currentAppLocalizations.unknownNetworkError; + } + final result = DownloadFileResult.fromJson(json.decode(data)); + if (result.error.isNotEmpty) { + throw result.error; + } + return result; + } + Future setupConfig({ required SetupParams params, required SetupState setupState, diff --git a/lib/core/interface.dart b/lib/core/interface.dart index 8154017297..a51bd315d4 100644 --- a/lib/core/interface.dart +++ b/lib/core/interface.dart @@ -22,6 +22,8 @@ mixin CoreInterface { Future asyncTestDelay(String url, String proxyName); + Future downloadFile(DownloadFileParams params); + Future updateConfig(UpdateParams updateParams); Future setupConfig(SetupParams setupParams); @@ -93,9 +95,13 @@ abstract class CoreHandlerInterface with CoreInterface { ); return null; } + final logData = switch (method) { + ActionMethod.downloadFile => '', + _ => data, + }; return await utils.handleWatch( onStart: () { - commonPrint.log('Invoke ${method.name} ${DateTime.now()} $data'); + commonPrint.log('Invoke ${method.name} ${DateTime.now()} $logData'); }, function: () async { return invoke(method: method, data: data, timeout: timeout); @@ -331,6 +337,15 @@ abstract class CoreHandlerInterface with CoreInterface { json.encode(Delay(name: proxyName, value: -1, url: url)); } + @override + Future downloadFile(DownloadFileParams params) async { + return await _invoke( + method: ActionMethod.downloadFile, + data: json.encode(params), + ) ?? + ''; + } + @override Future getCountryCode(String ip) async { return await _invoke( diff --git a/lib/enum/enum.dart b/lib/enum/enum.dart index 6a25b6458a..fc65838e5e 100644 --- a/lib/enum/enum.dart +++ b/lib/enum/enum.dart @@ -234,6 +234,7 @@ enum ActionMethod { getTotalTraffic, resetTraffic, asyncTestDelay, + downloadFile, getConnections, closeConnections, resetConnections, diff --git a/lib/l10n/intl/messages_en.dart b/lib/l10n/intl/messages_en.dart index c4da298db0..b7e29bceec 100644 --- a/lib/l10n/intl/messages_en.dart +++ b/lib/l10n/intl/messages_en.dart @@ -325,6 +325,12 @@ class MessageLookup extends MessageLookupByLibrary { "Developer mode is enabled.", ), "direct": MessageLookupByLibrary.simpleMessage("Direct"), + "directProfileUpdate": MessageLookupByLibrary.simpleMessage( + "Use direct connection for profile updates", + ), + "directProfileUpdateDesc": MessageLookupByLibrary.simpleMessage( + "Use DIRECT instead of the current proxy when updating profiles", + ), "disableUDP": MessageLookupByLibrary.simpleMessage("Disable UDP"), "disclaimer": MessageLookupByLibrary.simpleMessage("Disclaimer"), "disclaimerDesc": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/intl/messages_ja.dart b/lib/l10n/intl/messages_ja.dart index ceac8a469b..750df770e5 100644 --- a/lib/l10n/intl/messages_ja.dart +++ b/lib/l10n/intl/messages_ja.dart @@ -255,6 +255,12 @@ class MessageLookup extends MessageLookupByLibrary { "デベロッパーモードが有効になりました。", ), "direct": MessageLookupByLibrary.simpleMessage("ダイレクト"), + "directProfileUpdate": MessageLookupByLibrary.simpleMessage( + "プロファイル更新に直接接続を使用", + ), + "directProfileUpdateDesc": MessageLookupByLibrary.simpleMessage( + "プロファイル更新時に現在のプロキシではなく DIRECT を使用します", + ), "disableUDP": MessageLookupByLibrary.simpleMessage("UDPを無効化"), "disclaimer": MessageLookupByLibrary.simpleMessage("免責事項"), "disclaimerDesc": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/intl/messages_ru.dart b/lib/l10n/intl/messages_ru.dart index adf4d6de1e..337d3ce7fd 100644 --- a/lib/l10n/intl/messages_ru.dart +++ b/lib/l10n/intl/messages_ru.dart @@ -330,6 +330,12 @@ class MessageLookup extends MessageLookupByLibrary { "Режим разработчика активирован.", ), "direct": MessageLookupByLibrary.simpleMessage("Прямой"), + "directProfileUpdate": MessageLookupByLibrary.simpleMessage( + "Прямое подключение для обновления профилей", + ), + "directProfileUpdateDesc": MessageLookupByLibrary.simpleMessage( + "Использовать DIRECT вместо текущего прокси при обновлении профилей", + ), "disableUDP": MessageLookupByLibrary.simpleMessage("Отключить UDP"), "disclaimer": MessageLookupByLibrary.simpleMessage( "Отказ от ответственности", diff --git a/lib/l10n/intl/messages_zh_CN.dart b/lib/l10n/intl/messages_zh_CN.dart index e198261265..f210021e2e 100644 --- a/lib/l10n/intl/messages_zh_CN.dart +++ b/lib/l10n/intl/messages_zh_CN.dart @@ -227,6 +227,10 @@ class MessageLookup extends MessageLookupByLibrary { "developerMode": MessageLookupByLibrary.simpleMessage("开发者模式"), "developerModeEnableTip": MessageLookupByLibrary.simpleMessage("开发者模式已启用。"), "direct": MessageLookupByLibrary.simpleMessage("直连"), + "directProfileUpdate": MessageLookupByLibrary.simpleMessage("订阅更新使用直连"), + "directProfileUpdateDesc": MessageLookupByLibrary.simpleMessage( + "更新订阅时使用 DIRECT,不经过当前代理", + ), "disableUDP": MessageLookupByLibrary.simpleMessage("禁用UDP"), "disclaimer": MessageLookupByLibrary.simpleMessage("免责声明"), "disclaimerDesc": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 3b96b136b2..d978654444 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -4493,6 +4493,26 @@ class AppLocalizations { args: [count], ); } + + /// `Use direct connection for profile updates` + String get directProfileUpdate { + return Intl.message( + 'Use direct connection for profile updates', + name: 'directProfileUpdate', + desc: '', + args: [], + ); + } + + /// `Use DIRECT instead of the current proxy when updating profiles` + String get directProfileUpdateDesc { + return Intl.message( + 'Use DIRECT instead of the current proxy when updating profiles', + name: 'directProfileUpdateDesc', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/models/clash_config.dart b/lib/models/clash_config.dart index 19fe746e14..a735226760 100644 --- a/lib/models/clash_config.dart +++ b/lib/models/clash_config.dart @@ -219,6 +219,9 @@ abstract class Tun with _$Tun { @Default(false) bool enable, @Default(appName) String device, @JsonKey(name: 'auto-route') @Default(false) bool autoRoute, + @JsonKey(name: 'auto-detect-interface') + @Default(false) + bool autoDetectInterface, @Default(TunStack.mixed) TunStack stack, @JsonKey(name: 'dns-hijack') @Default(['any:53']) List dnsHijack, @JsonKey(name: 'route-address') @Default([]) List routeAddress, @@ -244,7 +247,11 @@ extension TunExt on Tun { ? defaultBypassPrivateRouteAddress : routeAddress; return switch (system.isDesktop) { - true => copyWith(autoRoute: true, routeAddress: []), + true => copyWith( + autoRoute: true, + autoDetectInterface: system.isLinux, + routeAddress: [], + ), false => copyWith( autoRoute: mRouteAddress.isEmpty ? true : false, routeAddress: mRouteAddress, diff --git a/lib/models/config.dart b/lib/models/config.dart index a71ee4e6bf..6a56e4e753 100644 --- a/lib/models/config.dart +++ b/lib/models/config.dart @@ -171,6 +171,7 @@ abstract class NetworkProps with _$NetworkProps { @Default(RouteMode.config) RouteMode routeMode, @Default(true) bool autoSetSystemDns, @Default(false) bool appendSystemDns, + @Default(false) bool useDirectForProfileUpdate, }) = _NetworkProps; factory NetworkProps.fromJson(Map? json) => diff --git a/lib/models/core.dart b/lib/models/core.dart index 7cd12576c7..dd835d32f7 100644 --- a/lib/models/core.dart +++ b/lib/models/core.dart @@ -80,6 +80,34 @@ abstract class ChangeProxyParams with _$ChangeProxyParams { _$ChangeProxyParamsFromJson(json); } +@freezed +abstract class DownloadFileParams with _$DownloadFileParams { + const factory DownloadFileParams({ + required String url, + required String path, + @JsonKey(name: 'user-agent') required String userAgent, + }) = _DownloadFileParams; + + factory DownloadFileParams.fromJson(Map json) => + _$DownloadFileParamsFromJson(json); +} + +@freezed +abstract class DownloadFileResult with _$DownloadFileResult { + const factory DownloadFileResult({ + @Default('') + @JsonKey(name: 'content-disposition') + String contentDisposition, + @Default('') + @JsonKey(name: 'subscription-userinfo') + String subscriptionUserinfo, + @Default('') String error, + }) = _DownloadFileResult; + + factory DownloadFileResult.fromJson(Map json) => + _$DownloadFileResultFromJson(json); +} + @freezed abstract class UpdateGeoDataParams with _$UpdateGeoDataParams { const factory UpdateGeoDataParams({ diff --git a/lib/models/generated/clash_config.freezed.dart b/lib/models/generated/clash_config.freezed.dart index b33a1efc7a..cfd25178af 100644 --- a/lib/models/generated/clash_config.freezed.dart +++ b/lib/models/generated/clash_config.freezed.dart @@ -2058,7 +2058,7 @@ as bool?, /// @nodoc mixin _$Tun { - bool get enable; String get device;@JsonKey(name: 'auto-route') bool get autoRoute; TunStack get stack;@JsonKey(name: 'dns-hijack') List get dnsHijack;@JsonKey(name: 'route-address') List get routeAddress; + bool get enable; String get device;@JsonKey(name: 'auto-route') bool get autoRoute;@JsonKey(name: 'auto-detect-interface') bool get autoDetectInterface; TunStack get stack;@JsonKey(name: 'dns-hijack') List get dnsHijack;@JsonKey(name: 'route-address') List get routeAddress; /// Create a copy of Tun /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2071,16 +2071,16 @@ $TunCopyWith get copyWith => _$TunCopyWithImpl(this as Tun, _$identity @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Tun&&(identical(other.enable, enable) || other.enable == enable)&&(identical(other.device, device) || other.device == device)&&(identical(other.autoRoute, autoRoute) || other.autoRoute == autoRoute)&&(identical(other.stack, stack) || other.stack == stack)&&const DeepCollectionEquality().equals(other.dnsHijack, dnsHijack)&&const DeepCollectionEquality().equals(other.routeAddress, routeAddress)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Tun&&(identical(other.enable, enable) || other.enable == enable)&&(identical(other.device, device) || other.device == device)&&(identical(other.autoRoute, autoRoute) || other.autoRoute == autoRoute)&&(identical(other.autoDetectInterface, autoDetectInterface) || other.autoDetectInterface == autoDetectInterface)&&(identical(other.stack, stack) || other.stack == stack)&&const DeepCollectionEquality().equals(other.dnsHijack, dnsHijack)&&const DeepCollectionEquality().equals(other.routeAddress, routeAddress)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,enable,device,autoRoute,stack,const DeepCollectionEquality().hash(dnsHijack),const DeepCollectionEquality().hash(routeAddress)); +int get hashCode => Object.hash(runtimeType,enable,device,autoRoute,autoDetectInterface,stack,const DeepCollectionEquality().hash(dnsHijack),const DeepCollectionEquality().hash(routeAddress)); @override String toString() { - return 'Tun(enable: $enable, device: $device, autoRoute: $autoRoute, stack: $stack, dnsHijack: $dnsHijack, routeAddress: $routeAddress)'; + return 'Tun(enable: $enable, device: $device, autoRoute: $autoRoute, autoDetectInterface: $autoDetectInterface, stack: $stack, dnsHijack: $dnsHijack, routeAddress: $routeAddress)'; } @@ -2091,7 +2091,7 @@ abstract mixin class $TunCopyWith<$Res> { factory $TunCopyWith(Tun value, $Res Function(Tun) _then) = _$TunCopyWithImpl; @useResult $Res call({ - bool enable, String device,@JsonKey(name: 'auto-route') bool autoRoute, TunStack stack,@JsonKey(name: 'dns-hijack') List dnsHijack,@JsonKey(name: 'route-address') List routeAddress + bool enable, String device,@JsonKey(name: 'auto-route') bool autoRoute,@JsonKey(name: 'auto-detect-interface') bool autoDetectInterface, TunStack stack,@JsonKey(name: 'dns-hijack') List dnsHijack,@JsonKey(name: 'route-address') List routeAddress }); @@ -2108,11 +2108,12 @@ class _$TunCopyWithImpl<$Res> /// Create a copy of Tun /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? enable = null,Object? device = null,Object? autoRoute = null,Object? stack = null,Object? dnsHijack = null,Object? routeAddress = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? enable = null,Object? device = null,Object? autoRoute = null,Object? autoDetectInterface = null,Object? stack = null,Object? dnsHijack = null,Object? routeAddress = null,}) { return _then(_self.copyWith( enable: null == enable ? _self.enable : enable // ignore: cast_nullable_to_non_nullable as bool,device: null == device ? _self.device : device // ignore: cast_nullable_to_non_nullable as String,autoRoute: null == autoRoute ? _self.autoRoute : autoRoute // ignore: cast_nullable_to_non_nullable +as bool,autoDetectInterface: null == autoDetectInterface ? _self.autoDetectInterface : autoDetectInterface // ignore: cast_nullable_to_non_nullable as bool,stack: null == stack ? _self.stack : stack // ignore: cast_nullable_to_non_nullable as TunStack,dnsHijack: null == dnsHijack ? _self.dnsHijack : dnsHijack // ignore: cast_nullable_to_non_nullable as List,routeAddress: null == routeAddress ? _self.routeAddress : routeAddress // ignore: cast_nullable_to_non_nullable @@ -2201,10 +2202,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool enable, String device, @JsonKey(name: 'auto-route') bool autoRoute, TunStack stack, @JsonKey(name: 'dns-hijack') List dnsHijack, @JsonKey(name: 'route-address') List routeAddress)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool enable, String device, @JsonKey(name: 'auto-route') bool autoRoute, @JsonKey(name: 'auto-detect-interface') bool autoDetectInterface, TunStack stack, @JsonKey(name: 'dns-hijack') List dnsHijack, @JsonKey(name: 'route-address') List routeAddress)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Tun() when $default != null: -return $default(_that.enable,_that.device,_that.autoRoute,_that.stack,_that.dnsHijack,_that.routeAddress);case _: +return $default(_that.enable,_that.device,_that.autoRoute,_that.autoDetectInterface,_that.stack,_that.dnsHijack,_that.routeAddress);case _: return orElse(); } @@ -2222,10 +2223,10 @@ return $default(_that.enable,_that.device,_that.autoRoute,_that.stack,_that.dnsH /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool enable, String device, @JsonKey(name: 'auto-route') bool autoRoute, TunStack stack, @JsonKey(name: 'dns-hijack') List dnsHijack, @JsonKey(name: 'route-address') List routeAddress) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool enable, String device, @JsonKey(name: 'auto-route') bool autoRoute, @JsonKey(name: 'auto-detect-interface') bool autoDetectInterface, TunStack stack, @JsonKey(name: 'dns-hijack') List dnsHijack, @JsonKey(name: 'route-address') List routeAddress) $default,) {final _that = this; switch (_that) { case _Tun(): -return $default(_that.enable,_that.device,_that.autoRoute,_that.stack,_that.dnsHijack,_that.routeAddress);case _: +return $default(_that.enable,_that.device,_that.autoRoute,_that.autoDetectInterface,_that.stack,_that.dnsHijack,_that.routeAddress);case _: throw StateError('Unexpected subclass'); } @@ -2242,10 +2243,10 @@ return $default(_that.enable,_that.device,_that.autoRoute,_that.stack,_that.dnsH /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool enable, String device, @JsonKey(name: 'auto-route') bool autoRoute, TunStack stack, @JsonKey(name: 'dns-hijack') List dnsHijack, @JsonKey(name: 'route-address') List routeAddress)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool enable, String device, @JsonKey(name: 'auto-route') bool autoRoute, @JsonKey(name: 'auto-detect-interface') bool autoDetectInterface, TunStack stack, @JsonKey(name: 'dns-hijack') List dnsHijack, @JsonKey(name: 'route-address') List routeAddress)? $default,) {final _that = this; switch (_that) { case _Tun() when $default != null: -return $default(_that.enable,_that.device,_that.autoRoute,_that.stack,_that.dnsHijack,_that.routeAddress);case _: +return $default(_that.enable,_that.device,_that.autoRoute,_that.autoDetectInterface,_that.stack,_that.dnsHijack,_that.routeAddress);case _: return null; } @@ -2257,12 +2258,13 @@ return $default(_that.enable,_that.device,_that.autoRoute,_that.stack,_that.dnsH @JsonSerializable() class _Tun implements Tun { - const _Tun({this.enable = false, this.device = appName, @JsonKey(name: 'auto-route') this.autoRoute = false, this.stack = TunStack.mixed, @JsonKey(name: 'dns-hijack') final List dnsHijack = const ['any:53'], @JsonKey(name: 'route-address') final List routeAddress = const []}): _dnsHijack = dnsHijack,_routeAddress = routeAddress; + const _Tun({this.enable = false, this.device = appName, @JsonKey(name: 'auto-route') this.autoRoute = false, @JsonKey(name: 'auto-detect-interface') this.autoDetectInterface = false, this.stack = TunStack.mixed, @JsonKey(name: 'dns-hijack') final List dnsHijack = const ['any:53'], @JsonKey(name: 'route-address') final List routeAddress = const []}): _dnsHijack = dnsHijack,_routeAddress = routeAddress; factory _Tun.fromJson(Map json) => _$TunFromJson(json); @override@JsonKey() final bool enable; @override@JsonKey() final String device; @override@JsonKey(name: 'auto-route') final bool autoRoute; +@override@JsonKey(name: 'auto-detect-interface') final bool autoDetectInterface; @override@JsonKey() final TunStack stack; final List _dnsHijack; @override@JsonKey(name: 'dns-hijack') List get dnsHijack { @@ -2292,16 +2294,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Tun&&(identical(other.enable, enable) || other.enable == enable)&&(identical(other.device, device) || other.device == device)&&(identical(other.autoRoute, autoRoute) || other.autoRoute == autoRoute)&&(identical(other.stack, stack) || other.stack == stack)&&const DeepCollectionEquality().equals(other._dnsHijack, _dnsHijack)&&const DeepCollectionEquality().equals(other._routeAddress, _routeAddress)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Tun&&(identical(other.enable, enable) || other.enable == enable)&&(identical(other.device, device) || other.device == device)&&(identical(other.autoRoute, autoRoute) || other.autoRoute == autoRoute)&&(identical(other.autoDetectInterface, autoDetectInterface) || other.autoDetectInterface == autoDetectInterface)&&(identical(other.stack, stack) || other.stack == stack)&&const DeepCollectionEquality().equals(other._dnsHijack, _dnsHijack)&&const DeepCollectionEquality().equals(other._routeAddress, _routeAddress)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,enable,device,autoRoute,stack,const DeepCollectionEquality().hash(_dnsHijack),const DeepCollectionEquality().hash(_routeAddress)); +int get hashCode => Object.hash(runtimeType,enable,device,autoRoute,autoDetectInterface,stack,const DeepCollectionEquality().hash(_dnsHijack),const DeepCollectionEquality().hash(_routeAddress)); @override String toString() { - return 'Tun(enable: $enable, device: $device, autoRoute: $autoRoute, stack: $stack, dnsHijack: $dnsHijack, routeAddress: $routeAddress)'; + return 'Tun(enable: $enable, device: $device, autoRoute: $autoRoute, autoDetectInterface: $autoDetectInterface, stack: $stack, dnsHijack: $dnsHijack, routeAddress: $routeAddress)'; } @@ -2312,7 +2314,7 @@ abstract mixin class _$TunCopyWith<$Res> implements $TunCopyWith<$Res> { factory _$TunCopyWith(_Tun value, $Res Function(_Tun) _then) = __$TunCopyWithImpl; @override @useResult $Res call({ - bool enable, String device,@JsonKey(name: 'auto-route') bool autoRoute, TunStack stack,@JsonKey(name: 'dns-hijack') List dnsHijack,@JsonKey(name: 'route-address') List routeAddress + bool enable, String device,@JsonKey(name: 'auto-route') bool autoRoute,@JsonKey(name: 'auto-detect-interface') bool autoDetectInterface, TunStack stack,@JsonKey(name: 'dns-hijack') List dnsHijack,@JsonKey(name: 'route-address') List routeAddress }); @@ -2329,11 +2331,12 @@ class __$TunCopyWithImpl<$Res> /// Create a copy of Tun /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? enable = null,Object? device = null,Object? autoRoute = null,Object? stack = null,Object? dnsHijack = null,Object? routeAddress = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? enable = null,Object? device = null,Object? autoRoute = null,Object? autoDetectInterface = null,Object? stack = null,Object? dnsHijack = null,Object? routeAddress = null,}) { return _then(_Tun( enable: null == enable ? _self.enable : enable // ignore: cast_nullable_to_non_nullable as bool,device: null == device ? _self.device : device // ignore: cast_nullable_to_non_nullable as String,autoRoute: null == autoRoute ? _self.autoRoute : autoRoute // ignore: cast_nullable_to_non_nullable +as bool,autoDetectInterface: null == autoDetectInterface ? _self.autoDetectInterface : autoDetectInterface // ignore: cast_nullable_to_non_nullable as bool,stack: null == stack ? _self.stack : stack // ignore: cast_nullable_to_non_nullable as TunStack,dnsHijack: null == dnsHijack ? _self._dnsHijack : dnsHijack // ignore: cast_nullable_to_non_nullable as List,routeAddress: null == routeAddress ? _self._routeAddress : routeAddress // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/generated/clash_config.g.dart b/lib/models/generated/clash_config.g.dart index b70d7a5a8c..9b409890d2 100644 --- a/lib/models/generated/clash_config.g.dart +++ b/lib/models/generated/clash_config.g.dart @@ -164,6 +164,7 @@ _Tun _$TunFromJson(Map json) => _Tun( enable: json['enable'] as bool? ?? false, device: json['device'] as String? ?? appName, autoRoute: json['auto-route'] as bool? ?? false, + autoDetectInterface: json['auto-detect-interface'] as bool? ?? false, stack: $enumDecodeNullable(_$TunStackEnumMap, json['stack']) ?? TunStack.mixed, dnsHijack: @@ -182,6 +183,7 @@ Map _$TunToJson(_Tun instance) => { 'enable': instance.enable, 'device': instance.device, 'auto-route': instance.autoRoute, + 'auto-detect-interface': instance.autoDetectInterface, 'stack': _$TunStackEnumMap[instance.stack]!, 'dns-hijack': instance.dnsHijack, 'route-address': instance.routeAddress, diff --git a/lib/models/generated/config.freezed.dart b/lib/models/generated/config.freezed.dart index e776a21159..fb8afe9648 100644 --- a/lib/models/generated/config.freezed.dart +++ b/lib/models/generated/config.freezed.dart @@ -1205,7 +1205,7 @@ $AccessControlPropsCopyWith<$Res> get accessControlProps { /// @nodoc mixin _$NetworkProps { - bool get systemProxy; List get bypassDomain; RouteMode get routeMode; bool get autoSetSystemDns; bool get appendSystemDns; + bool get systemProxy; List get bypassDomain; RouteMode get routeMode; bool get autoSetSystemDns; bool get appendSystemDns; bool get useDirectForProfileUpdate; /// Create a copy of NetworkProps /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -1218,16 +1218,16 @@ $NetworkPropsCopyWith get copyWith => _$NetworkPropsCopyWithImpl Object.hash(runtimeType,systemProxy,const DeepCollectionEquality().hash(bypassDomain),routeMode,autoSetSystemDns,appendSystemDns); +int get hashCode => Object.hash(runtimeType,systemProxy,const DeepCollectionEquality().hash(bypassDomain),routeMode,autoSetSystemDns,appendSystemDns,useDirectForProfileUpdate); @override String toString() { - return 'NetworkProps(systemProxy: $systemProxy, bypassDomain: $bypassDomain, routeMode: $routeMode, autoSetSystemDns: $autoSetSystemDns, appendSystemDns: $appendSystemDns)'; + return 'NetworkProps(systemProxy: $systemProxy, bypassDomain: $bypassDomain, routeMode: $routeMode, autoSetSystemDns: $autoSetSystemDns, appendSystemDns: $appendSystemDns, useDirectForProfileUpdate: $useDirectForProfileUpdate)'; } @@ -1238,7 +1238,7 @@ abstract mixin class $NetworkPropsCopyWith<$Res> { factory $NetworkPropsCopyWith(NetworkProps value, $Res Function(NetworkProps) _then) = _$NetworkPropsCopyWithImpl; @useResult $Res call({ - bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns + bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns, bool useDirectForProfileUpdate }); @@ -1255,13 +1255,14 @@ class _$NetworkPropsCopyWithImpl<$Res> /// Create a copy of NetworkProps /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? systemProxy = null,Object? bypassDomain = null,Object? routeMode = null,Object? autoSetSystemDns = null,Object? appendSystemDns = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? systemProxy = null,Object? bypassDomain = null,Object? routeMode = null,Object? autoSetSystemDns = null,Object? appendSystemDns = null,Object? useDirectForProfileUpdate = null,}) { return _then(_self.copyWith( systemProxy: null == systemProxy ? _self.systemProxy : systemProxy // ignore: cast_nullable_to_non_nullable as bool,bypassDomain: null == bypassDomain ? _self.bypassDomain : bypassDomain // ignore: cast_nullable_to_non_nullable as List,routeMode: null == routeMode ? _self.routeMode : routeMode // ignore: cast_nullable_to_non_nullable as RouteMode,autoSetSystemDns: null == autoSetSystemDns ? _self.autoSetSystemDns : autoSetSystemDns // ignore: cast_nullable_to_non_nullable as bool,appendSystemDns: null == appendSystemDns ? _self.appendSystemDns : appendSystemDns // ignore: cast_nullable_to_non_nullable +as bool,useDirectForProfileUpdate: null == useDirectForProfileUpdate ? _self.useDirectForProfileUpdate : useDirectForProfileUpdate // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -1347,10 +1348,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns, bool useDirectForProfileUpdate)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _NetworkProps() when $default != null: -return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoSetSystemDns,_that.appendSystemDns);case _: +return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoSetSystemDns,_that.appendSystemDns,_that.useDirectForProfileUpdate);case _: return orElse(); } @@ -1368,10 +1369,10 @@ return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoS /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns, bool useDirectForProfileUpdate) $default,) {final _that = this; switch (_that) { case _NetworkProps(): -return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoSetSystemDns,_that.appendSystemDns);case _: +return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoSetSystemDns,_that.appendSystemDns,_that.useDirectForProfileUpdate);case _: throw StateError('Unexpected subclass'); } @@ -1388,10 +1389,10 @@ return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoS /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns, bool useDirectForProfileUpdate)? $default,) {final _that = this; switch (_that) { case _NetworkProps() when $default != null: -return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoSetSystemDns,_that.appendSystemDns);case _: +return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoSetSystemDns,_that.appendSystemDns,_that.useDirectForProfileUpdate);case _: return null; } @@ -1403,7 +1404,7 @@ return $default(_that.systemProxy,_that.bypassDomain,_that.routeMode,_that.autoS @JsonSerializable() class _NetworkProps implements NetworkProps { - const _NetworkProps({this.systemProxy = true, final List bypassDomain = defaultBypassDomain, this.routeMode = RouteMode.config, this.autoSetSystemDns = true, this.appendSystemDns = false}): _bypassDomain = bypassDomain; + const _NetworkProps({this.systemProxy = true, final List bypassDomain = defaultBypassDomain, this.routeMode = RouteMode.config, this.autoSetSystemDns = true, this.appendSystemDns = false, this.useDirectForProfileUpdate = false}): _bypassDomain = bypassDomain; factory _NetworkProps.fromJson(Map json) => _$NetworkPropsFromJson(json); @override@JsonKey() final bool systemProxy; @@ -1417,6 +1418,7 @@ class _NetworkProps implements NetworkProps { @override@JsonKey() final RouteMode routeMode; @override@JsonKey() final bool autoSetSystemDns; @override@JsonKey() final bool appendSystemDns; +@override@JsonKey() final bool useDirectForProfileUpdate; /// Create a copy of NetworkProps /// with the given fields replaced by the non-null parameter values. @@ -1431,16 +1433,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _NetworkProps&&(identical(other.systemProxy, systemProxy) || other.systemProxy == systemProxy)&&const DeepCollectionEquality().equals(other._bypassDomain, _bypassDomain)&&(identical(other.routeMode, routeMode) || other.routeMode == routeMode)&&(identical(other.autoSetSystemDns, autoSetSystemDns) || other.autoSetSystemDns == autoSetSystemDns)&&(identical(other.appendSystemDns, appendSystemDns) || other.appendSystemDns == appendSystemDns)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NetworkProps&&(identical(other.systemProxy, systemProxy) || other.systemProxy == systemProxy)&&const DeepCollectionEquality().equals(other._bypassDomain, _bypassDomain)&&(identical(other.routeMode, routeMode) || other.routeMode == routeMode)&&(identical(other.autoSetSystemDns, autoSetSystemDns) || other.autoSetSystemDns == autoSetSystemDns)&&(identical(other.appendSystemDns, appendSystemDns) || other.appendSystemDns == appendSystemDns)&&(identical(other.useDirectForProfileUpdate, useDirectForProfileUpdate) || other.useDirectForProfileUpdate == useDirectForProfileUpdate)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,systemProxy,const DeepCollectionEquality().hash(_bypassDomain),routeMode,autoSetSystemDns,appendSystemDns); +int get hashCode => Object.hash(runtimeType,systemProxy,const DeepCollectionEquality().hash(_bypassDomain),routeMode,autoSetSystemDns,appendSystemDns,useDirectForProfileUpdate); @override String toString() { - return 'NetworkProps(systemProxy: $systemProxy, bypassDomain: $bypassDomain, routeMode: $routeMode, autoSetSystemDns: $autoSetSystemDns, appendSystemDns: $appendSystemDns)'; + return 'NetworkProps(systemProxy: $systemProxy, bypassDomain: $bypassDomain, routeMode: $routeMode, autoSetSystemDns: $autoSetSystemDns, appendSystemDns: $appendSystemDns, useDirectForProfileUpdate: $useDirectForProfileUpdate)'; } @@ -1451,7 +1453,7 @@ abstract mixin class _$NetworkPropsCopyWith<$Res> implements $NetworkPropsCopyWi factory _$NetworkPropsCopyWith(_NetworkProps value, $Res Function(_NetworkProps) _then) = __$NetworkPropsCopyWithImpl; @override @useResult $Res call({ - bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns + bool systemProxy, List bypassDomain, RouteMode routeMode, bool autoSetSystemDns, bool appendSystemDns, bool useDirectForProfileUpdate }); @@ -1468,13 +1470,14 @@ class __$NetworkPropsCopyWithImpl<$Res> /// Create a copy of NetworkProps /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? systemProxy = null,Object? bypassDomain = null,Object? routeMode = null,Object? autoSetSystemDns = null,Object? appendSystemDns = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? systemProxy = null,Object? bypassDomain = null,Object? routeMode = null,Object? autoSetSystemDns = null,Object? appendSystemDns = null,Object? useDirectForProfileUpdate = null,}) { return _then(_NetworkProps( systemProxy: null == systemProxy ? _self.systemProxy : systemProxy // ignore: cast_nullable_to_non_nullable as bool,bypassDomain: null == bypassDomain ? _self._bypassDomain : bypassDomain // ignore: cast_nullable_to_non_nullable as List,routeMode: null == routeMode ? _self.routeMode : routeMode // ignore: cast_nullable_to_non_nullable as RouteMode,autoSetSystemDns: null == autoSetSystemDns ? _self.autoSetSystemDns : autoSetSystemDns // ignore: cast_nullable_to_non_nullable as bool,appendSystemDns: null == appendSystemDns ? _self.appendSystemDns : appendSystemDns // ignore: cast_nullable_to_non_nullable +as bool,useDirectForProfileUpdate: null == useDirectForProfileUpdate ? _self.useDirectForProfileUpdate : useDirectForProfileUpdate // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/models/generated/config.g.dart b/lib/models/generated/config.g.dart index 76d3d60445..0a3ea568e5 100644 --- a/lib/models/generated/config.g.dart +++ b/lib/models/generated/config.g.dart @@ -178,6 +178,8 @@ _NetworkProps _$NetworkPropsFromJson(Map json) => RouteMode.config, autoSetSystemDns: json['autoSetSystemDns'] as bool? ?? true, appendSystemDns: json['appendSystemDns'] as bool? ?? false, + useDirectForProfileUpdate: + json['useDirectForProfileUpdate'] as bool? ?? false, ); Map _$NetworkPropsToJson(_NetworkProps instance) => @@ -187,6 +189,7 @@ Map _$NetworkPropsToJson(_NetworkProps instance) => 'routeMode': _$RouteModeEnumMap[instance.routeMode]!, 'autoSetSystemDns': instance.autoSetSystemDns, 'appendSystemDns': instance.appendSystemDns, + 'useDirectForProfileUpdate': instance.useDirectForProfileUpdate, }; const _$RouteModeEnumMap = { diff --git a/lib/models/generated/core.freezed.dart b/lib/models/generated/core.freezed.dart index bc15672f9c..52f86e0697 100644 --- a/lib/models/generated/core.freezed.dart +++ b/lib/models/generated/core.freezed.dart @@ -1450,6 +1450,544 @@ as String, } +/// @nodoc +mixin _$DownloadFileParams { + + String get url; String get path;@JsonKey(name: 'user-agent') String get userAgent; +/// Create a copy of DownloadFileParams +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DownloadFileParamsCopyWith get copyWith => _$DownloadFileParamsCopyWithImpl(this as DownloadFileParams, _$identity); + + /// Serializes this DownloadFileParams to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DownloadFileParams&&(identical(other.url, url) || other.url == url)&&(identical(other.path, path) || other.path == path)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,url,path,userAgent); + +@override +String toString() { + return 'DownloadFileParams(url: $url, path: $path, userAgent: $userAgent)'; +} + + +} + +/// @nodoc +abstract mixin class $DownloadFileParamsCopyWith<$Res> { + factory $DownloadFileParamsCopyWith(DownloadFileParams value, $Res Function(DownloadFileParams) _then) = _$DownloadFileParamsCopyWithImpl; +@useResult +$Res call({ + String url, String path,@JsonKey(name: 'user-agent') String userAgent +}); + + + + +} +/// @nodoc +class _$DownloadFileParamsCopyWithImpl<$Res> + implements $DownloadFileParamsCopyWith<$Res> { + _$DownloadFileParamsCopyWithImpl(this._self, this._then); + + final DownloadFileParams _self; + final $Res Function(DownloadFileParams) _then; + +/// Create a copy of DownloadFileParams +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? url = null,Object? path = null,Object? userAgent = null,}) { + return _then(_self.copyWith( +url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [DownloadFileParams]. +extension DownloadFileParamsPatterns on DownloadFileParams { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DownloadFileParams value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DownloadFileParams() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DownloadFileParams value) $default,){ +final _that = this; +switch (_that) { +case _DownloadFileParams(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DownloadFileParams value)? $default,){ +final _that = this; +switch (_that) { +case _DownloadFileParams() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String url, String path, @JsonKey(name: 'user-agent') String userAgent)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DownloadFileParams() when $default != null: +return $default(_that.url,_that.path,_that.userAgent);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String url, String path, @JsonKey(name: 'user-agent') String userAgent) $default,) {final _that = this; +switch (_that) { +case _DownloadFileParams(): +return $default(_that.url,_that.path,_that.userAgent);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String url, String path, @JsonKey(name: 'user-agent') String userAgent)? $default,) {final _that = this; +switch (_that) { +case _DownloadFileParams() when $default != null: +return $default(_that.url,_that.path,_that.userAgent);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _DownloadFileParams implements DownloadFileParams { + const _DownloadFileParams({required this.url, required this.path, @JsonKey(name: 'user-agent') required this.userAgent}); + factory _DownloadFileParams.fromJson(Map json) => _$DownloadFileParamsFromJson(json); + +@override final String url; +@override final String path; +@override@JsonKey(name: 'user-agent') final String userAgent; + +/// Create a copy of DownloadFileParams +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DownloadFileParamsCopyWith<_DownloadFileParams> get copyWith => __$DownloadFileParamsCopyWithImpl<_DownloadFileParams>(this, _$identity); + +@override +Map toJson() { + return _$DownloadFileParamsToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DownloadFileParams&&(identical(other.url, url) || other.url == url)&&(identical(other.path, path) || other.path == path)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,url,path,userAgent); + +@override +String toString() { + return 'DownloadFileParams(url: $url, path: $path, userAgent: $userAgent)'; +} + + +} + +/// @nodoc +abstract mixin class _$DownloadFileParamsCopyWith<$Res> implements $DownloadFileParamsCopyWith<$Res> { + factory _$DownloadFileParamsCopyWith(_DownloadFileParams value, $Res Function(_DownloadFileParams) _then) = __$DownloadFileParamsCopyWithImpl; +@override @useResult +$Res call({ + String url, String path,@JsonKey(name: 'user-agent') String userAgent +}); + + + + +} +/// @nodoc +class __$DownloadFileParamsCopyWithImpl<$Res> + implements _$DownloadFileParamsCopyWith<$Res> { + __$DownloadFileParamsCopyWithImpl(this._self, this._then); + + final _DownloadFileParams _self; + final $Res Function(_DownloadFileParams) _then; + +/// Create a copy of DownloadFileParams +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? url = null,Object? path = null,Object? userAgent = null,}) { + return _then(_DownloadFileParams( +url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$DownloadFileResult { + +@JsonKey(name: 'content-disposition') String get contentDisposition;@JsonKey(name: 'subscription-userinfo') String get subscriptionUserinfo; String get error; +/// Create a copy of DownloadFileResult +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DownloadFileResultCopyWith get copyWith => _$DownloadFileResultCopyWithImpl(this as DownloadFileResult, _$identity); + + /// Serializes this DownloadFileResult to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DownloadFileResult&&(identical(other.contentDisposition, contentDisposition) || other.contentDisposition == contentDisposition)&&(identical(other.subscriptionUserinfo, subscriptionUserinfo) || other.subscriptionUserinfo == subscriptionUserinfo)&&(identical(other.error, error) || other.error == error)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,contentDisposition,subscriptionUserinfo,error); + +@override +String toString() { + return 'DownloadFileResult(contentDisposition: $contentDisposition, subscriptionUserinfo: $subscriptionUserinfo, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $DownloadFileResultCopyWith<$Res> { + factory $DownloadFileResultCopyWith(DownloadFileResult value, $Res Function(DownloadFileResult) _then) = _$DownloadFileResultCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'content-disposition') String contentDisposition,@JsonKey(name: 'subscription-userinfo') String subscriptionUserinfo, String error +}); + + + + +} +/// @nodoc +class _$DownloadFileResultCopyWithImpl<$Res> + implements $DownloadFileResultCopyWith<$Res> { + _$DownloadFileResultCopyWithImpl(this._self, this._then); + + final DownloadFileResult _self; + final $Res Function(DownloadFileResult) _then; + +/// Create a copy of DownloadFileResult +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? contentDisposition = null,Object? subscriptionUserinfo = null,Object? error = null,}) { + return _then(_self.copyWith( +contentDisposition: null == contentDisposition ? _self.contentDisposition : contentDisposition // ignore: cast_nullable_to_non_nullable +as String,subscriptionUserinfo: null == subscriptionUserinfo ? _self.subscriptionUserinfo : subscriptionUserinfo // ignore: cast_nullable_to_non_nullable +as String,error: null == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [DownloadFileResult]. +extension DownloadFileResultPatterns on DownloadFileResult { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DownloadFileResult value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DownloadFileResult() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DownloadFileResult value) $default,){ +final _that = this; +switch (_that) { +case _DownloadFileResult(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DownloadFileResult value)? $default,){ +final _that = this; +switch (_that) { +case _DownloadFileResult() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'content-disposition') String contentDisposition, @JsonKey(name: 'subscription-userinfo') String subscriptionUserinfo, String error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DownloadFileResult() when $default != null: +return $default(_that.contentDisposition,_that.subscriptionUserinfo,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'content-disposition') String contentDisposition, @JsonKey(name: 'subscription-userinfo') String subscriptionUserinfo, String error) $default,) {final _that = this; +switch (_that) { +case _DownloadFileResult(): +return $default(_that.contentDisposition,_that.subscriptionUserinfo,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'content-disposition') String contentDisposition, @JsonKey(name: 'subscription-userinfo') String subscriptionUserinfo, String error)? $default,) {final _that = this; +switch (_that) { +case _DownloadFileResult() when $default != null: +return $default(_that.contentDisposition,_that.subscriptionUserinfo,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _DownloadFileResult implements DownloadFileResult { + const _DownloadFileResult({@JsonKey(name: 'content-disposition') this.contentDisposition = '', @JsonKey(name: 'subscription-userinfo') this.subscriptionUserinfo = '', this.error = ''}); + factory _DownloadFileResult.fromJson(Map json) => _$DownloadFileResultFromJson(json); + +@override@JsonKey(name: 'content-disposition') final String contentDisposition; +@override@JsonKey(name: 'subscription-userinfo') final String subscriptionUserinfo; +@override@JsonKey() final String error; + +/// Create a copy of DownloadFileResult +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DownloadFileResultCopyWith<_DownloadFileResult> get copyWith => __$DownloadFileResultCopyWithImpl<_DownloadFileResult>(this, _$identity); + +@override +Map toJson() { + return _$DownloadFileResultToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DownloadFileResult&&(identical(other.contentDisposition, contentDisposition) || other.contentDisposition == contentDisposition)&&(identical(other.subscriptionUserinfo, subscriptionUserinfo) || other.subscriptionUserinfo == subscriptionUserinfo)&&(identical(other.error, error) || other.error == error)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,contentDisposition,subscriptionUserinfo,error); + +@override +String toString() { + return 'DownloadFileResult(contentDisposition: $contentDisposition, subscriptionUserinfo: $subscriptionUserinfo, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$DownloadFileResultCopyWith<$Res> implements $DownloadFileResultCopyWith<$Res> { + factory _$DownloadFileResultCopyWith(_DownloadFileResult value, $Res Function(_DownloadFileResult) _then) = __$DownloadFileResultCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'content-disposition') String contentDisposition,@JsonKey(name: 'subscription-userinfo') String subscriptionUserinfo, String error +}); + + + + +} +/// @nodoc +class __$DownloadFileResultCopyWithImpl<$Res> + implements _$DownloadFileResultCopyWith<$Res> { + __$DownloadFileResultCopyWithImpl(this._self, this._then); + + final _DownloadFileResult _self; + final $Res Function(_DownloadFileResult) _then; + +/// Create a copy of DownloadFileResult +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? contentDisposition = null,Object? subscriptionUserinfo = null,Object? error = null,}) { + return _then(_DownloadFileResult( +contentDisposition: null == contentDisposition ? _self.contentDisposition : contentDisposition // ignore: cast_nullable_to_non_nullable +as String,subscriptionUserinfo: null == subscriptionUserinfo ? _self.subscriptionUserinfo : subscriptionUserinfo // ignore: cast_nullable_to_non_nullable +as String,error: null == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + /// @nodoc mixin _$UpdateGeoDataParams { diff --git a/lib/models/generated/core.g.dart b/lib/models/generated/core.g.dart index 7e785dfad3..5999caa2e2 100644 --- a/lib/models/generated/core.g.dart +++ b/lib/models/generated/core.g.dart @@ -138,6 +138,34 @@ Map _$ChangeProxyParamsToJson(_ChangeProxyParams instance) => 'proxy-name': instance.proxyName, }; +_DownloadFileParams _$DownloadFileParamsFromJson(Map json) => + _DownloadFileParams( + url: json['url'] as String, + path: json['path'] as String, + userAgent: json['user-agent'] as String, + ); + +Map _$DownloadFileParamsToJson(_DownloadFileParams instance) => + { + 'url': instance.url, + 'path': instance.path, + 'user-agent': instance.userAgent, + }; + +_DownloadFileResult _$DownloadFileResultFromJson(Map json) => + _DownloadFileResult( + contentDisposition: json['content-disposition'] as String? ?? '', + subscriptionUserinfo: json['subscription-userinfo'] as String? ?? '', + error: json['error'] as String? ?? '', + ); + +Map _$DownloadFileResultToJson(_DownloadFileResult instance) => + { + 'content-disposition': instance.contentDisposition, + 'subscription-userinfo': instance.subscriptionUserinfo, + 'error': instance.error, + }; + _UpdateGeoDataParams _$UpdateGeoDataParamsFromJson(Map json) => _UpdateGeoDataParams( geoType: json['geo-type'] as String, @@ -277,6 +305,7 @@ const _$ActionMethodEnumMap = { ActionMethod.getTotalTraffic: 'getTotalTraffic', ActionMethod.resetTraffic: 'resetTraffic', ActionMethod.asyncTestDelay: 'asyncTestDelay', + ActionMethod.downloadFile: 'downloadFile', ActionMethod.getConnections: 'getConnections', ActionMethod.closeConnections: 'closeConnections', ActionMethod.resetConnections: 'resetConnections', diff --git a/lib/models/profile.dart b/lib/models/profile.dart index c6a5dbee29..d183b689d5 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -4,9 +4,11 @@ import 'dart:typed_data'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/core/controller.dart'; import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/state.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'clash_config.dart'; +import 'core.dart'; part 'generated/profile.freezed.dart'; part 'generated/profile.g.dart'; @@ -24,12 +26,18 @@ abstract class SubscriptionInfo with _$SubscriptionInfo { _$SubscriptionInfoFromJson(json); factory SubscriptionInfo.formHString(String? info) { - if (info == null) return const SubscriptionInfo(); - final list = info.split(';'); - final Map map = {}; - for (final i in list) { - final keyValue = i.trim().split('='); - map[keyValue[0]] = int.tryParse(keyValue[1]); + if (info == null || info.trim().isEmpty) { + return const SubscriptionInfo(); + } + final Map map = {}; + for (final item in info.split(';')) { + final separatorIndex = item.indexOf('='); + if (separatorIndex <= 0) continue; + final key = item.substring(0, separatorIndex).trim(); + final value = int.tryParse(item.substring(separatorIndex + 1).trim()); + if (key.isNotEmpty && value != null) { + map[key] = value; + } } return SubscriptionInfo( upload: map['upload'] ?? 0, @@ -161,13 +169,13 @@ extension ProfileExtension on Profile { String get updatingKey => 'profile_$id'; - Future checkAndUpdateAndCopy() async { + Future checkAndUpdateAndCopy({bool useDirect = false}) async { final mFile = await _getFile(false); final isExists = await mFile.exists(); if (isExists || url.isEmpty) { return null; } - return update(); + return update(useDirect: useDirect); } Future _getFile([bool autoCreate = true]) async { @@ -178,54 +186,84 @@ extension ProfileExtension on Profile { return file.create(recursive: true); } return file; - // final oldPath = await appPath.getProfilePath(id); - // final newPath = await appPath.getProfilePath(fileName); - // final oldFile = oldPath == newPath ? null : File(oldPath); - // final oldIsExists = await oldFile?.exists() ?? false; - // if (oldIsExists) { - // return await oldFile!.rename(newPath); - // } - // final file = File(newPath); - // final isExists = await file.exists(); - // if (!isExists && autoCreate) { - // return await file.create(recursive: true); - // } - // return file; } Future get file async { return _getFile(); } - Future update() async { + Future update({bool useDirect = false}) async { + if (useDirect) { + return updateDirect( + controller: coreController, + userAgent: globalState.ua, + ); + } final response = await request.getFileResponseForUrl(url); final disposition = response.headers.value('content-disposition'); final userinfo = response.headers.value('subscription-userinfo'); - return copyWith( - label: label.takeFirstValid([ - utils.getFileNameForDisposition(disposition), - id.toString(), - ]), - subscriptionInfo: SubscriptionInfo.formHString(userinfo), + return _withDownloadMetadata( + disposition: disposition, + userinfo: userinfo, ).saveFile(response.data ?? Uint8List.fromList([])); } + @visibleForTesting + Future updateDirect({ + required CoreController controller, + required String userAgent, + }) async { + final path = await appPath.tempFilePath; + final tempFile = File(path); + try { + final result = await controller.downloadFile( + DownloadFileParams(url: url, path: path, userAgent: userAgent), + ); + return await _withDownloadMetadata( + disposition: result.contentDisposition, + userinfo: result.subscriptionUserinfo, + )._saveFileWithPath(path, controller: controller); + } finally { + try { + await tempFile.safeDelete(); + } on FileSystemException { + final message = await controller.deleteFile(path); + if (message.isNotEmpty) { + throw message; + } + } + } + } + Future saveFile(Uint8List bytes) async { final path = await appPath.tempFilePath; final tempFile = File(path); - await tempFile.safeWriteAsBytes(bytes); - final message = await coreController.validateConfig(path); - if (message.isNotEmpty) { - throw message; + try { + await tempFile.safeWriteAsBytes(bytes); + return await _saveFileWithPath(path, controller: coreController); + } finally { + await tempFile.safeDelete(); } - final mFile = await file; - await tempFile.copy(mFile.path); - await tempFile.safeDelete(); - return copyWith(lastUpdateDate: DateTime.now()); } - Future saveFileWithPath(String path) async { - final message = await coreController.validateConfig(path); + Profile _withDownloadMetadata({ + required String? disposition, + required String? userinfo, + }) { + return copyWith( + label: label.takeFirstValid([ + utils.getFileNameForDisposition(disposition), + id.toString(), + ]), + subscriptionInfo: SubscriptionInfo.formHString(userinfo), + ); + } + + Future _saveFileWithPath( + String path, { + required CoreController controller, + }) async { + final message = await controller.validateConfig(path); if (message.isNotEmpty) { throw message; } diff --git a/lib/providers/action.dart b/lib/providers/action.dart index c444723c7c..b31e2f8049 100644 --- a/lib/providers/action.dart +++ b/lib/providers/action.dart @@ -373,7 +373,12 @@ class SetupAction extends _$SetupAction { FutureOr Function()? onUpdated, }) async { var profile = ref.read(currentProfileProvider); - final nextProfile = await profile?.checkAndUpdateAndCopy(); + final useDirectForProfileUpdate = ref + .read(networkSettingProvider) + .useDirectForProfileUpdate; + final nextProfile = await profile?.checkAndUpdateAndCopy( + useDirect: useDirectForProfileUpdate, + ); if (nextProfile != null) { profile = nextProfile; ref.read(profilesProvider.notifier).put(nextProfile); @@ -898,7 +903,10 @@ class ProfilesAction extends _$ProfilesAction { ref.read(isUpdatingProvider(profile.updatingKey).notifier).value = true; } ref.read(profilesProvider.notifier).put(profile); - final newProfile = await profile.update(); + final useDirect = ref + .read(networkSettingProvider) + .useDirectForProfileUpdate; + final newProfile = await profile.update(useDirect: useDirect); ref.read(profilesProvider.notifier).put(newProfile); if (profile.id == ref.read(currentProfileIdProvider)) { ref @@ -936,7 +944,10 @@ class ProfilesAction extends _$ProfilesAction { final profile = await globalState.loadingRun( tag: LoadingTag.profiles, () async { - return Profile.normal(url: url).update(); + final useDirect = ref + .read(networkSettingProvider) + .useDirectForProfileUpdate; + return Profile.normal(url: url).update(useDirect: useDirect); }, title: currentAppLocalizations.addProfile, ); diff --git a/lib/providers/generated/action.g.dart b/lib/providers/generated/action.g.dart index eac384e11a..6185213bf2 100644 --- a/lib/providers/generated/action.g.dart +++ b/lib/providers/generated/action.g.dart @@ -91,7 +91,7 @@ final class SetupActionProvider extends $NotifierProvider { } } -String _$setupActionHash() => r'c6c7b3b5d90f5070dca9ed02e51dcc06f812a294'; +String _$setupActionHash() => r'a0bfc370062d1f214ff59288014c12352765dd77'; abstract class _$SetupAction extends $Notifier { void build(); @@ -450,7 +450,7 @@ final class ProfilesActionProvider } } -String _$profilesActionHash() => r'b2457dc5b18d51204d17995949a52cffce41df38'; +String _$profilesActionHash() => r'ecc1438e662e2519ed06a3972d243d1238db642d'; abstract class _$ProfilesAction extends $Notifier { void build(); diff --git a/lib/views/config/network.dart b/lib/views/config/network.dart index b9001088b7..559236f93e 100644 --- a/lib/views/config/network.dart +++ b/lib/views/config/network.dart @@ -173,6 +173,32 @@ class AutoSetSystemDnsItem extends ConsumerWidget { } } +class DirectProfileUpdateItem extends ConsumerWidget { + const DirectProfileUpdateItem({super.key}); + + @override + Widget build(BuildContext context, ref) { + final appLocalizations = context.appLocalizations; + final useDirect = ref.watch( + networkSettingProvider.select((state) => state.useDirectForProfileUpdate), + ); + return ListItem.switchItem( + title: Text(appLocalizations.directProfileUpdate), + subtitle: Text(appLocalizations.directProfileUpdateDesc), + delegate: SwitchDelegate( + value: useDirect, + onChanged: (bool value) { + ref + .read(networkSettingProvider.notifier) + .update( + (state) => state.copyWith(useDirectForProfileUpdate: value), + ); + }, + ), + ); + } +} + class TunStackItem extends ConsumerWidget { const TunStackItem({super.key}); @@ -357,6 +383,7 @@ class NetworkListView extends StatelessWidget { ...generateSection( title: appLocalizations.options, items: [ + const DirectProfileUpdateItem(), if (system.isDesktop) const TUNItem(), if (system.isMacOS) const AutoSetSystemDnsItem(), const TunStackItem(), diff --git a/linux/packaging/appimage/make_config.yaml b/linux/packaging/appimage/make_config.yaml index d67a90dc3c..2c92e7eb10 100644 --- a/linux/packaging/appimage/make_config.yaml +++ b/linux/packaging/appimage/make_config.yaml @@ -14,5 +14,6 @@ categories: - Network startup_notify: true +startup_wm_class: com.follow.clash -include: [] \ No newline at end of file +include: [] diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index 535ae3742e..6ab5977c4a 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -25,4 +25,5 @@ generic_name: FlClash categories: - Network -startup_notify: true \ No newline at end of file +startup_notify: true +startup_wm_class: com.follow.clash diff --git a/linux/packaging/flutter_distributor_startup_wm_class.patch b/linux/packaging/flutter_distributor_startup_wm_class.patch new file mode 100644 index 0000000000..174731057b --- /dev/null +++ b/linux/packaging/flutter_distributor_startup_wm_class.patch @@ -0,0 +1,101 @@ +diff --git a/packages/flutter_app_packager/lib/src/makers/appimage/make_appimage_config.dart b/packages/flutter_app_packager/lib/src/makers/appimage/make_appimage_config.dart +index 95504e9..3c02bc6 100644 +--- a/packages/flutter_app_packager/lib/src/makers/appimage/make_appimage_config.dart ++++ b/packages/flutter_app_packager/lib/src/makers/appimage/make_appimage_config.dart +@@ -39,6 +39,7 @@ class MakeAppImageConfig extends MakeConfig { + this.actions = const [], + this.include = const [], + this.startupNotify = true, ++ this.startupWMClass, + this.genericName = 'A Flutter Application', + }); + factory MakeAppImageConfig.fromJson(Map map) { +@@ -50,6 +51,7 @@ class MakeAppImageConfig extends MakeConfig { + keywords: (map['keywords'] as List? ?? []).cast(), + categories: (map['categories'] as List? ?? []).cast(), + startupNotify: map['startup_notify'] as bool? ?? false, ++ startupWMClass: map['startup_wm_class'] as String?, + genericName: map['generic_name'] as String? ?? 'A Flutter Application', + actions: (map['actions'] as List? ?? []) + .map( +@@ -72,6 +74,7 @@ class MakeAppImageConfig extends MakeConfig { + final List categories; + final List actions; + final bool startupNotify; ++ final String? startupWMClass; + final String genericName; + final String displayName; + final List include; +@@ -86,6 +89,7 @@ class MakeAppImageConfig extends MakeConfig { + 'Icon': appName, + 'Type': 'Application', + 'StartupNotify': startupNotify ? 'true' : 'false', ++ if (startupWMClass != null) 'StartupWMClass': startupWMClass!, + if (categories.isNotEmpty) 'Categories': categories.join(';'), + if (keywords.isNotEmpty) 'Keywords': keywords.join(';'), + if (this.actions.isNotEmpty) +diff --git a/packages/flutter_app_packager/lib/src/makers/deb/make_deb_config.dart b/packages/flutter_app_packager/lib/src/makers/deb/make_deb_config.dart +index 7cb3d27..ab46d6c 100644 +--- a/packages/flutter_app_packager/lib/src/makers/deb/make_deb_config.dart ++++ b/packages/flutter_app_packager/lib/src/makers/deb/make_deb_config.dart +@@ -128,4 +128,5 @@ class MakeDebConfig extends MakeLinuxPackageConfig { + this.startupNotify = true, ++ this.startupWMClass, + this.essential = false, + List? postinstallScripts, + List? postuninstallScripts, +@@ -221,5 +222,6 @@ class MakeDebConfig extends MakeLinuxPackageConfig { + genericName: map['generic_name'], + startupNotify: map['startup_notify'], ++ startupWMClass: map['startup_wm_class'], + installedSize: map['installed_size'], + icon: map['icon'], + ); +@@ -236,6 +238,7 @@ class MakeDebConfig extends MakeLinuxPackageConfig { + String? icon; + String? genericName; + bool? startupNotify; ++ String? startupWMClass; + List? coAuthors; + List? dependencies; + List? buildDependenciesIndep; +@@ -314,5 +317,6 @@ class MakeDebConfig extends MakeLinuxPackageConfig { + : null, + 'StartupNotify': startupNotify, ++ 'StartupWMClass': startupWMClass, + }..removeWhere((key, value) => value == null), + }; + } +diff --git a/packages/flutter_app_packager/lib/src/makers/rpm/make_rpm_config.dart b/packages/flutter_app_packager/lib/src/makers/rpm/make_rpm_config.dart +index ee20c22..cd84a41 100644 +--- a/packages/flutter_app_packager/lib/src/makers/rpm/make_rpm_config.dart ++++ b/packages/flutter_app_packager/lib/src/makers/rpm/make_rpm_config.dart +@@ -8,5 +8,6 @@ class MakeRPMConfig extends MakeConfig { + required this.displayName, + this.startupNotify = true, ++ this.startupWMClass, + this.actions, + this.categories, + this.genericName, +@@ -51,5 +52,6 @@ class MakeRPMConfig extends MakeConfig { + genericName: json['generic_name'] as String?, + startupNotify: json['startup_notify'] as bool?, ++ startupWMClass: json['startup_wm_class'] as String?, + keywords: (json['keywords'] as List?)?.cast(), + supportedMimeType: + (json['supported_mime_type'] as List?)?.cast(), +@@ -70,6 +72,7 @@ class MakeRPMConfig extends MakeConfig { + String? icon; + String? genericName; + bool? startupNotify; ++ String? startupWMClass; + List? keywords; + List? supportedMimeType; + List? actions; +@@ -174,5 +177,6 @@ class MakeRPMConfig extends MakeConfig { + : null, + 'StartupNotify': startupNotify, ++ 'StartupWMClass': startupWMClass, + }..removeWhere((key, value) => value == null), + }; + } diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 50a7ddabef..c4f7a2c8c5 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -20,4 +20,5 @@ generic_name: FlClash group: Applications/Internet -startup_notify: true \ No newline at end of file +startup_notify: true +startup_wm_class: com.follow.clash diff --git a/pubspec.lock b/pubspec.lock index 78082c16f5..919d22e34f 100755 --- a/pubspec.lock +++ b/pubspec.lock @@ -1031,7 +1031,7 @@ packages: source: hosted version: "2.2.1" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" diff --git a/pubspec.yaml b/pubspec.yaml index 1e5d8f8074..e6380cce90 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,7 @@ dev_dependencies: intl_utils: ^2.8.14 test: ^1.30.0 mocktail: ^1.0.4 + path_provider_platform_interface: ^2.1.3 flutter: uses-material-design: true diff --git a/setup.dart b/setup.dart index e46f5bcd8e..394ce51001 100755 --- a/setup.dart +++ b/setup.dart @@ -23,6 +23,10 @@ const _hostPlatform = { 'windows': 'windows', }; +const _flutterDistributorUrl = + 'https://github.com/chen08209/flutter_distributor.git'; +const _flutterDistributorRef = 'FlClash'; + Future main(List args) async { final parser = createSetupArgParser(); @@ -156,22 +160,8 @@ Future _package( final depExit = await _ensureDependencies(platform, arch); if (depExit != 0) return depExit; - final activateResult = await Process.run('dart', [ - 'pub', - 'global', - 'activate', - '-s', - 'git', - 'https://github.com/chen08209/flutter_distributor.git', - '--git-ref', - 'FlClash', - '--git-path', - 'packages/flutter_distributor', - ]); - if (activateResult.exitCode != 0) { - stderr.write(activateResult.stderr); - return activateResult.exitCode; - } + final activateExit = await _activateFlutterDistributor(platform, rootDir); + if (activateExit != 0) return activateExit; final process = await Process.start( 'flutter_distributor', @@ -203,6 +193,73 @@ Future _package( return exitCode; } +Future _activateFlutterDistributor(String platform, String rootDir) async { + if (platform != 'linux') { + final result = await Process.run('dart', [ + 'pub', + 'global', + 'activate', + '-s', + 'git', + _flutterDistributorUrl, + '--git-ref', + _flutterDistributorRef, + '--git-path', + 'packages/flutter_distributor', + ]); + if (result.exitCode != 0) stderr.write(result.stderr); + return result.exitCode; + } + + final sourceDir = Directory( + p.join(rootDir, '.dart_tool', 'flutter_distributor'), + ); + if (sourceDir.existsSync()) { + sourceDir.deleteSync(recursive: true); + } + + final cloneResult = await Process.run('git', [ + 'clone', + '--depth', + '1', + '--branch', + _flutterDistributorRef, + _flutterDistributorUrl, + sourceDir.path, + ]); + if (cloneResult.exitCode != 0) { + stderr.write(cloneResult.stderr); + return cloneResult.exitCode; + } + + final patchFile = p.join( + rootDir, + 'linux', + 'packaging', + 'flutter_distributor_startup_wm_class.patch', + ); + final patchResult = await Process.run('git', [ + 'apply', + patchFile, + ], workingDirectory: sourceDir.path); + if (patchResult.exitCode != 0) { + stderr.writeln('flutter_distributor patch failed.'); + stderr.write(patchResult.stderr); + return patchResult.exitCode; + } + + final result = await Process.run('dart', [ + 'pub', + 'global', + 'activate', + '-s', + 'path', + p.join(sourceDir.path, 'packages', 'flutter_distributor'), + ]); + if (result.exitCode != 0) stderr.write(result.stderr); + return result.exitCode; +} + Future _buildGoCore(String rootDir) async { final buildToolDir = p.join( rootDir, diff --git a/test/common/task_test.dart b/test/common/task_test.dart new file mode 100644 index 0000000000..729cb4b8ea --- /dev/null +++ b/test/common/task_test.dart @@ -0,0 +1,24 @@ +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/models/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('makeRealProfileTask includes auto-detect-interface', () async { + final result = await makeRealProfileTask( + const MakeRealProfileState( + profilesPath: '/tmp/profiles', + profileId: 1, + rawConfig: {}, + realPatchConfig: PatchClashConfig(tun: Tun(autoDetectInterface: true)), + overrideDns: false, + appendSystemDns: false, + proxyGroups: [], + rules: [], + addedRules: [], + defaultUA: 'FlClash/Test', + ), + ); + + expect(result.a, contains('auto-detect-interface: true')); + }); +} diff --git a/test/core/controller_test.dart b/test/core/controller_test.dart index f5f81db139..1602233a08 100644 --- a/test/core/controller_test.dart +++ b/test/core/controller_test.dart @@ -44,6 +44,13 @@ void main() { registerFallbackValue( const UpdateGeoDataParams(geoType: 't', geoName: 'n'), ); + registerFallbackValue( + const DownloadFileParams( + url: 'https://example.com/profile', + path: '/tmp/profile.yaml', + userAgent: 'FlClash/Test', + ), + ); }); setUp(() { @@ -116,6 +123,51 @@ void main() { }); }); + group('downloadFile', () { + const params = DownloadFileParams( + url: 'https://example.com/profile', + path: '/tmp/profile.yaml', + userAgent: 'FlClash/Test', + ); + + test('parses successful response', () async { + when(() => mock.downloadFile(params)).thenAnswer( + (_) async => json.encode({ + 'content-disposition': 'attachment; filename=profile.yaml', + 'subscription-userinfo': 'upload=1; total=10', + 'error': '', + }), + ); + + final result = await controller.downloadFile(params); + + expect(result.contentDisposition, 'attachment; filename=profile.yaml'); + expect(result.subscriptionUserinfo, 'upload=1; total=10'); + verify(() => mock.downloadFile(params)).called(1); + }); + + test('throws core download errors', () async { + when(() => mock.downloadFile(params)).thenAnswer( + (_) async => json.encode({ + 'content-disposition': '', + 'subscription-userinfo': '', + 'error': 'download failed', + }), + ); + + await expectLater( + controller.downloadFile(params), + throwsA('download failed'), + ); + }); + + test('throws for an empty core response', () async { + when(() => mock.downloadFile(params)).thenAnswer((_) async => ''); + + await expectLater(controller.downloadFile(params), throwsA(anything)); + }); + }); + group('proxy methods', () { test('changeProxy delegates to interface', () async { const params = ChangeProxyParams(groupName: 'G1', proxyName: 'P1'); diff --git a/test/models/config_test.dart b/test/models/config_test.dart index bf037ac8c5..baad576011 100644 --- a/test/models/config_test.dart +++ b/test/models/config_test.dart @@ -223,6 +223,7 @@ void main() { expect(props.routeMode, RouteMode.config); expect(props.autoSetSystemDns, true); expect(props.appendSystemDns, false); + expect(props.useDirectForProfileUpdate, false); }); test('round-trip with custom values', () { @@ -230,11 +231,27 @@ void main() { systemProxy: false, bypassDomain: ['example.com'], routeMode: RouteMode.bypassPrivate, + useDirectForProfileUpdate: true, ); final restored = roundTrip(() => props.toJson(), NetworkProps.fromJson); expect(restored.systemProxy, false); expect(restored.bypassDomain, ['example.com']); expect(restored.routeMode, RouteMode.bypassPrivate); + expect(restored.useDirectForProfileUpdate, true); + }); + }); + + group('Tun', () { + test('serializes auto-detect-interface', () { + final json = const Tun(autoDetectInterface: true).toJson(); + + expect(json['auto-detect-interface'], true); + }); + + test('enables interface detection for Linux desktop TUN', () { + final tun = const Tun().getRealTun(RouteMode.config); + + expect(tun.autoDetectInterface, system.isLinux); }); }); diff --git a/test/models/core_test.dart b/test/models/core_test.dart index 0fbdcb96d7..b7f5c90091 100644 --- a/test/models/core_test.dart +++ b/test/models/core_test.dart @@ -73,6 +73,34 @@ void main() { }); }); + group('DownloadFile', () { + test('params use kebab-case user-agent key', () { + const params = DownloadFileParams( + url: 'https://example.com/profile', + path: '/tmp/profile.yaml', + userAgent: 'FlClash/Test', + ); + + expect(params.toJson(), { + 'url': 'https://example.com/profile', + 'path': '/tmp/profile.yaml', + 'user-agent': 'FlClash/Test', + }); + }); + + test('result parses response headers and error', () { + final result = DownloadFileResult.fromJson({ + 'content-disposition': 'attachment; filename=profile.yaml', + 'subscription-userinfo': 'upload=1; total=10', + 'error': '', + }); + + expect(result.contentDisposition, 'attachment; filename=profile.yaml'); + expect(result.subscriptionUserinfo, 'upload=1; total=10'); + expect(result.error, isEmpty); + }); + }); + group('UpdateGeoDataParams', () { test('fromJson with snake-case keys', () { final json = {'geo-type': 'mmdb', 'geo-name': 'Country'}; diff --git a/test/models/profile_test.dart b/test/models/profile_test.dart index e1a9f1c4b3..89e2f4e273 100644 --- a/test/models/profile_test.dart +++ b/test/models/profile_test.dart @@ -1,9 +1,64 @@ +import 'dart:async'; +import 'dart:convert'; +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/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:test/test.dart'; +class MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {} + +class FakePathProviderPlatform extends PathProviderPlatform { + FakePathProviderPlatform(this.rootPath); + + final String rootPath; + + @override + Future getApplicationSupportPath() async => p.join(rootPath, 'data'); + + @override + Future getTemporaryPath() async => p.join(rootPath, 'temp'); + + @override + Future getDownloadsPath() async => p.join(rootPath, 'downloads'); + + @override + Future getApplicationCachePath() async => p.join(rootPath, 'cache'); +} + void main() { + late Directory rootDir; + late PathProviderPlatform originalPathProvider; + + setUpAll(() async { + rootDir = await Directory.systemTemp.createTemp('fl_clash_profile_test_'); + originalPathProvider = PathProviderPlatform.instance; + PathProviderPlatform.instance = FakePathProviderPlatform(rootDir.path); + await Directory(p.join(rootDir.path, 'temp')).create(); + registerFallbackValue( + const DownloadFileParams( + url: 'https://example.com/profile', + path: '/tmp/profile.yaml', + userAgent: 'FlClash/Test', + ), + ); + }); + + setUp(CoreController.resetInstance); + + tearDownAll(() async { + PathProviderPlatform.instance = originalPathProvider; + await rootDir.delete(recursive: true); + }); + + tearDown(CoreController.resetInstance); + group('SubscriptionInfo', () { test('parses subscription-userinfo header values', () { final info = SubscriptionInfo.formHString( @@ -18,9 +73,10 @@ void main() { test('falls back to zero for null and invalid values', () { expect(SubscriptionInfo.formHString(null), const SubscriptionInfo()); + expect(SubscriptionInfo.formHString(''), const SubscriptionInfo()); final info = SubscriptionInfo.formHString( - 'upload=bad; download=20; total=; expire=abc', + 'invalid; upload=bad; download=20; total=; expire=abc', ); expect(info.upload, 0); @@ -54,6 +110,63 @@ void main() { expect(urlProfile.realAutoUpdate, true); expect(urlProfile.realLabel, 'Remote'); }); + + test( + 'copies a direct download before deleting its temporary file', + () async { + final mock = MockCoreHandlerInterface(); + final controller = CoreController.test(mock); + final validationStarted = Completer(); + final finishValidation = Completer(); + late String downloadPath; + when(() => mock.downloadFile(any())).thenAnswer((invocation) async { + final params = + invocation.positionalArguments.first as DownloadFileParams; + downloadPath = params.path; + await File(downloadPath).writeAsString('proxies: []'); + return json.encode({ + 'content-disposition': 'attachment; filename=direct.yaml', + 'subscription-userinfo': 'upload=1; total=10', + 'error': '', + }); + }); + when(() => mock.validateConfig(any())).thenAnswer((_) async { + validationStarted.complete(); + await finishValidation.future; + return ''; + }); + + try { + const profile = Profile( + id: 7, + url: 'https://example.com/profile', + autoUpdateDuration: defaultUpdateDuration, + ); + final update = profile.updateDirect( + controller: controller, + userAgent: 'FlClash/Test', + ); + + await validationStarted.future; + await Future.delayed(Duration.zero); + expect(await File(downloadPath).exists(), isTrue); + + finishValidation.complete(); + final updated = await update; + final profileFile = File(await appPath.getProfilePath('7')); + + expect(await profileFile.readAsString(), 'proxies: []'); + expect(await File(downloadPath).exists(), isFalse); + expect(updated.label, 'direct.yaml'); + expect(updated.subscriptionInfo?.upload, 1); + expect(updated.subscriptionInfo?.total, 10); + } finally { + if (!finishValidation.isCompleted) { + finishValidation.complete(); + } + } + }, + ); }); group('ProfilesExt', () {