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:
chen08209
2026-08-27 11:40:46 +08:00
parent 576b28720f
commit 5589c76921
30 changed files with 1372 additions and 183 deletions
+1 -1
View File
@@ -546,7 +546,7 @@
"addSsid": "Добавить SSID",
"editSsid": "Изменить SSID",
"authorized": "Разрешено",
"tapToAuthorize": "Нажмите, чтобы разрешить",
"tapToAuthorize": "Разрешить",
"suspended": "Приостановлено...",
"geoOptions": "Настройки Geo",
"geoAutoUpdate": "Автообновление",
+3
View File
@@ -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,
+7
View File
@@ -0,0 +1,7 @@
//go:build !(darwin || linux) || android
package main
func initOwnership(homeDir string) {}
func scheduleReclaimOwnership() {}
+201
View File
@@ -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")
}
}
+159
View File
@@ -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)
}()
}
+3
View File
@@ -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';
+8
View File
@@ -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
View File
@@ -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 {
+41
View File
@@ -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
View File
@@ -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;
}
+7 -1
View File
@@ -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));
+47 -20
View File
@@ -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,
),
),
),
],
],
);
},
),
);
}
+1 -3
View File
@@ -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",
+4
View File
@@ -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,
+1 -3
View File
@@ -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() {
+88 -12
View File
@@ -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,
+20 -22
View File
@@ -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,
+12 -14
View File
@@ -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();
},
+7 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+20
View File
@@ -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;
+29 -2
View File
@@ -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', () {
+45
View File
@@ -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);
});
});
}
+178
View File
@@ -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);
});
}
+70 -1
View File
@@ -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);
});
}
+70 -1
View File
@@ -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 {