fix: stabilize iOS login storage and mobile settings

This commit is contained in:
Haitao Pan 2026-05-25 09:43:57 +08:00
parent 88fa597c8e
commit 22c49c2e37
6 changed files with 958 additions and 7 deletions

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../features/assistant/assistant_page.dart';
import '../features/mobile/mobile_assistant_page.dart';
import '../features/mobile/mobile_settings_page.dart';
import '../features/settings/settings_page.dart';
import '../models/app_models.dart';
import 'app_controller.dart';
@ -64,10 +65,7 @@ final Map<WorkspaceDestination, WorkspacePageSpec> workspacePageSpecsInternal =
initialTab: controller.settingsTab,
),
mobileBuilder: (controller, onOpenDetail, mobileActions) =>
SettingsPage(
controller: controller,
initialTab: controller.settingsTab,
),
MobileSettingsPage(controller: controller),
),
};

View File

@ -0,0 +1,634 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/runtime_controllers.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import 'mobile_settings_page_widgets.dart';
class MobileSettingsPage extends StatefulWidget {
const MobileSettingsPage({super.key, required this.controller});
final AppController controller;
@override
State<MobileSettingsPage> createState() => _MobileSettingsPageState();
}
class _MobileSettingsPageState extends State<MobileSettingsPage> {
late final TextEditingController accountBaseUrlController;
late final TextEditingController accountIdentifierController;
late final TextEditingController accountPasswordController;
late final TextEditingController accountMfaCodeController;
late final TextEditingController bridgeUrlController;
late final TextEditingController bridgeTokenController;
String lastSavedAccountBaseUrl = '';
String lastSavedAccountIdentifier = '';
String lastSavedBridgeUrl = '';
@override
void initState() {
super.initState();
final settings = widget.controller.settings;
lastSavedAccountBaseUrl = settings.accountBaseUrl;
lastSavedAccountIdentifier = settings.accountUsername;
lastSavedBridgeUrl =
settings.acpBridgeServerModeConfig.selfHosted.serverUrl;
accountBaseUrlController = TextEditingController(
text: lastSavedAccountBaseUrl,
);
accountIdentifierController = TextEditingController(
text: lastSavedAccountIdentifier,
);
accountPasswordController = TextEditingController();
accountMfaCodeController = TextEditingController();
bridgeUrlController = TextEditingController(text: lastSavedBridgeUrl);
bridgeTokenController = TextEditingController();
unawaited(loadBridgeToken());
}
@override
void dispose() {
accountBaseUrlController.dispose();
accountIdentifierController.dispose();
accountPasswordController.dispose();
accountMfaCodeController.dispose();
bridgeUrlController.dispose();
bridgeTokenController.dispose();
super.dispose();
}
Future<void> loadBridgeToken() async {
final token = await widget.controller.settingsController
.loadSecretValueByRef(
widget
.controller
.settings
.acpBridgeServerModeConfig
.selfHosted
.passwordRef,
);
if (!mounted) {
return;
}
bridgeTokenController.text = token;
}
void syncControllers(SettingsSnapshot settings) {
if (accountBaseUrlController.text == lastSavedAccountBaseUrl &&
settings.accountBaseUrl != lastSavedAccountBaseUrl) {
accountBaseUrlController.text = settings.accountBaseUrl;
}
if (accountIdentifierController.text == lastSavedAccountIdentifier &&
settings.accountUsername != lastSavedAccountIdentifier) {
accountIdentifierController.text = settings.accountUsername;
}
lastSavedAccountBaseUrl = settings.accountBaseUrl;
lastSavedAccountIdentifier = settings.accountUsername;
final bridgeConfig = settings.acpBridgeServerModeConfig;
if (bridgeUrlController.text == lastSavedBridgeUrl &&
bridgeConfig.selfHosted.serverUrl != lastSavedBridgeUrl) {
bridgeUrlController.text = bridgeConfig.selfHosted.serverUrl;
}
lastSavedBridgeUrl = bridgeConfig.selfHosted.serverUrl;
}
Future<void> persistAccountProfileSettings({
required SettingsSnapshot settings,
required bool isManualBridge,
}) async {
final bridgeConfig = settings.acpBridgeServerModeConfig;
final nextBridgeConfig = bridgeConfig.copyWith(
selfHosted: bridgeConfig.selfHosted.copyWith(
serverUrl: bridgeUrlController.text.trim(),
username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username,
),
);
final nextEffective = widget.controller.settingsController
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
final nextSettings = settings.copyWith(
accountBaseUrl: accountBaseUrlController.text.trim(),
accountUsername: accountIdentifierController.text.trim(),
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
effective: nextEffective,
),
);
if (isManualBridge && bridgeTokenController.text.isNotEmpty) {
await widget.controller.settingsController.saveSecretValueByRef(
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
bridgeTokenController.text,
provider: 'Bridge',
module: 'Manual',
);
}
await widget.controller.saveSettings(nextSettings);
lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
lastSavedAccountIdentifier = nextSettings.accountUsername;
lastSavedBridgeUrl =
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
}
Future<void> loginAccount(SettingsSnapshot settings) async {
try {
await persistAccountProfileSettings(
settings: settings,
isManualBridge: false,
);
await widget.controller.settingsController.loginAccount(
baseUrl: accountBaseUrlController.text.trim(),
identifier: accountIdentifierController.text.trim(),
password: accountPasswordController.text,
);
await refreshBridgeCapabilities();
} finally {
accountPasswordController.clear();
}
}
Future<void> syncAccount(SettingsSnapshot settings) async {
await persistAccountProfileSettings(
settings: settings,
isManualBridge: false,
);
await widget.controller.settingsController.syncAccountSettings(
baseUrl: accountBaseUrlController.text.trim(),
);
await refreshBridgeCapabilities();
}
Future<void> verifyMfa(SettingsSnapshot settings) async {
try {
await persistAccountProfileSettings(
settings: settings,
isManualBridge: false,
);
await widget.controller.settingsController.verifyAccountMfa(
baseUrl: accountBaseUrlController.text.trim(),
code: accountMfaCodeController.text.trim(),
);
await refreshBridgeCapabilities();
} finally {
accountMfaCodeController.clear();
}
}
Future<void> refreshBridgeCapabilities() async {
final dynamic controller = widget.controller;
try {
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
} catch (_) {
// Account login should not fail only because runtime refresh is transient.
}
try {
await controller.refreshAcpCapabilitiesInternal(forceRefresh: true);
} catch (_) {
// Runtime capabilities can be refreshed again from Assistant.
}
}
Future<void> logoutAccount() async {
await widget.controller.settingsController.logoutAccount();
accountPasswordController.clear();
accountMfaCodeController.clear();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge(<Listenable>[
widget.controller,
widget.controller.settingsController,
]),
builder: (context, _) {
final controller = widget.controller;
final settings = controller.settings;
syncControllers(settings);
final features = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final availableTabs = features.availableSettingsTabs;
final currentTab = availableTabs.contains(controller.settingsTab)
? controller.settingsTab
: SettingsTab.gateway;
final palette = context.palette;
final bottomPadding = MediaQuery.viewPaddingOf(context).bottom + 16;
return ColoredBox(
key: const Key('mobile-settings-page'),
color: palette.canvas,
child: CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 14, 16, bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('设置', 'Settings'),
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
if (availableTabs.length > 1) ...[
MobileSettingsTabSelectorInternal(
currentTab: currentTab,
availableTabs: availableTabs,
onChanged: (tab) => controller.openSettings(tab: tab),
),
const SizedBox(height: 12),
],
if (currentTab == SettingsTab.archivedTasks)
_ArchivedTasksSection(controller: controller)
else
_AccountSection(
settings: settings,
accountSession:
controller.settingsController.accountSession,
accountState:
controller.settingsController.accountSyncState,
accountBusy:
controller.settingsController.accountBusy,
accountStatus:
controller.settingsController.accountStatus,
accountSignedIn:
controller.settingsController.accountSignedIn,
accountMfaRequired:
controller.settingsController.accountMfaRequired,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController:
accountIdentifierController,
accountPasswordController: accountPasswordController,
accountMfaCodeController: accountMfaCodeController,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onLogin: () => loginAccount(settings),
onVerifyMfa: () => verifyMfa(settings),
onCancelMfa: () async {
await controller.settingsController
.cancelAccountMfaChallenge();
accountPasswordController.clear();
accountMfaCodeController.clear();
},
onSync: () => syncAccount(settings),
onLogout: logoutAccount,
onSaveManualBridge: () =>
persistAccountProfileSettings(
settings: settings,
isManualBridge: true,
),
),
],
),
),
),
],
),
);
},
);
}
}
class _AccountSection extends StatelessWidget {
const _AccountSection({
required this.settings,
required this.accountSession,
required this.accountState,
required this.accountBusy,
required this.accountStatus,
required this.accountSignedIn,
required this.accountMfaRequired,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountPasswordController,
required this.accountMfaCodeController,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onLogin,
required this.onVerifyMfa,
required this.onCancelMfa,
required this.onSync,
required this.onLogout,
required this.onSaveManualBridge,
});
final SettingsSnapshot settings;
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final bool accountSignedIn;
final bool accountMfaRequired;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountPasswordController;
final TextEditingController accountMfaCodeController;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onLogin;
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
final Future<void> Function() onSaveManualBridge;
@override
Widget build(BuildContext context) {
if (accountMfaRequired) {
return MobileSettingsCardInternal(
key: const Key('mobile-settings-mfa-card'),
icon: Icons.verified_user_outlined,
title: appText('双重验证', 'Multi-Factor Authentication'),
subtitle: appText(
'输入验证码完成登录并同步托管 Bridge。',
'Enter the code to finish sign-in and sync the managed Bridge.',
),
children: [
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-mfa-code-field'),
controller: accountMfaCodeController,
label: appText('验证码', 'Code'),
icon: Icons.key_outlined,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onVerifyMfa(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton(
key: const Key('mobile-settings-account-mfa-verify-button'),
onPressed: accountBusy ? null : onVerifyMfa,
child: Text(appText('验证', 'Verify')),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.tonal(
key: const Key('mobile-settings-account-mfa-cancel-button'),
onPressed: accountBusy ? null : onCancelMfa,
child: Text(appText('返回', 'Back')),
),
),
],
),
],
);
}
if (accountSignedIn) {
final email = accountSession?.email.trim().isNotEmpty == true
? accountSession!.email.trim()
: settings.accountUsername.trim();
final status = accountState?.syncMessage.trim().isNotEmpty == true
? accountState!.syncMessage.trim()
: accountStatus.trim();
return Column(
children: [
MobileSettingsCardInternal(
key: const Key('mobile-settings-account-signed-in-card'),
icon: Icons.cloud_done_outlined,
title: email.isEmpty ? appText('已登录', 'Signed In') : email,
subtitle: status.isEmpty
? appText('svc.plus 托管 Bridge 已就绪。', 'Managed Bridge is ready.')
: status,
children: [
MobileSettingsMetaRowInternal(
icon: Icons.hub_outlined,
label: appText('托管入口', 'Managed Endpoint'),
value: kManagedBridgeServerUrl,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
key: const Key('mobile-settings-account-sync-button'),
onPressed: accountBusy ? null : onSync,
icon: const Icon(Icons.sync_rounded),
label: Text(appText('同步', 'Sync')),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.tonalIcon(
key: const Key('mobile-settings-account-logout-button'),
onPressed: accountBusy ? null : onLogout,
icon: const Icon(Icons.logout_rounded),
label: Text(appText('退出', 'Sign Out')),
),
),
],
),
],
),
const SizedBox(height: 12),
_ManualBridgeCard(
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveManualBridge: onSaveManualBridge,
),
],
);
}
return Column(
children: [
MobileSettingsCardInternal(
key: const Key('mobile-settings-account-login-card'),
icon: Icons.cloud_outlined,
title: appText('svc.plus 登录', 'svc.plus Sign In'),
subtitle: appText(
'登录后同步托管 Bridge助手会直接使用统一入口。',
'Sign in to sync the managed Bridge for Assistant.',
),
children: [
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-base-url-field'),
controller: accountBaseUrlController,
label: appText('服务地址', 'Service URL'),
icon: Icons.dns_outlined,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.url],
),
const SizedBox(height: 10),
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-identifier-field'),
controller: accountIdentifierController,
label: appText('邮箱或账号', 'Email or Username'),
icon: Icons.person_outline_rounded,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
),
const SizedBox(height: 10),
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-password-field'),
controller: accountPasswordController,
label: appText('密码', 'Password'),
icon: Icons.lock_outline_rounded,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => onLogin(),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
key: const Key('mobile-settings-account-login-button'),
onPressed: accountBusy ? null : onLogin,
icon: accountBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login_rounded),
label: Text(appText('登录', 'Sign In')),
),
),
],
),
const SizedBox(height: 12),
_ManualBridgeCard(
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveManualBridge: onSaveManualBridge,
),
],
);
}
}
class _ManualBridgeCard extends StatelessWidget {
const _ManualBridgeCard({
required this.accountBusy,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveManualBridge,
});
final bool accountBusy;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onSaveManualBridge;
@override
Widget build(BuildContext context) {
return MobileSettingsCardInternal(
key: const Key('mobile-settings-manual-bridge-card'),
icon: Icons.link_outlined,
title: appText('手动 Bridge', 'Manual Bridge'),
subtitle: appText(
'仅用于私有或本地 Bridge远端托管登录优先。',
'Use only for private or local Bridge; managed sign-in is preferred.',
),
children: [
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-manual-bridge-url-field'),
controller: bridgeUrlController,
label: appText('Bridge 地址', 'Bridge URL'),
icon: Icons.dns_outlined,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 10),
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-manual-bridge-token-field'),
controller: bridgeTokenController,
label: appText('鉴权令牌', 'Auth Token'),
icon: Icons.key_outlined,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSaveManualBridge(),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
key: const Key('mobile-settings-manual-bridge-save-button'),
onPressed: accountBusy ? null : onSaveManualBridge,
icon: const Icon(Icons.save_outlined),
label: Text(appText('保存手动配置', 'Save Manual Config')),
),
),
],
);
}
}
class _ArchivedTasksSection extends StatelessWidget {
const _ArchivedTasksSection({required this.controller});
final AppController controller;
@override
Widget build(BuildContext context) {
final sessions = controller.archivedAssistantSessions;
if (sessions.isEmpty) {
return MobileSettingsCardInternal(
key: const Key('mobile-settings-archived-empty-card'),
icon: Icons.inventory_2_outlined,
title: appText('归档任务', 'Archived Tasks'),
subtitle: appText('暂无归档任务。', 'No archived tasks.'),
children: const [],
);
}
return Column(
children: [
for (final session in sessions)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MobileSettingsCardInternal(
icon: Icons.inventory_2_outlined,
title: session.label.trim().isEmpty
? appText('未命名任务', 'Untitled Task')
: session.label.trim(),
subtitle: session.lastMessagePreview?.trim() ?? '',
children: [
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => controller.saveAssistantTaskArchived(
session.key,
false,
),
icon: const Icon(Icons.unarchive_outlined),
label: Text(appText('恢复', 'Restore')),
),
),
const SizedBox(width: 10),
Expanded(
child: TextButton.icon(
onPressed: () =>
controller.deleteArchivedAssistantTask(session.key),
icon: const Icon(Icons.delete_outline_rounded),
label: Text(appText('删除', 'Delete')),
),
),
],
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import '../../models/app_models.dart';
import '../../theme/app_palette.dart';
class MobileSettingsTabSelectorInternal extends StatelessWidget {
const MobileSettingsTabSelectorInternal({
super.key,
required this.currentTab,
required this.availableTabs,
required this.onChanged,
});
final SettingsTab currentTab;
final List<SettingsTab> availableTabs;
final ValueChanged<SettingsTab> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SettingsTab>(
key: const Key('mobile-settings-tab-selector'),
segments: [
for (final tab in availableTabs)
ButtonSegment<SettingsTab>(
value: tab,
icon: Icon(
tab == SettingsTab.archivedTasks
? Icons.inventory_2_outlined
: Icons.hub_outlined,
),
label: Text(tab.label),
),
],
selected: <SettingsTab>{currentTab},
onSelectionChanged: (selection) {
if (selection.isNotEmpty) {
onChanged(selection.first);
}
},
);
}
}
class MobileSettingsCardInternal extends StatelessWidget {
const MobileSettingsCardInternal({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.children,
});
final IconData icon;
final String title;
final String subtitle;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return DecoratedBox(
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: palette.accent, size: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
if (subtitle.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: palette.textSecondary),
),
],
],
),
),
],
),
if (children.isNotEmpty) ...[
const SizedBox(height: 14),
...children,
],
],
),
),
);
}
}
class MobileSettingsTextFieldInternal extends StatelessWidget {
const MobileSettingsTextFieldInternal({
super.key,
required this.controller,
required this.label,
required this.icon,
this.obscureText = false,
this.keyboardType,
this.textInputAction,
this.autofillHints,
this.onSubmitted,
});
final TextEditingController controller;
final String label;
final IconData icon;
final bool obscureText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final Iterable<String>? autofillHints;
final ValueChanged<String>? onSubmitted;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
textInputAction: textInputAction,
autofillHints: autofillHints,
autocorrect: false,
enableSuggestions: !obscureText,
decoration: InputDecoration(labelText: label, prefixIcon: Icon(icon)),
onFieldSubmitted: onSubmitted,
);
}
}
class MobileSettingsMetaRowInternal extends StatelessWidget {
const MobileSettingsMetaRowInternal({
super.key,
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: palette.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: palette.textSecondary),
),
const SizedBox(height: 2),
Text(value, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
);
}
}

