fix(core,profile,ui): keep files inside their owner and layouts usable when narrow
The elevated Core no longer leaves root-owned files behind on macOS, and provider files stay inside the profile that owns them. Cards, form rows, and narrow layouts stay usable at small widths. Changelog: Elevated runs and profile providers stop leaving files outside their owner, and narrow windows stay usable
This commit is contained in:
+1
-1
@@ -546,7 +546,7 @@
|
||||
"addSsid": "Добавить SSID",
|
||||
"editSsid": "Изменить SSID",
|
||||
"authorized": "Разрешено",
|
||||
"tapToAuthorize": "Нажмите, чтобы разрешить",
|
||||
"tapToAuthorize": "Разрешить",
|
||||
"suspended": "Приостановлено...",
|
||||
"geoOptions": "Настройки Geo",
|
||||
"geoAutoUpdate": "Автообновление",
|
||||
|
||||
@@ -42,6 +42,7 @@ func handleInitClash(params *InitParams) bool {
|
||||
defer configMu.Unlock()
|
||||
sdkVersion.Store(int32(params.Version))
|
||||
constant.SetHomeDir(params.HomeDir)
|
||||
initOwnership(params.HomeDir)
|
||||
isInit.Store(true)
|
||||
return true
|
||||
}
|
||||
@@ -632,6 +633,7 @@ func init() {
|
||||
})
|
||||
}
|
||||
executor.DefaultProviderLoadedHook = func(providerName string) {
|
||||
scheduleReclaimOwnership()
|
||||
sendMessage(Message{
|
||||
Type: LoadedMessage,
|
||||
Data: providerName,
|
||||
@@ -642,6 +644,7 @@ func init() {
|
||||
claimGeoUpdateFromHook(geoType)
|
||||
} else {
|
||||
releaseGeoUpdateFromHook(geoType)
|
||||
scheduleReclaimOwnership()
|
||||
}
|
||||
status := GeoUpdateStatus{
|
||||
Type: geoType,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(darwin || linux) || android
|
||||
|
||||
package main
|
||||
|
||||
func initOwnership(homeDir string) {}
|
||||
|
||||
func scheduleReclaimOwnership() {}
|
||||
@@ -0,0 +1,201 @@
|
||||
//go:build (darwin || linux) && !android
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanReclaimRequiresADirectoryOwnedByTheCaller(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
uid := os.Getuid()
|
||||
|
||||
if !canReclaim(homeDir, uid) {
|
||||
t.Fatal("a directory owned by the caller must be reclaimable")
|
||||
}
|
||||
if canReclaim(homeDir, uid+1) {
|
||||
t.Error("a directory owned by somebody else must be refused")
|
||||
}
|
||||
if canReclaim("", uid) {
|
||||
t.Error("an empty home directory must be refused")
|
||||
}
|
||||
if canReclaim(filepath.Join(homeDir, "missing"), uid) {
|
||||
t.Error("a missing path must be refused")
|
||||
}
|
||||
|
||||
file := filepath.Join(homeDir, "config.yaml")
|
||||
if err := os.WriteFile(file, []byte("mixed-port: 7890\n"), 0o600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
if canReclaim(file, uid) {
|
||||
t.Error("a regular file must be refused")
|
||||
}
|
||||
|
||||
link := filepath.Join(homeDir, "link")
|
||||
if err := os.Symlink(homeDir, link); err != nil {
|
||||
t.Fatalf("symlink error: %v", err)
|
||||
}
|
||||
if canReclaim(link, uid) {
|
||||
t.Error("a symlink to a directory must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReclaimEntrySkipsOwnedLinkedAndSymlinkedEntries(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
uid := os.Getuid()
|
||||
foreign := uid + 1
|
||||
|
||||
file := filepath.Join(homeDir, "provider")
|
||||
if err := os.WriteFile(file, []byte("proxies: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
info, err := os.Lstat(file)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat error: %v", err)
|
||||
}
|
||||
if !shouldReclaimEntry(info, foreign) {
|
||||
t.Error("a file owned by another user must be reclaimed")
|
||||
}
|
||||
if shouldReclaimEntry(info, uid) {
|
||||
t.Error("a file already owned by the caller must be skipped")
|
||||
}
|
||||
|
||||
hardLink := filepath.Join(homeDir, "hardlink")
|
||||
if err := os.Link(file, hardLink); err != nil {
|
||||
t.Fatalf("link error: %v", err)
|
||||
}
|
||||
linkInfo, err := os.Lstat(hardLink)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat error: %v", err)
|
||||
}
|
||||
if shouldReclaimEntry(linkInfo, foreign) {
|
||||
t.Error("a hard link must be skipped so it cannot smuggle in a foreign inode")
|
||||
}
|
||||
|
||||
symLink := filepath.Join(homeDir, "symlink")
|
||||
if err := os.Symlink(file, symLink); err != nil {
|
||||
t.Fatalf("symlink error: %v", err)
|
||||
}
|
||||
symInfo, err := os.Lstat(symLink)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat error: %v", err)
|
||||
}
|
||||
if shouldReclaimEntry(symInfo, foreign) {
|
||||
t.Error("a symlink must be skipped")
|
||||
}
|
||||
|
||||
dirInfo, err := os.Lstat(homeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat error: %v", err)
|
||||
}
|
||||
if !shouldReclaimEntry(dirInfo, foreign) {
|
||||
t.Error("a directory must be reclaimed even though its link count exceeds one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectReclaimTargetsWalksWithoutFollowingSymlinks(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
uid := os.Getuid()
|
||||
|
||||
providers := filepath.Join(homeDir, "profiles", "providers", "1", "proxies")
|
||||
if err := os.MkdirAll(providers, 0o755); err != nil {
|
||||
t.Fatalf("mkdir error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(providers, "abc"), []byte("proxies: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
|
||||
if targets := collectReclaimTargets(homeDir, uid); len(targets) != 0 {
|
||||
t.Errorf("targets = %v, want nothing when the caller already owns the tree", targets)
|
||||
}
|
||||
if targets := collectReclaimTargets(homeDir, uid+1); targets != nil {
|
||||
t.Errorf("targets = %v, want nil when the home directory belongs to somebody else", targets)
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(homeDir, "escape")); err != nil {
|
||||
t.Fatalf("symlink error: %v", err)
|
||||
}
|
||||
for _, path := range collectReclaimTargets(homeDir, uid) {
|
||||
if path == filepath.Join(outside, "secret") {
|
||||
t.Fatal("the walk must not follow a symlink out of the home directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimEntryVerifiesTheInodeItChowns(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
uid := os.Getuid()
|
||||
gid := os.Getgid()
|
||||
foreign := uid + 1
|
||||
|
||||
file := filepath.Join(homeDir, "provider")
|
||||
if err := os.WriteFile(file, []byte("proxies: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
|
||||
if ok, err := reclaimEntry(file, uid, gid); ok || err != nil {
|
||||
t.Errorf("reclaimEntry = (%v, %v), want no work for an entry the caller owns", ok, err)
|
||||
}
|
||||
|
||||
hardLink := filepath.Join(homeDir, "hardlink")
|
||||
if err := os.Link(file, hardLink); err != nil {
|
||||
t.Fatalf("link error: %v", err)
|
||||
}
|
||||
if ok, err := reclaimEntry(hardLink, foreign, gid); ok || err != nil {
|
||||
t.Errorf("reclaimEntry = (%v, %v), want a hard link left alone", ok, err)
|
||||
}
|
||||
|
||||
symLink := filepath.Join(homeDir, "symlink")
|
||||
if err := os.Symlink(file, symLink); err != nil {
|
||||
t.Fatalf("symlink error: %v", err)
|
||||
}
|
||||
if _, err := reclaimEntry(symLink, foreign, gid); !errors.Is(err, syscall.ELOOP) {
|
||||
t.Errorf("reclaimEntry error = %v, want ELOOP so a swapped final component cannot redirect the chown", err)
|
||||
}
|
||||
|
||||
fifo := filepath.Join(homeDir, "fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatalf("mkfifo error: %v", err)
|
||||
}
|
||||
if ok, err := reclaimEntry(fifo, foreign, gid); ok || err != nil {
|
||||
t.Errorf("reclaimEntry = (%v, %v), want a fifo skipped without blocking", ok, err)
|
||||
}
|
||||
|
||||
if _, err := reclaimEntry(filepath.Join(homeDir, "missing"), foreign, gid); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("reclaimEntry error = %v, want a missing entry reported as absent", err)
|
||||
}
|
||||
|
||||
plain := filepath.Join(homeDir, "plain")
|
||||
if err := os.WriteFile(plain, []byte("proxies: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
if _, err := reclaimEntry(plain, foreign, gid); err == nil {
|
||||
t.Error("a foreign-owned regular file must reach fchown, which only root may complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleReclaimOwnershipOnlyUsesTheHomeDirInitPassed(t *testing.T) {
|
||||
t.Cleanup(func() { reclaimHomeDir.Store("") })
|
||||
reclaimHomeDir.Store("")
|
||||
|
||||
scheduleReclaimOwnership()
|
||||
|
||||
if homeDir, _ := reclaimHomeDir.Load().(string); homeDir != "" {
|
||||
t.Errorf("home dir = %q, want the sweep to stay unarmed until init records one", homeDir)
|
||||
}
|
||||
|
||||
initOwnership(t.TempDir())
|
||||
|
||||
if homeDir, _ := reclaimHomeDir.Load().(string); homeDir == "" {
|
||||
t.Error("init must record the home directory the app handed the core")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//go:build (darwin || linux) && !android
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/log"
|
||||
)
|
||||
|
||||
const reclaimDebounce = 2 * time.Second
|
||||
|
||||
var (
|
||||
realUid = os.Getuid()
|
||||
realGid = os.Getgid()
|
||||
|
||||
reclaimPending atomic.Bool
|
||||
reclaimHomeDir atomic.Value
|
||||
)
|
||||
|
||||
func isElevated() bool {
|
||||
return os.Geteuid() == 0 && realUid != 0
|
||||
}
|
||||
|
||||
func canReclaim(homeDir string, uid int) bool {
|
||||
if homeDir == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Lstat(homeDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
return ok && int(stat.Uid) == uid
|
||||
}
|
||||
|
||||
func shouldReclaimEntry(info fs.FileInfo, uid int) bool {
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return false
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if int(stat.Uid) == uid {
|
||||
return false
|
||||
}
|
||||
return info.IsDir() || stat.Nlink <= 1
|
||||
}
|
||||
|
||||
func collectReclaimTargets(homeDir string, uid int) []string {
|
||||
if !canReclaim(homeDir, uid) {
|
||||
return nil
|
||||
}
|
||||
var targets []string
|
||||
_ = filepath.WalkDir(homeDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
info, infoErr := d.Info()
|
||||
if infoErr != nil {
|
||||
return nil
|
||||
}
|
||||
if shouldReclaimEntry(info, uid) {
|
||||
targets = append(targets, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return targets
|
||||
}
|
||||
|
||||
func isReclaimableStat(stat *syscall.Stat_t, uid int) bool {
|
||||
switch stat.Mode & syscall.S_IFMT {
|
||||
case syscall.S_IFDIR:
|
||||
case syscall.S_IFREG:
|
||||
if stat.Nlink > 1 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return int(stat.Uid) != uid
|
||||
}
|
||||
|
||||
func reclaimEntry(path string, uid int, gid int) (bool, error) {
|
||||
fd, err := syscall.Open(
|
||||
path,
|
||||
syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
var stat syscall.Stat_t
|
||||
if err := syscall.Fstat(fd, &stat); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !isReclaimableStat(&stat, uid) {
|
||||
return false, nil
|
||||
}
|
||||
if err := syscall.Fchown(fd, uid, gid); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func reclaimOwnership(homeDir string) {
|
||||
if !isElevated() {
|
||||
return
|
||||
}
|
||||
targets := collectReclaimTargets(homeDir, realUid)
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
reclaimed := 0
|
||||
for _, path := range targets {
|
||||
ok, err := reclaimEntry(path, realUid, realGid)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
log.Warnln("[APP] reclaim %s: %v", path, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
reclaimed++
|
||||
}
|
||||
}
|
||||
log.Infoln("[APP] reclaimed %d of %d elevated entries under %s", reclaimed, len(targets), homeDir)
|
||||
}
|
||||
|
||||
func initOwnership(homeDir string) {
|
||||
reclaimHomeDir.Store(homeDir)
|
||||
reclaimOwnership(homeDir)
|
||||
}
|
||||
|
||||
func scheduleReclaimOwnership() {
|
||||
if !isElevated() {
|
||||
return
|
||||
}
|
||||
homeDir, _ := reclaimHomeDir.Load().(string)
|
||||
if homeDir == "" {
|
||||
return
|
||||
}
|
||||
if !reclaimPending.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(reclaimDebounce)
|
||||
reclaimPending.Store(false)
|
||||
reclaimOwnership(homeDir)
|
||||
}()
|
||||
}
|
||||
@@ -70,6 +70,9 @@ final double kHeaderHeight = getWindowHeaderHeight(
|
||||
isMacOS: system.isMacOS,
|
||||
);
|
||||
const profilesDirectoryName = 'profiles';
|
||||
const providersDirectoryName = 'providers';
|
||||
const proxiesProviderDirectoryName = 'proxies';
|
||||
const rulesProviderDirectoryName = 'rules';
|
||||
const localhost = '127.0.0.1';
|
||||
const clashConfigKey = 'clash_config';
|
||||
const configKey = 'config';
|
||||
|
||||
@@ -36,3 +36,11 @@ extension FileSystemEntityExt on FileSystemEntity {
|
||||
await delete(recursive: recursive);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> safeDeletePath(String path) async {
|
||||
final entity = switch (FileSystemEntity.typeSync(path)) {
|
||||
FileSystemEntityType.directory => Directory(path),
|
||||
_ => File(path),
|
||||
};
|
||||
await entity.safeDelete(recursive: true);
|
||||
}
|
||||
|
||||
+19
-1
@@ -131,7 +131,25 @@ class AppPath {
|
||||
|
||||
Future<String> getProvidersRootPath() async {
|
||||
final directory = await profilesPath;
|
||||
return join(directory, 'providers');
|
||||
return join(directory, providersDirectoryName);
|
||||
}
|
||||
|
||||
Future<String> getProviderDirPath(int profileId, String type) async {
|
||||
final directory = await getProvidersRootPath();
|
||||
return join(directory, profileId.toString(), type);
|
||||
}
|
||||
|
||||
Future<void> ensureProviderDirs(int profileId) async {
|
||||
for (final type in const [
|
||||
proxiesProviderDirectoryName,
|
||||
rulesProviderDirectoryName,
|
||||
]) {
|
||||
final directory = Directory(await getProviderDirPath(profileId, type));
|
||||
if (await directory.exists()) {
|
||||
continue;
|
||||
}
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> get tempPath async {
|
||||
|
||||
@@ -120,6 +120,47 @@ class System {
|
||||
return true;
|
||||
}
|
||||
|
||||
static const _inheritedAclPermissions =
|
||||
'list,search,add_file,add_subdirectory,delete,delete_child,'
|
||||
'file_inherit,directory_inherit';
|
||||
|
||||
@visibleForTesting
|
||||
static List<String> aclArguments(String homeDirPath, String userName) {
|
||||
return [
|
||||
'-R',
|
||||
'+a',
|
||||
'user:$userName allow $_inheritedAclPermissions',
|
||||
homeDirPath,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> grantHomeDirAccess(String homeDirPath) async {
|
||||
if (!isMacOS) {
|
||||
return;
|
||||
}
|
||||
final userName = Platform.environment['USER'];
|
||||
if (userName == null || userName.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final result = await runProcess(
|
||||
'chmod',
|
||||
aclArguments(homeDirPath, userName),
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
commonPrint.log(
|
||||
'chmod +a exited with ${result.exitCode}: ${result.stderr.toString().trim()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'chmod +a failed: ${compactError(error)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _shellEscape(String value) {
|
||||
return "'${value.replaceAll("'", "'\\''")}'";
|
||||
}
|
||||
|
||||
+33
-44
@@ -120,13 +120,31 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
|
||||
String getProvidersFilePathInner(String type, String url) {
|
||||
return join(
|
||||
profilesPath,
|
||||
'providers',
|
||||
providersDirectoryName,
|
||||
profileId.toString(),
|
||||
type,
|
||||
url.toMd5(),
|
||||
);
|
||||
}
|
||||
|
||||
void confineProviders(String section, String type) {
|
||||
final providers = rawConfig[section];
|
||||
if (providers is! Map) {
|
||||
return;
|
||||
}
|
||||
for (final name in providers.keys) {
|
||||
final provider = providers[name];
|
||||
if (provider is! Map || provider['type'] == 'inline') {
|
||||
continue;
|
||||
}
|
||||
final url = provider['url'];
|
||||
provider['path'] = getProvidersFilePathInner(
|
||||
type,
|
||||
url is String && url.isNotEmpty ? url : '$section/$name',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rawConfig['external-controller'] = realPatchConfig.externalController.value;
|
||||
rawConfig['external-ui'] = '';
|
||||
rawConfig['interface-name'] = '';
|
||||
@@ -169,36 +187,8 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
|
||||
if (rawConfig['profile'] == null) {
|
||||
rawConfig['profile'] = {};
|
||||
}
|
||||
if (rawConfig['proxy-providers'] != null) {
|
||||
final proxyProviders = rawConfig['proxy-providers'] as Map;
|
||||
for (final key in proxyProviders.keys) {
|
||||
final proxyProvider = proxyProviders[key];
|
||||
if (proxyProvider['type'] != 'http') {
|
||||
continue;
|
||||
}
|
||||
if (proxyProvider['url'] != null) {
|
||||
proxyProvider['path'] = getProvidersFilePathInner(
|
||||
'proxies',
|
||||
proxyProvider['url'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rawConfig['rule-providers'] != null) {
|
||||
final ruleProviders = rawConfig['rule-providers'] as Map;
|
||||
for (final key in ruleProviders.keys) {
|
||||
final ruleProvider = ruleProviders[key];
|
||||
if (ruleProvider['type'] != 'http') {
|
||||
continue;
|
||||
}
|
||||
if (ruleProvider['url'] != null) {
|
||||
ruleProvider['path'] = getProvidersFilePathInner(
|
||||
'rules',
|
||||
ruleProvider['url'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
confineProviders('proxy-providers', proxiesProviderDirectoryName);
|
||||
confineProviders('rule-providers', rulesProviderDirectoryName);
|
||||
rawConfig['profile']['store-selected'] = false;
|
||||
rawConfig['geox-url'] = realPatchConfig.geoXUrl.raw;
|
||||
rawConfig['global-ua'] = realPatchConfig.globalUa ?? defaultUA;
|
||||
@@ -331,31 +321,30 @@ List<String> shakeOrphanFiles({
|
||||
void scanDirectory(
|
||||
Directory dir,
|
||||
Iterable<int> baseNames, {
|
||||
bool skipProvidersFolder = false,
|
||||
bool includeDirectories = false,
|
||||
}) {
|
||||
if (!dir.existsSync()) return;
|
||||
final entities = dir.listSync(recursive: false, followLinks: false);
|
||||
|
||||
for (final entity in entities) {
|
||||
if (entity is File) {
|
||||
final id = basenameWithoutExtension(entity.path);
|
||||
if (!baseNames.contains(int.tryParse(id))) {
|
||||
targets.add(entity.path);
|
||||
}
|
||||
} else if (skipProvidersFolder && entity is Directory) {
|
||||
if (basename(entity.path) == 'providers') {
|
||||
continue;
|
||||
}
|
||||
final selected =
|
||||
entity is File || (includeDirectories && entity is Directory);
|
||||
if (!selected) {
|
||||
continue;
|
||||
}
|
||||
final id = basenameWithoutExtension(entity.path);
|
||||
if (!baseNames.contains(int.tryParse(id))) {
|
||||
targets.add(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDirectory(Directory(profilesDirPath), profileIds);
|
||||
scanDirectory(
|
||||
Directory(profilesDirPath),
|
||||
Directory(providersDirPath),
|
||||
profileIds,
|
||||
skipProvidersFolder: true,
|
||||
includeDirectories: true,
|
||||
);
|
||||
scanDirectory(Directory(providersDirPath), profileIds);
|
||||
scanDirectory(Directory(scriptsDirPath), scriptIds);
|
||||
return targets;
|
||||
}
|
||||
|
||||
@@ -48,13 +48,18 @@ class CoreController {
|
||||
|
||||
Future<CoreLifecycleResult> close() => _interface.close();
|
||||
|
||||
static Future<void> initGeo() async {
|
||||
static Future<void> ensureHomeDir() async {
|
||||
final homePath = await appPath.homeDirPath;
|
||||
final homeDir = Directory(homePath);
|
||||
final isExists = await homeDir.exists();
|
||||
if (!isExists) {
|
||||
await homeDir.create(recursive: true);
|
||||
}
|
||||
await system.grantHomeDirAccess(homePath);
|
||||
}
|
||||
|
||||
static Future<void> initGeo() async {
|
||||
final homePath = await appPath.homeDirPath;
|
||||
const geoFileNameList = [MMDB, GEOIP, GEOSITE, ASN];
|
||||
try {
|
||||
for (final geoFileName in geoFileNameList) {
|
||||
@@ -77,6 +82,7 @@ class CoreController {
|
||||
}
|
||||
|
||||
Future<bool> init(int version) async {
|
||||
await ensureHomeDir();
|
||||
await initGeo();
|
||||
final homeDirPath = await appPath.homeDirPath;
|
||||
return _interface.init(InitParams(homeDir: homeDirPath, version: version));
|
||||
|
||||
@@ -23,7 +23,12 @@ class InfoMessageButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
class OverwriteFormRow extends StatelessWidget {
|
||||
final Widget title;
|
||||
static const _spacing = 16.0;
|
||||
static const _titleMaxLines = 2;
|
||||
static const _titleMaxWidthFactor = 0.5;
|
||||
|
||||
final String title;
|
||||
final TextStyle? titleStyle;
|
||||
final Widget? trailing;
|
||||
final bool invalid;
|
||||
final VoidCallback? onPressed;
|
||||
@@ -31,36 +36,58 @@ class OverwriteFormRow extends StatelessWidget {
|
||||
const OverwriteFormRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.titleStyle,
|
||||
this.trailing,
|
||||
this.invalid = false,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
Widget _buildTitle(double maxWidth) {
|
||||
final text = TooltipLabel(
|
||||
title,
|
||||
style: titleStyle,
|
||||
maxLines: _titleMaxLines,
|
||||
);
|
||||
if (trailing == null) {
|
||||
return Flexible(child: text);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: (maxWidth - _spacing) * _titleMaxWidthFactor,
|
||||
),
|
||||
child: text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecorationListItem(
|
||||
invalid: invalid,
|
||||
onPressed: onPressed,
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 16,
|
||||
children: [
|
||||
title,
|
||||
if (trailing != null)
|
||||
Flexible(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
size: 16.ap,
|
||||
color: context.colorScheme.onSurface.opacity60,
|
||||
title: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: _spacing,
|
||||
children: [
|
||||
_buildTitle(constraints.maxWidth),
|
||||
if (trailing != null)
|
||||
Flexible(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
size: 16.ap,
|
||||
color: context.colorScheme.onSurface.opacity60,
|
||||
),
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
height: globalState.measure.bodyLargeHeight + 24,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
height: globalState.measure.bodyLargeHeight + 24,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1100,9 +1100,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"tabAnimationDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Действительно только в мобильном виде",
|
||||
),
|
||||
"tapToAuthorize": MessageLookupByLibrary.simpleMessage(
|
||||
"Нажмите, чтобы разрешить",
|
||||
),
|
||||
"tapToAuthorize": MessageLookupByLibrary.simpleMessage("Разрешить"),
|
||||
"tcpConcurrent": MessageLookupByLibrary.simpleMessage("TCP параллелизм"),
|
||||
"tcpConcurrentDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Включение позволит использовать параллелизм TCP",
|
||||
|
||||
@@ -433,6 +433,10 @@ class SetupAction extends _$SetupAction {
|
||||
() async {
|
||||
final configFilePath = await appPath.configFilePath;
|
||||
await File(configFilePath).safeWriteAsString(yamlString);
|
||||
final profileId = profile?.id;
|
||||
if (profileId != null) {
|
||||
await appPath.ensureProviderDirs(profileId);
|
||||
}
|
||||
final message = await _core.setupConfig(
|
||||
params: _setupParams,
|
||||
preloadInvoke: preloadInvoke,
|
||||
|
||||
@@ -15,9 +15,7 @@ class StoreAction extends _$StoreAction {
|
||||
profileIds: profileIds,
|
||||
scriptIds: scriptIds,
|
||||
));
|
||||
await Future.wait(
|
||||
pathsToDelete.map((path) => File(path).safeDelete(recursive: true)),
|
||||
);
|
||||
await Future.wait(pathsToDelete.map(safeDeletePath));
|
||||
}
|
||||
|
||||
void savePreferencesDebounce() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/permission.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/profiles/overwrite/custom/widgets.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -23,6 +25,15 @@ class OnDemandView extends ConsumerStatefulWidget {
|
||||
|
||||
class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
with UniqueKeyStateMixin {
|
||||
static const _authorizeButtonPadding = 12.0;
|
||||
static const _minAuthorizeButtonWidth = 80.0;
|
||||
static const _inlineActionMaxWidthFactor = 0.4;
|
||||
static const _descMaxLines = 4;
|
||||
|
||||
/// Matches the horizontal content padding, so a stacked action keeps the same
|
||||
/// gap to the card edge as the text does.
|
||||
static const _prerequisiteVerticalPadding = 16.0;
|
||||
|
||||
bool get _isAndroid => widget.isAndroid ?? system.isAndroid;
|
||||
|
||||
bool get _isMacOS => widget.isMacOS ?? system.isMacOS;
|
||||
@@ -181,6 +192,23 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
ref.read(itemsProvider(key).notifier).value = {};
|
||||
}
|
||||
|
||||
double _authorizeButtonWidth() {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final style = context.textTheme.labelLarge;
|
||||
final labelWidth =
|
||||
[appLocalizations.tapToAuthorize, appLocalizations.authorized]
|
||||
.map(
|
||||
(label) => globalState.measure
|
||||
.computeTextSize(Text(label, style: style))
|
||||
.width,
|
||||
)
|
||||
.reduce(math.max);
|
||||
return math.max(
|
||||
_minAuthorizeButtonWidth,
|
||||
labelWidth + _authorizeButtonPadding * 2,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAuthorizeButton({
|
||||
required bool authorized,
|
||||
required VoidCallback onPressed,
|
||||
@@ -190,8 +218,10 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: authorized ? null : context.colorScheme.error,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(80, 40),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _authorizeButtonPadding,
|
||||
),
|
||||
minimumSize: const Size(_minAuthorizeButtonWidth, 40),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Stack(
|
||||
@@ -218,6 +248,54 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrerequisiteItem({
|
||||
required String title,
|
||||
required String desc,
|
||||
required Widget action,
|
||||
}) {
|
||||
return DecorationListItem(
|
||||
minVerticalPadding: _prerequisiteVerticalPadding,
|
||||
title: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final texts = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TooltipLabel(title),
|
||||
Text(
|
||||
desc,
|
||||
maxLines: _descMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final isInline =
|
||||
_authorizeButtonWidth() <=
|
||||
constraints.maxWidth * _inlineActionMaxWidthFactor;
|
||||
if (isInline) {
|
||||
return Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
Expanded(child: texts),
|
||||
action,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
texts,
|
||||
Align(alignment: Alignment.centerRight, child: action),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryOptimizationItem() {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final isStart = ref.watch(isStartProvider);
|
||||
@@ -225,11 +303,10 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
loadingProvider(LoadingTag.batteryOptimization),
|
||||
);
|
||||
final disabled = ref.watch(batteryOptimizationDisableProvider);
|
||||
return DecorationListItem(
|
||||
minVerticalPadding: 8,
|
||||
title: Text(appLocalizations.ignoreBatteryOptimization),
|
||||
subtitle: Text(appLocalizations.batteryOptimizationDesc),
|
||||
trailing: Stack(
|
||||
return _buildPrerequisiteItem(
|
||||
title: appLocalizations.ignoreBatteryOptimization,
|
||||
desc: appLocalizations.batteryOptimizationDesc,
|
||||
action: Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
Visibility(
|
||||
@@ -260,11 +337,10 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
(state) => state == WifiSsidPermission.granted,
|
||||
),
|
||||
);
|
||||
return DecorationListItem(
|
||||
minVerticalPadding: 8,
|
||||
title: Text(appLocalizations.locationPermission),
|
||||
subtitle: Text(appLocalizations.locationPermissionDesc),
|
||||
trailing: _buildAuthorizeButton(
|
||||
return _buildPrerequisiteItem(
|
||||
title: appLocalizations.locationPermission,
|
||||
desc: appLocalizations.locationPermissionDesc,
|
||||
action: _buildAuthorizeButton(
|
||||
authorized: granted,
|
||||
onPressed: _handleRequestLocationPermission,
|
||||
),
|
||||
|
||||
@@ -36,6 +36,7 @@ class OutboundMode extends ConsumerWidget {
|
||||
child: CommonCard(
|
||||
radius: AppCorner.lg,
|
||||
onPressed: () {},
|
||||
skipTraversal: true,
|
||||
info: Info(
|
||||
label: appLocalizations.outboundMode,
|
||||
iconData: Icons.call_split_sharp,
|
||||
|
||||
@@ -227,7 +227,8 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
}
|
||||
|
||||
Widget _buildItem({
|
||||
required Widget title,
|
||||
required String title,
|
||||
TextStyle? titleStyle,
|
||||
Widget? trailing,
|
||||
final VoidCallback? onPressed,
|
||||
bool invalid = false,
|
||||
@@ -236,6 +237,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
invalid: invalid,
|
||||
onPressed: onPressed,
|
||||
title: title,
|
||||
titleStyle: titleStyle,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
@@ -262,7 +264,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
);
|
||||
return _buildItem(
|
||||
invalid: invalid,
|
||||
title: Text(appLocalizations.selectProxyProviders),
|
||||
title: appLocalizations.selectProxyProviders,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 2,
|
||||
@@ -286,7 +288,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildFilterItem(String? filter) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.proxyFilter),
|
||||
title: appLocalizations.proxyFilter,
|
||||
trailing: TextFormField(
|
||||
textAlign: TextAlign.end,
|
||||
initialValue: filter,
|
||||
@@ -307,7 +309,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildMaxFailedTimesItem(int? maxFailedTimes) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.maxFailedTimes),
|
||||
title: appLocalizations.maxFailedTimes,
|
||||
trailing: TextFormField(
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.number),
|
||||
@@ -331,7 +333,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildUrlItem(String? url) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.testUrl),
|
||||
title: appLocalizations.testUrl,
|
||||
trailing: TextFormField(
|
||||
keyboardType: TextInputType.url,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
|
||||
@@ -353,7 +355,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildIntervalItem(int? interval) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.testInterval),
|
||||
title: appLocalizations.testInterval,
|
||||
trailing: TextFormField(
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.interval),
|
||||
@@ -375,7 +377,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildExcludeFilterItem(String? excludeFilter) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.excludeProxyFilter),
|
||||
title: appLocalizations.excludeProxyFilter,
|
||||
trailing: TextFormField(
|
||||
textAlign: TextAlign.end,
|
||||
initialValue: excludeFilter,
|
||||
@@ -396,7 +398,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildExcludeTypeItem(String? type) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.excludeType),
|
||||
title: appLocalizations.excludeType,
|
||||
trailing: TextFormField(
|
||||
textAlign: TextAlign.end,
|
||||
initialValue: type,
|
||||
@@ -417,7 +419,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildExpectedStatusItem(String? expectedStatus) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.expectedStatus),
|
||||
title: appLocalizations.expectedStatus,
|
||||
trailing: TextFormField(
|
||||
textAlign: TextAlign.end,
|
||||
initialValue: expectedStatus,
|
||||
@@ -445,7 +447,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
);
|
||||
return _buildItem(
|
||||
invalid: invalid,
|
||||
title: Text(appLocalizations.selectProxies),
|
||||
title: appLocalizations.selectProxies,
|
||||
trailing: Row(
|
||||
spacing: 2,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -469,7 +471,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildTypeItem(GroupType type) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.proxyType),
|
||||
title: appLocalizations.proxyType,
|
||||
onPressed: () {
|
||||
_showTypeOptions(type);
|
||||
},
|
||||
@@ -480,7 +482,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildIconItem(String? icon) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.icon),
|
||||
title: appLocalizations.icon,
|
||||
onPressed: () {
|
||||
_showIconEdit(icon);
|
||||
},
|
||||
@@ -500,7 +502,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
Widget _buildNameItem(String name) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.name),
|
||||
title: appLocalizations.name,
|
||||
trailing: TextFormField(
|
||||
initialValue: name,
|
||||
keyboardType: TextInputType.name,
|
||||
@@ -531,7 +533,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
}
|
||||
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.hideFromList),
|
||||
title: appLocalizations.hideFromList,
|
||||
onPressed: handleChangeHidden,
|
||||
trailing: Switch(
|
||||
value: hidden ?? false,
|
||||
@@ -551,7 +553,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
}
|
||||
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.testWhenUsed),
|
||||
title: appLocalizations.testWhenUsed,
|
||||
onPressed: handleChangeLazy,
|
||||
trailing: Switch(
|
||||
value: lazy ?? false,
|
||||
@@ -573,7 +575,7 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
}
|
||||
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.disableUDP),
|
||||
title: appLocalizations.disableUDP,
|
||||
onPressed: handleChangeDisableUDP,
|
||||
trailing: Switch(
|
||||
value: disableUDP ?? false,
|
||||
@@ -686,12 +688,8 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
|
||||
items: [
|
||||
if (id != -1)
|
||||
_buildItem(
|
||||
title: Text(
|
||||
appLocalizations.delete,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
),
|
||||
title: appLocalizations.delete,
|
||||
titleStyle: TextStyle(color: context.colorScheme.error),
|
||||
onPressed: () {
|
||||
_handleDelete(profileId);
|
||||
},
|
||||
|
||||
@@ -228,14 +228,14 @@ class _IncludeAllCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonCard(
|
||||
radius: AppCorner.md,
|
||||
radius: AppCorner.xl,
|
||||
type: CommonCardType.filled,
|
||||
child: ListItem.toggle(
|
||||
minTileHeight: 54,
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label),
|
||||
Flexible(child: TooltipLabel(label)),
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton(
|
||||
tooltip: context.appLocalizations.tip,
|
||||
|
||||
@@ -124,7 +124,8 @@ class _AddOrEditRuleView extends ConsumerStatefulWidget {
|
||||
|
||||
class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
Widget _buildItem({
|
||||
required Widget title,
|
||||
required String title,
|
||||
TextStyle? titleStyle,
|
||||
Widget? trailing,
|
||||
bool? invalid,
|
||||
final VoidCallback? onPressed,
|
||||
@@ -133,6 +134,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
invalid: invalid ?? false,
|
||||
onPressed: onPressed,
|
||||
title: title,
|
||||
titleStyle: titleStyle,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
@@ -165,7 +167,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
|
||||
Widget _buildTypeItem(RuleAction action) {
|
||||
return _buildItem(
|
||||
title: Text(context.appLocalizations.proxyType),
|
||||
title: context.appLocalizations.proxyType,
|
||||
onPressed: () {
|
||||
_handleSelectedType();
|
||||
},
|
||||
@@ -188,7 +190,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
Widget _buildContentItem(String? content) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.content),
|
||||
title: appLocalizations.content,
|
||||
trailing: TextFormField(
|
||||
initialValue: content,
|
||||
keyboardType: TextInputType.name,
|
||||
@@ -244,7 +246,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
Widget _buildRuleProviderItem(String? ruleProvider) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.ruleSet),
|
||||
title: appLocalizations.ruleSet,
|
||||
onPressed: _handleSelectedRuleProvider,
|
||||
trailing: Row(
|
||||
spacing: 4,
|
||||
@@ -325,7 +327,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
: context.colorScheme.onSurfaceVariant;
|
||||
return _buildItem(
|
||||
invalid: invalid,
|
||||
title: Text(appLocalizations.splitStrategy),
|
||||
title: appLocalizations.splitStrategy,
|
||||
onPressed: _handleSelectedTarget,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -403,7 +405,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
Widget _buildSubRuleItem(String? subRule) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.subRule),
|
||||
title: appLocalizations.subRule,
|
||||
onPressed: _handleSelectedSubRule,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -431,7 +433,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
Widget _buildNoResolveItem(bool? noResolve) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.noResolveHostname),
|
||||
title: appLocalizations.noResolveHostname,
|
||||
trailing: Switch(value: noResolve ?? false, onChanged: (_) {}),
|
||||
);
|
||||
}
|
||||
@@ -439,7 +441,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
Widget _buildSrcItem(bool? src) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return _buildItem(
|
||||
title: Text(appLocalizations.matchSourceIp),
|
||||
title: appLocalizations.matchSourceIp,
|
||||
trailing: Switch(value: src ?? false, onChanged: (_) {}),
|
||||
);
|
||||
}
|
||||
@@ -501,12 +503,8 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
|
||||
items: [
|
||||
if (rule.id != -1)
|
||||
_buildItem(
|
||||
title: Text(
|
||||
appLocalizations.delete,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
),
|
||||
title: appLocalizations.delete,
|
||||
titleStyle: TextStyle(color: context.colorScheme.error),
|
||||
onPressed: () {
|
||||
_handleDelete();
|
||||
},
|
||||
|
||||
@@ -104,11 +104,13 @@ class _ScriptOption extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Radio<int>(
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
toggleable: true,
|
||||
value: value,
|
||||
child: ExcludeFocus(
|
||||
child: Radio<int>(
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
toggleable: true,
|
||||
value: value,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
+91
-45
@@ -97,11 +97,13 @@ class CommonCard extends StatelessWidget {
|
||||
this.shape,
|
||||
this.isError = false,
|
||||
this.enterActionsOnRight = false,
|
||||
this.skipTraversal = false,
|
||||
required this.child,
|
||||
}) : isSelected = isSelected ?? false;
|
||||
|
||||
final bool enterAnimated;
|
||||
final bool enterActionsOnRight;
|
||||
final bool skipTraversal;
|
||||
final bool isSelected;
|
||||
final bool isError;
|
||||
final void Function()? onPressed;
|
||||
@@ -191,6 +193,60 @@ class CommonCard extends StatelessWidget {
|
||||
return colorScheme.primary;
|
||||
}
|
||||
|
||||
Widget _buildButton(
|
||||
BuildContext context,
|
||||
Widget childWidget,
|
||||
FocusNode? focusNode,
|
||||
) {
|
||||
return switch (type == CommonCardType.filled) {
|
||||
true => FilledButton(
|
||||
focusNode: focusNode,
|
||||
onLongPress: onLongPress,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
|
||||
iconSize: 20,
|
||||
iconColor: _buildIconColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
side: BorderSide.none,
|
||||
elevation: 0,
|
||||
).copyWith(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
_buildBackgroundColor(context),
|
||||
),
|
||||
side: WidgetStateProperty.resolveWith(
|
||||
(states) => _buildBorderSide(context, states),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: childWidget,
|
||||
),
|
||||
false => OutlinedButton(
|
||||
focusNode: focusNode,
|
||||
onLongPress: onLongPress,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
style:
|
||||
OutlinedButton.styleFrom(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
|
||||
iconSize: 20,
|
||||
iconColor: _buildIconColor(context),
|
||||
backgroundColor: _buildBackgroundColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
elevation: 0,
|
||||
).copyWith(
|
||||
side: WidgetStateProperty.resolveWith(
|
||||
(states) => _buildBorderSide(context, states),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: childWidget,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var childWidget = child;
|
||||
@@ -215,51 +271,12 @@ class CommonCard extends StatelessWidget {
|
||||
childWidget = Stack(children: children);
|
||||
}
|
||||
|
||||
final button = switch (type == CommonCardType.filled) {
|
||||
true => FilledButton(
|
||||
onLongPress: onLongPress,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
|
||||
iconSize: 20,
|
||||
iconColor: _buildIconColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
side: BorderSide.none,
|
||||
elevation: 0,
|
||||
).copyWith(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
_buildBackgroundColor(context),
|
||||
),
|
||||
side: WidgetStateProperty.resolveWith(
|
||||
(states) => _buildBorderSide(context, states),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: childWidget,
|
||||
),
|
||||
false => OutlinedButton(
|
||||
onLongPress: onLongPress,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
style:
|
||||
OutlinedButton.styleFrom(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
|
||||
iconSize: 20,
|
||||
iconColor: _buildIconColor(context),
|
||||
backgroundColor: _buildBackgroundColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
elevation: 0,
|
||||
).copyWith(
|
||||
side: WidgetStateProperty.resolveWith(
|
||||
(states) => _buildBorderSide(context, states),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: childWidget,
|
||||
),
|
||||
};
|
||||
final button = skipTraversal
|
||||
? _SkipTraversalScope(
|
||||
builder: (focusNode) =>
|
||||
_buildButton(context, childWidget, focusNode),
|
||||
)
|
||||
: _buildButton(context, childWidget, null);
|
||||
final card = !enterActionsOnRight
|
||||
? button
|
||||
: Focus(
|
||||
@@ -290,6 +307,35 @@ class CommonCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SkipTraversalFocusNode extends FocusNode {
|
||||
@override
|
||||
bool get skipTraversal => true;
|
||||
}
|
||||
|
||||
class _SkipTraversalScope extends StatefulWidget {
|
||||
const _SkipTraversalScope({required this.builder});
|
||||
|
||||
final Widget Function(FocusNode focusNode) builder;
|
||||
|
||||
@override
|
||||
State<_SkipTraversalScope> createState() => _SkipTraversalScopeState();
|
||||
}
|
||||
|
||||
class _SkipTraversalScopeState extends State<_SkipTraversalScope> {
|
||||
final FocusNode _focusNode = _SkipTraversalFocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(_focusNode);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectIcon extends StatelessWidget {
|
||||
const SelectIcon({super.key});
|
||||
|
||||
|
||||
+13
-6
@@ -464,11 +464,13 @@ class ListItem<T> extends StatelessWidget {
|
||||
final radioDelegate = radio as _RadioAction<T>;
|
||||
return _buildListTile(
|
||||
onTap: radioDelegate.onTap,
|
||||
leading: Radio<T>(
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
value: radioDelegate.value,
|
||||
toggleable: true,
|
||||
leading: ExcludeFocus(
|
||||
child: Radio<T>(
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
value: radioDelegate.value,
|
||||
toggleable: true,
|
||||
),
|
||||
),
|
||||
trailing: trailing,
|
||||
);
|
||||
@@ -502,7 +504,10 @@ class ListHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 36,
|
||||
crossAxisAlignment: subTitle == null
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.start,
|
||||
spacing: actions.isEmpty ? 0 : 12,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -510,6 +515,8 @@ class ListHeader extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant.opacity80,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -32,6 +32,26 @@ class TooltipText extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class TooltipLabel extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final int maxLines;
|
||||
|
||||
const TooltipLabel(this.text, {super.key, this.style, this.maxLines = 2});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TooltipText(
|
||||
text: Text(
|
||||
text,
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DefaultTextStyle.of(context).style.merge(style),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TooltipTextV2 extends StatefulWidget {
|
||||
final Text text;
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ void main() {
|
||||
writeFile(join(profiles.path, '2.yaml'), 'drop');
|
||||
writeFile(join(scripts.path, '10.js'), 'keep');
|
||||
writeFile(join(scripts.path, '20.js'), 'drop');
|
||||
writeFile(join(providers.path, '1'), 'keep');
|
||||
writeFile(join(providers.path, '2'), 'drop');
|
||||
writeFile(join(providers.path, '1', 'proxies', 'abc'), 'keep');
|
||||
writeFile(join(providers.path, '2', 'proxies', 'abc'), 'drop');
|
||||
|
||||
final orphans = shakeOrphanFiles(
|
||||
profileIds: [1],
|
||||
@@ -83,6 +83,33 @@ void main() {
|
||||
);
|
||||
|
||||
expect(orphans.map(basename), unorderedEquals(['2.yaml', '20.js', '2']));
|
||||
expect(
|
||||
orphans.singleWhere((path) => basename(path) == '2'),
|
||||
join(providers.path, '2'),
|
||||
);
|
||||
});
|
||||
|
||||
test('reports the whole provider directory a dead profile left behind', () {
|
||||
final providers = makeDir('providers');
|
||||
writeFile(join(providers.path, '3', 'proxies', 'abc'), 'drop');
|
||||
writeFile(join(providers.path, '3', 'rules', 'def'), 'drop');
|
||||
writeFile(join(providers.path, 'stray'), 'drop');
|
||||
|
||||
final orphans = shakeOrphanFiles(
|
||||
profileIds: const [],
|
||||
scriptIds: const [],
|
||||
profilesDirPath: join(root.path, 'missing'),
|
||||
providersDirPath: providers.path,
|
||||
scriptsDirPath: join(root.path, 'missing'),
|
||||
);
|
||||
|
||||
expect(
|
||||
orphans,
|
||||
unorderedEquals([
|
||||
join(providers.path, '3'),
|
||||
join(providers.path, 'stray'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('treats an unparsable file name as an orphan', () {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
|
||||
setUp(() {
|
||||
root = Directory.systemTemp.createTempSync('file_test');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('safeDeletePath', () {
|
||||
test('removes a file', () async {
|
||||
final path = join(root.path, 'config.yaml');
|
||||
File(path).writeAsStringSync('mixed-port: 7890');
|
||||
|
||||
await safeDeletePath(path);
|
||||
|
||||
expect(File(path).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('removes a directory along with everything under it', () async {
|
||||
final path = join(root.path, '42');
|
||||
File(join(path, 'proxies', 'abc'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('proxies: []');
|
||||
|
||||
await safeDeletePath(path);
|
||||
|
||||
expect(Directory(path).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('accepts a path that is already gone', () async {
|
||||
await expectLater(safeDeletePath(join(root.path, 'missing')), completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform {
|
||||
_FakePathProvider(this.root);
|
||||
|
||||
final String root;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => root;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory root;
|
||||
|
||||
setUpAll(() {
|
||||
root = Directory.systemTemp.createTempSync('path_test');
|
||||
PathProviderPlatform.instance = _FakePathProvider(root.path);
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('provider directories match the paths handed to the core', () async {
|
||||
const proxiesUrl = 'https://example.com/a.yaml';
|
||||
const rulesUrl = 'https://example.com/b.yaml';
|
||||
final proxiesDir = await appPath.getProviderDirPath(
|
||||
7,
|
||||
proxiesProviderDirectoryName,
|
||||
);
|
||||
final rulesDir = await appPath.getProviderDirPath(
|
||||
7,
|
||||
rulesProviderDirectoryName,
|
||||
);
|
||||
|
||||
final result = await makeRealProfileTask(
|
||||
MakeRealProfileState(
|
||||
profilesPath: await appPath.profilesPath,
|
||||
profileId: 7,
|
||||
rawConfig: {
|
||||
'proxy-providers': {
|
||||
'a': {'type': 'http', 'url': proxiesUrl},
|
||||
},
|
||||
'rule-providers': {
|
||||
'b': {'type': 'http', 'url': rulesUrl},
|
||||
},
|
||||
},
|
||||
realPatchConfig: const PatchClashConfig(),
|
||||
overrideDns: false,
|
||||
appendSystemDns: false,
|
||||
proxyGroups: const [],
|
||||
rules: const [],
|
||||
addedRules: const [],
|
||||
defaultUA: 'FlClash',
|
||||
),
|
||||
);
|
||||
final config = loadYaml(result.yaml) as YamlMap;
|
||||
|
||||
expect(
|
||||
config['proxy-providers']['a']['path'],
|
||||
join(proxiesDir, proxiesUrl.toMd5()),
|
||||
);
|
||||
expect(
|
||||
config['rule-providers']['b']['path'],
|
||||
join(rulesDir, rulesUrl.toMd5()),
|
||||
);
|
||||
});
|
||||
|
||||
test('confines a provider path the profile tried to choose', () async {
|
||||
final proxiesDir = await appPath.getProviderDirPath(
|
||||
7,
|
||||
proxiesProviderDirectoryName,
|
||||
);
|
||||
final rulesDir = await appPath.getProviderDirPath(
|
||||
7,
|
||||
rulesProviderDirectoryName,
|
||||
);
|
||||
|
||||
final result = await makeRealProfileTask(
|
||||
MakeRealProfileState(
|
||||
profilesPath: await appPath.profilesPath,
|
||||
profileId: 7,
|
||||
rawConfig: {
|
||||
'proxy-providers': {
|
||||
'escape': {'type': 'file', 'path': '../../../../config.yaml'},
|
||||
'urlless': {'type': 'http', 'path': 'cache.db'},
|
||||
'literal': {
|
||||
'type': 'inline',
|
||||
'payload': ['DIRECT'],
|
||||
},
|
||||
},
|
||||
'rule-providers': {
|
||||
'sneak': {'type': 'file', 'path': '/etc/hosts'},
|
||||
},
|
||||
},
|
||||
realPatchConfig: const PatchClashConfig(),
|
||||
overrideDns: false,
|
||||
appendSystemDns: false,
|
||||
proxyGroups: const [],
|
||||
rules: const [],
|
||||
addedRules: const [],
|
||||
defaultUA: 'FlClash',
|
||||
),
|
||||
);
|
||||
final config = loadYaml(result.yaml) as YamlMap;
|
||||
|
||||
expect(
|
||||
config['proxy-providers']['escape']['path'],
|
||||
join(proxiesDir, 'proxy-providers/escape'.toMd5()),
|
||||
);
|
||||
expect(
|
||||
config['proxy-providers']['urlless']['path'],
|
||||
join(proxiesDir, 'proxy-providers/urlless'.toMd5()),
|
||||
);
|
||||
expect(config['proxy-providers']['literal']['path'], isNull);
|
||||
expect(
|
||||
config['rule-providers']['sneak']['path'],
|
||||
join(rulesDir, 'rule-providers/sneak'.toMd5()),
|
||||
);
|
||||
});
|
||||
|
||||
test('survives a provider section that is not a map', () async {
|
||||
final result = await makeRealProfileTask(
|
||||
MakeRealProfileState(
|
||||
profilesPath: await appPath.profilesPath,
|
||||
profileId: 7,
|
||||
rawConfig: {
|
||||
'proxy-providers': ['not-a-map'],
|
||||
'rule-providers': {
|
||||
'broken': ['also-not-a-map'],
|
||||
},
|
||||
},
|
||||
realPatchConfig: const PatchClashConfig(),
|
||||
overrideDns: false,
|
||||
appendSystemDns: false,
|
||||
proxyGroups: const [],
|
||||
rules: const [],
|
||||
addedRules: const [],
|
||||
defaultUA: 'FlClash',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.yaml, isNotEmpty);
|
||||
});
|
||||
|
||||
test('ensureProviderDirs creates both provider directories', () async {
|
||||
await appPath.ensureProviderDirs(9);
|
||||
|
||||
for (final type in const [
|
||||
proxiesProviderDirectoryName,
|
||||
rulesProviderDirectoryName,
|
||||
]) {
|
||||
expect(
|
||||
Directory(await appPath.getProviderDirPath(9, type)).existsSync(),
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
|
||||
await expectLater(appPath.ensureProviderDirs(9), completes);
|
||||
});
|
||||
}
|
||||
@@ -64,7 +64,8 @@ class _FakeProcesses {
|
||||
.firstWhere((run) => run.key == key || run.executable == key)
|
||||
.arguments;
|
||||
|
||||
bool ran(String key) => runs.any((run) => run.key == key);
|
||||
bool ran(String key) =>
|
||||
runs.any((run) => run.key == key || run.executable == key);
|
||||
}
|
||||
|
||||
const _routeOutput = '''
|
||||
@@ -141,6 +142,74 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('aclArguments', () {
|
||||
test('grants the inheriting user access to the whole tree', () {
|
||||
final arguments = System.aclArguments('/Users/a/Support', 'alice');
|
||||
|
||||
expect(arguments.first, '-R');
|
||||
expect(arguments[1], '+a');
|
||||
expect(arguments[2], startsWith('user:alice allow '));
|
||||
expect(arguments.last, '/Users/a/Support');
|
||||
expect(
|
||||
arguments[2].split(' allow ').last.split(','),
|
||||
unorderedEquals(const [
|
||||
'list',
|
||||
'search',
|
||||
'add_file',
|
||||
'add_subdirectory',
|
||||
'delete',
|
||||
'delete_child',
|
||||
'file_inherit',
|
||||
'directory_inherit',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('never hands out ownership or the ACL itself', () {
|
||||
final arguments = System.aclArguments('/Users/a/Support', 'alice');
|
||||
|
||||
expect(arguments[2], isNot(contains('writesecurity')));
|
||||
expect(arguments[2], isNot(contains('chown')));
|
||||
});
|
||||
|
||||
test('passes a path containing spaces through untouched', () {
|
||||
const path = '/Users/a b/Library/Application Support/com.follow.clash';
|
||||
|
||||
final arguments = System.aclArguments(path, 'alice');
|
||||
|
||||
expect(arguments.last, path);
|
||||
expect(arguments.last, isNot(contains(r'\')));
|
||||
});
|
||||
});
|
||||
|
||||
group('grantHomeDirAccess', () {
|
||||
test('only touches the filesystem on macOS', () async {
|
||||
await system.grantHomeDirAccess('/Users/a/Support');
|
||||
|
||||
final userName = Platform.environment['USER'];
|
||||
expect(
|
||||
processes.ran('chmod'),
|
||||
system.isMacOS && userName != null && userName.isNotEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('survives a chmod that cannot apply an ACL', () async {
|
||||
processes.stub('chmod', '', exitCode: 1);
|
||||
|
||||
await expectLater(
|
||||
system.grantHomeDirAccess('/Users/a/Support'),
|
||||
completes,
|
||||
);
|
||||
|
||||
processes.stubThrow('chmod');
|
||||
|
||||
await expectLater(
|
||||
system.grantHomeDirAccess('/Users/a/Support'),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isPrivilegedStatOutput', () {
|
||||
test('accepts a root-owned setuid binary', () {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/dashboard/widgets/outbound_mode.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../helpers/test_app.dart';
|
||||
import '../../helpers/test_profiles.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
container = ProviderContainer(
|
||||
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
|
||||
);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1200, 1000);
|
||||
});
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
Widget neighbor(String label) {
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
child: CommonCard(onPressed: () {}, child: Text(label)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpDashboard(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: TestApp(
|
||||
child: Scaffold(
|
||||
body: FocusTraversalGroup(
|
||||
policy: PageTraversalPolicy(),
|
||||
child: PageFocusScope(
|
||||
child: Column(
|
||||
children: [
|
||||
neighbor('above'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: neighbor('left')),
|
||||
const Expanded(child: OutboundMode()),
|
||||
Expanded(child: neighbor('right')),
|
||||
],
|
||||
),
|
||||
neighbor('below'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
String? focusedLabel() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
final texts = find
|
||||
.descendant(
|
||||
of: find.byWidget(context.widget),
|
||||
matching: find.byType(Text),
|
||||
)
|
||||
.evaluate()
|
||||
.map((element) => (element.widget as Text).data)
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
if (texts.isEmpty) {
|
||||
return '<unlabelled ${context.widget.runtimeType}>';
|
||||
}
|
||||
return texts.length == 1 ? texts.single : texts.join('+');
|
||||
}
|
||||
|
||||
Future<void> focusNeighbor(WidgetTester tester, String label) async {
|
||||
for (var i = 0; i < 12 && focusedLabel() != label; i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(focusedLabel(), label, reason: 'seed focus on the $label card');
|
||||
}
|
||||
|
||||
Future<List<String?>> walk(
|
||||
WidgetTester tester,
|
||||
LogicalKeyboardKey key, {
|
||||
int steps = 6,
|
||||
}) async {
|
||||
final trail = <String?>[];
|
||||
for (var i = 0; i < steps; i++) {
|
||||
await tester.sendKeyEvent(key);
|
||||
await tester.pump();
|
||||
trail.add(focusedLabel());
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
testWidgets('arrow down walks every mode and leaves the card', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpDashboard(tester);
|
||||
await focusNeighbor(tester, 'above');
|
||||
|
||||
final trail = await walk(tester, LogicalKeyboardKey.arrowDown);
|
||||
|
||||
expect(
|
||||
trail.take(4),
|
||||
<String>['Rule', 'Global', 'Direct', 'below'],
|
||||
reason:
|
||||
'arrow down should step through the modes without the card itself '
|
||||
'taking a turn, got $trail',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('arrow right enters a mode row and leaves through it', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpDashboard(tester);
|
||||
await focusNeighbor(tester, 'left');
|
||||
|
||||
final trail = await walk(tester, LogicalKeyboardKey.arrowRight, steps: 3);
|
||||
|
||||
expect(
|
||||
trail.first,
|
||||
anyOf('Rule', 'Global', 'Direct'),
|
||||
reason: 'arrow right should land on a mode row, got $trail',
|
||||
);
|
||||
expect(
|
||||
trail,
|
||||
contains('right'),
|
||||
reason: 'arrow right must keep going past the card, got $trail',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('navigating the card does not change the mode', (tester) async {
|
||||
await pumpDashboard(tester);
|
||||
await focusNeighbor(tester, 'left');
|
||||
|
||||
final modes = <Mode>[];
|
||||
for (final key in <LogicalKeyboardKey>[
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
LogicalKeyboardKey.arrowDown,
|
||||
LogicalKeyboardKey.arrowDown,
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
]) {
|
||||
await tester.sendKeyEvent(key);
|
||||
await tester.pump();
|
||||
modes.add(container.read(patchClashConfigProvider).mode);
|
||||
}
|
||||
|
||||
expect(
|
||||
modes,
|
||||
everyElement(Mode.rule),
|
||||
reason: 'arrow keys must move focus, never the selection',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('activating a mode row selects it', (tester) async {
|
||||
await pumpDashboard(tester);
|
||||
await focusNeighbor(tester, 'above');
|
||||
|
||||
for (var i = 0; i < 6 && focusedLabel() != 'Global'; i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(focusedLabel(), 'Global');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(container.read(patchClashConfigProvider).mode, Mode.global);
|
||||
});
|
||||
}
|
||||
@@ -37,8 +37,10 @@ void main() {
|
||||
WifiSsidPermission permission = WifiSsidPermission.denied,
|
||||
bool isAndroid = false,
|
||||
bool isMacOS = false,
|
||||
Locale? locale,
|
||||
Size size = const Size(1400, 1000),
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
@@ -58,6 +60,7 @@ void main() {
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: TestApp(
|
||||
locale: locale,
|
||||
child: OnDemandView(isAndroid: isAndroid, isMacOS: isMacOS),
|
||||
),
|
||||
),
|
||||
@@ -164,6 +167,72 @@ void main() {
|
||||
expect(find.text('Location Permission'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a narrow row in a verbose locale keeps its text readable', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpView(
|
||||
tester,
|
||||
isMacOS: true,
|
||||
locale: const Locale('ru'),
|
||||
size: const Size(360, 800),
|
||||
);
|
||||
|
||||
final rowWidth = tester
|
||||
.getSize(find.byType(DecorationListItem).first)
|
||||
.width;
|
||||
final descWidth = tester
|
||||
.getSize(
|
||||
find.text(
|
||||
'По требованию системы для получения имени сети Wi-Fi необходимо '
|
||||
'разрешение на геолокацию.',
|
||||
),
|
||||
)
|
||||
.width;
|
||||
|
||||
expect(
|
||||
descWidth,
|
||||
greaterThan(rowWidth * 0.5),
|
||||
reason: 'the authorize button must not squeeze the description',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an action too wide for its row moves under the text', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpView(
|
||||
tester,
|
||||
isMacOS: true,
|
||||
locale: const Locale('ru'),
|
||||
size: const Size(260, 800),
|
||||
);
|
||||
|
||||
final desc = find.text(
|
||||
'По требованию системы для получения имени сети Wi-Fi необходимо '
|
||||
'разрешение на геолокацию.',
|
||||
);
|
||||
|
||||
final card = tester.getRect(find.byType(DecorationListItem).first);
|
||||
final button = tester.getRect(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('Разрешить'),
|
||||
matching: find.byType(FilledButton),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
expect(
|
||||
button.top,
|
||||
greaterThanOrEqualTo(tester.getBottomLeft(desc).dy),
|
||||
reason: 'the button stacks under the text instead of squeezing it',
|
||||
);
|
||||
expect(
|
||||
card.bottom - button.bottom,
|
||||
card.right - button.right,
|
||||
reason: 'a stacked button keeps the same gap to the card as the text',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a desktop that is not macOS asks for no prerequisite', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user