View File

@ -145,7 +145,9 @@ class StoreLayoutResolver {
if (supportRootPath == null) {
// Fallback to a temporary directory instead of failing fast with an error.
// This ensures the app remains usable in "memory-only" or "ephemeral" mode.
final tempDir = await Directory.systemTemp.createTemp('xworkmate-fallback-');
final tempDir = await Directory.systemTemp.createTemp(
'xworkmate-fallback-',
);
final layout = StoreLayout(
rootDirectory: tempDir,
configDirectory: await ensureDirectory('${tempDir.path}/config'),
@ -243,19 +245,24 @@ Future<Directory> ensureDirectory(String path) async {
}
Future<void> ensureOwnerOnlyDirectory(Directory directory) async {
if (Platform.isWindows) {
if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) {
return;
}
await _setUnixPermissions(directory.path, '700');
}
Future<void> ensureOwnerOnlyFile(File file) async {
if (Platform.isWindows) {
if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) {
return;
}
await _setUnixPermissions(file.path, '600');
}
bool shouldApplyUnixOwnerOnlyPermissionsInternal({String? operatingSystem}) {
final os = operatingSystem ?? Platform.operatingSystem;
return os == 'linux' || os == 'macos';
}
String encodeStableFileKey(String key) {
return base64Url.encode(utf8.encode(key)).replaceAll('=', '');
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/app_shell_desktop.dart';
import 'package:xworkmate/features/mobile/mobile_settings_page.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
group('MobileSettingsPage', () {
testWidgets(
'mobile shell renders mobile settings instead of desktop page',
(tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(430, 932);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
controller.openSettings();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
home: AppShell(controller: controller),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('mobile-settings-page')), findsOneWidget);
expect(
find.byKey(const ValueKey('settings-account-panel-card')),
findsNothing,
);
expect(find.text('搜索设置'), findsNothing);
expect(
find.byKey(const Key('mobile-settings-account-login-card')),
findsOneWidget,
);
expect(
find.byKey(const Key('mobile-settings-manual-bridge-card')),
findsOneWidget,
);
},
);
testWidgets('login form uses mobile-friendly input hints and controls', (
tester,
) async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
home: MediaQuery(
data: const MediaQueryData(size: Size(390, 844)),
child: Scaffold(body: MobileSettingsPage(controller: controller)),
),
),
);
await tester.pumpAndSettle();
final emailField = tester.widget<TextField>(
find.descendant(
of: find.byKey(const Key('mobile-settings-account-identifier-field')),
matching: find.byType(TextField),
),
);
final passwordField = tester.widget<TextField>(
find.descendant(
of: find.byKey(const Key('mobile-settings-account-password-field')),
matching: find.byType(TextField),
),
);
expect(emailField.keyboardType, TextInputType.emailAddress);
expect(emailField.textInputAction, TextInputAction.next);
expect(passwordField.keyboardType, TextInputType.visiblePassword);
expect(passwordField.textInputAction, TextInputAction.done);
expect(passwordField.obscureText, isTrue);
expect(
find.byKey(const Key('mobile-settings-account-login-button')),
findsOneWidget,
);
});
});
}

View File

@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/file_store_support.dart';
void main() {
group('file store owner-only permissions', () {
test('uses chmod only on desktop Unix platforms', () {
expect(
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'macos'),
isTrue,
);
expect(
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'linux'),
isTrue,
);
});
test('does not require chmod on mobile sandbox platforms', () {
expect(
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'ios'),
isFalse,
);
expect(
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'android'),
isFalse,
);
});
test('does not require chmod on Windows', () {
expect(
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'windows'),
isFalse,
);
});
});
}