feat: add linux desktop parity scaffolding
This commit is contained in:
parent
583fb56bc5
commit
910fd3fedc
14
Makefile
14
Makefile
@ -7,7 +7,7 @@ PNPM ?= pnpm
|
|||||||
DART ?= dart
|
DART ?= dart
|
||||||
DEVICE ?= macos
|
DEVICE ?= macos
|
||||||
|
|
||||||
.PHONY: help deps analyze test check format run build-macos build-ios-sim package-mac install-mac clean
|
.PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean
|
||||||
|
|
||||||
help: ## Show available targets
|
help: ## Show available targets
|
||||||
@grep -E '^[a-zA-Z0-9_.-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-18s %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z0-9_.-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-18s %s\n", $$1, $$2}'
|
||||||
@ -29,12 +29,24 @@ format: ## Format Dart sources
|
|||||||
run: ## Run the app on a device or desktop target (DEVICE=macos by default)
|
run: ## Run the app on a device or desktop target (DEVICE=macos by default)
|
||||||
$(FLUTTER) run -d $(DEVICE)
|
$(FLUTTER) run -d $(DEVICE)
|
||||||
|
|
||||||
|
build-linux: ## Build the Linux app in release mode
|
||||||
|
$(FLUTTER) build linux --release
|
||||||
|
|
||||||
build-macos: ## Build the macOS app in release mode
|
build-macos: ## Build the macOS app in release mode
|
||||||
$(FLUTTER) build macos --release
|
$(FLUTTER) build macos --release
|
||||||
|
|
||||||
build-ios-sim: ## Build the iOS app for the simulator
|
build-ios-sim: ## Build the iOS app for the simulator
|
||||||
$(FLUTTER) build ios --simulator
|
$(FLUTTER) build ios --simulator
|
||||||
|
|
||||||
|
package-deb: ## Create the Linux .deb package
|
||||||
|
bash scripts/package-linux-deb.sh
|
||||||
|
|
||||||
|
package-rpm: ## Create the Linux .rpm package
|
||||||
|
bash scripts/package-linux-rpm.sh
|
||||||
|
|
||||||
|
package-linux: ## Create both Linux packages
|
||||||
|
bash scripts/package-linux.sh
|
||||||
|
|
||||||
package-mac: ## Create the macOS .app and DMG
|
package-mac: ## Create the macOS .app and DMG
|
||||||
bash scripts/package-flutter-mac-app.sh
|
bash scripts/package-flutter-mac-app.sh
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import '../i18n/app_language.dart';
|
|||||||
import '../models/app_models.dart';
|
import '../models/app_models.dart';
|
||||||
import '../runtime/device_identity_store.dart';
|
import '../runtime/device_identity_store.dart';
|
||||||
import '../runtime/runtime_bootstrap.dart';
|
import '../runtime/runtime_bootstrap.dart';
|
||||||
|
import '../runtime/desktop_platform_service.dart';
|
||||||
import '../runtime/gateway_runtime.dart';
|
import '../runtime/gateway_runtime.dart';
|
||||||
import '../runtime/runtime_controllers.dart';
|
import '../runtime/runtime_controllers.dart';
|
||||||
import '../runtime/runtime_models.dart';
|
import '../runtime/runtime_models.dart';
|
||||||
@ -25,6 +26,7 @@ class AppController extends ChangeNotifier {
|
|||||||
AppController({
|
AppController({
|
||||||
SecureConfigStore? store,
|
SecureConfigStore? store,
|
||||||
RuntimeCoordinator? runtimeCoordinator,
|
RuntimeCoordinator? runtimeCoordinator,
|
||||||
|
DesktopPlatformService? desktopPlatformService,
|
||||||
}) {
|
}) {
|
||||||
_store = store ?? SecureConfigStore();
|
_store = store ?? SecureConfigStore();
|
||||||
|
|
||||||
@ -58,6 +60,8 @@ class AppController extends ChangeNotifier {
|
|||||||
_cronJobsController = CronJobsController(_runtimeCoordinator.gateway);
|
_cronJobsController = CronJobsController(_runtimeCoordinator.gateway);
|
||||||
_devicesController = DevicesController(_runtimeCoordinator.gateway);
|
_devicesController = DevicesController(_runtimeCoordinator.gateway);
|
||||||
_tasksController = DerivedTasksController();
|
_tasksController = DerivedTasksController();
|
||||||
|
_desktopPlatformService =
|
||||||
|
desktopPlatformService ?? createDesktopPlatformService();
|
||||||
_attachChildListeners();
|
_attachChildListeners();
|
||||||
unawaited(_initialize());
|
unawaited(_initialize());
|
||||||
}
|
}
|
||||||
@ -78,6 +82,7 @@ class AppController extends ChangeNotifier {
|
|||||||
late final CronJobsController _cronJobsController;
|
late final CronJobsController _cronJobsController;
|
||||||
late final DevicesController _devicesController;
|
late final DevicesController _devicesController;
|
||||||
late final DerivedTasksController _tasksController;
|
late final DerivedTasksController _tasksController;
|
||||||
|
late final DesktopPlatformService _desktopPlatformService;
|
||||||
|
|
||||||
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
||||||
ThemeMode _themeMode = ThemeMode.light;
|
ThemeMode _themeMode = ThemeMode.light;
|
||||||
@ -118,6 +123,10 @@ class AppController extends ChangeNotifier {
|
|||||||
CronJobsController get cronJobsController => _cronJobsController;
|
CronJobsController get cronJobsController => _cronJobsController;
|
||||||
DevicesController get devicesController => _devicesController;
|
DevicesController get devicesController => _devicesController;
|
||||||
DerivedTasksController get tasksController => _tasksController;
|
DerivedTasksController get tasksController => _tasksController;
|
||||||
|
DesktopIntegrationState get desktopIntegration =>
|
||||||
|
_desktopPlatformService.state;
|
||||||
|
bool get supportsDesktopIntegration => desktopIntegration.isSupported;
|
||||||
|
bool get desktopPlatformBusy => _desktopPlatformBusy;
|
||||||
|
|
||||||
GatewayConnectionSnapshot get connection => _runtime.snapshot;
|
GatewayConnectionSnapshot get connection => _runtime.snapshot;
|
||||||
SettingsSnapshot get settings => _settingsController.snapshot;
|
SettingsSnapshot get settings => _settingsController.snapshot;
|
||||||
@ -160,6 +169,7 @@ class AppController extends ChangeNotifier {
|
|||||||
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
|
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
|
||||||
configuredCodeAgentRuntimeMode;
|
configuredCodeAgentRuntimeMode;
|
||||||
CodexCooperationState get codexCooperationState => _codexCooperationState;
|
CodexCooperationState get codexCooperationState => _codexCooperationState;
|
||||||
|
bool _desktopPlatformBusy = false;
|
||||||
|
|
||||||
Future<String> loadAiGatewayApiKey() async {
|
Future<String> loadAiGatewayApiKey() async {
|
||||||
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
|
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
|
||||||
@ -255,8 +265,8 @@ class AppController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void navigateHome() {
|
void navigateHome() {
|
||||||
final mainSessionKey = _runtime.snapshot.mainSessionKey?.trim().isNotEmpty ==
|
final mainSessionKey =
|
||||||
true
|
_runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true
|
||||||
? _runtime.snapshot.mainSessionKey!.trim()
|
? _runtime.snapshot.mainSessionKey!.trim()
|
||||||
: 'main';
|
: 'main';
|
||||||
final destinationChanged = _destination != WorkspaceDestination.assistant;
|
final destinationChanged = _destination != WorkspaceDestination.assistant;
|
||||||
@ -643,9 +653,77 @@ class AppController extends ChangeNotifier {
|
|||||||
_registerCodexExternalProvider(codexPath: sanitized.codexCliPath);
|
_registerCodexExternalProvider(codexPath: sanitized.codexCliPath);
|
||||||
await _refreshCodexCliAvailability();
|
await _refreshCodexCliAvailability();
|
||||||
}
|
}
|
||||||
|
if (current.linuxDesktop.toJson().toString() !=
|
||||||
|
sanitized.linuxDesktop.toJson().toString() ||
|
||||||
|
current.launchAtLogin != sanitized.launchAtLogin) {
|
||||||
|
await _desktopPlatformService.syncConfig(sanitized.linuxDesktop);
|
||||||
|
await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin);
|
||||||
|
}
|
||||||
if (refreshAfterSave) {
|
if (refreshAfterSave) {
|
||||||
_recomputeTasks();
|
_recomputeTasks();
|
||||||
}
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshDesktopIntegration() async {
|
||||||
|
_desktopPlatformBusy = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await _desktopPlatformService.refresh();
|
||||||
|
} finally {
|
||||||
|
_desktopPlatformBusy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveLinuxDesktopConfig(LinuxDesktopConfig config) async {
|
||||||
|
await saveSettings(settings.copyWith(linuxDesktop: config));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setDesktopVpnMode(VpnMode mode) async {
|
||||||
|
_desktopPlatformBusy = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await saveSettings(
|
||||||
|
settings.copyWith(
|
||||||
|
linuxDesktop: settings.linuxDesktop.copyWith(preferredMode: mode),
|
||||||
|
),
|
||||||
|
refreshAfterSave: false,
|
||||||
|
);
|
||||||
|
await _desktopPlatformService.setMode(mode);
|
||||||
|
} finally {
|
||||||
|
_desktopPlatformBusy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connectDesktopTunnel() async {
|
||||||
|
_desktopPlatformBusy = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await _desktopPlatformService.connectTunnel();
|
||||||
|
} finally {
|
||||||
|
_desktopPlatformBusy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnectDesktopTunnel() async {
|
||||||
|
_desktopPlatformBusy = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await _desktopPlatformService.disconnectTunnel();
|
||||||
|
} finally {
|
||||||
|
_desktopPlatformBusy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLaunchAtLogin(bool enabled) async {
|
||||||
|
await saveSettings(
|
||||||
|
settings.copyWith(launchAtLogin: enabled),
|
||||||
|
refreshAfterSave: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleAssistantNavigationDestination(
|
Future<void> toggleAssistantNavigationDestination(
|
||||||
@ -785,6 +863,7 @@ class AppController extends ChangeNotifier {
|
|||||||
_cronJobsController.dispose();
|
_cronJobsController.dispose();
|
||||||
_devicesController.dispose();
|
_devicesController.dispose();
|
||||||
_tasksController.dispose();
|
_tasksController.dispose();
|
||||||
|
_desktopPlatformService.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -808,6 +887,8 @@ class AppController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_modelsController.restoreFromSettings(settings.aiGateway);
|
_modelsController.restoreFromSettings(settings.aiGateway);
|
||||||
setActiveAppLanguage(settings.appLanguage);
|
setActiveAppLanguage(settings.appLanguage);
|
||||||
|
await _desktopPlatformService.initialize(settings.linuxDesktop);
|
||||||
|
await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin);
|
||||||
_registerCodexExternalProvider();
|
_registerCodexExternalProvider();
|
||||||
await _refreshCodexCliAvailability();
|
await _refreshCodexCliAvailability();
|
||||||
_agentsController.restoreSelection(settings.gateway.selectedAgentId);
|
_agentsController.restoreSelection(settings.gateway.selectedAgentId);
|
||||||
|
|||||||
@ -174,7 +174,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SwitchRow(
|
_SwitchRow(
|
||||||
label: appText('显示 Dock 图标', 'Show dock icon'),
|
label: controller.supportsDesktopIntegration
|
||||||
|
? appText('显示托盘图标', 'Show tray icon')
|
||||||
|
: appText('显示 Dock 图标', 'Show dock icon'),
|
||||||
value: settings.showDockIcon,
|
value: settings.showDockIcon,
|
||||||
onChanged: (value) => _saveSettings(
|
onChanged: (value) => _saveSettings(
|
||||||
controller,
|
controller,
|
||||||
@ -192,6 +194,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (controller.supportsDesktopIntegration)
|
||||||
|
_buildLinuxDesktopIntegration(context, controller, settings),
|
||||||
SurfaceCard(
|
SurfaceCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -231,6 +235,159 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLinuxDesktopIntegration(
|
||||||
|
BuildContext context,
|
||||||
|
AppController controller,
|
||||||
|
SettingsSnapshot settings,
|
||||||
|
) {
|
||||||
|
final desktop = controller.desktopIntegration;
|
||||||
|
final config = settings.linuxDesktop;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SurfaceCard(
|
||||||
|
key: const ValueKey('linux-desktop-integration-card'),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
appText('Linux 桌面集成', 'Linux Desktop Integration'),
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
appText(
|
||||||
|
'统一管理 GNOME / KDE 的代理模式、隧道连接、托盘菜单与开机自启。',
|
||||||
|
'Manage GNOME / KDE proxy mode, tunnel session, tray menu, and autostart from one surface.',
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_InfoRow(
|
||||||
|
label: appText('桌面环境', 'Desktop'),
|
||||||
|
value: desktop.environment.label,
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
label: 'NetworkManager',
|
||||||
|
value: desktop.networkManagerAvailable
|
||||||
|
? appText('可用', 'Available')
|
||||||
|
: appText('不可用', 'Unavailable'),
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
label: appText('当前模式', 'Current Mode'),
|
||||||
|
value: desktop.mode.label,
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
label: appText('隧道状态', 'Tunnel'),
|
||||||
|
value: desktop.tunnel.connected
|
||||||
|
? appText('已连接', 'Connected')
|
||||||
|
: desktop.tunnel.available
|
||||||
|
? appText('可连接', 'Ready')
|
||||||
|
: appText('未检测到配置', 'No profile detected'),
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
label: appText('系统代理', 'System Proxy'),
|
||||||
|
value: desktop.systemProxy.enabled
|
||||||
|
? '${desktop.systemProxy.host}:${desktop.systemProxy.port}'
|
||||||
|
: appText('未启用', 'Disabled'),
|
||||||
|
),
|
||||||
|
_SwitchRow(
|
||||||
|
label: appText('开机启动', 'Launch at login'),
|
||||||
|
value: settings.launchAtLogin,
|
||||||
|
onChanged: (value) => controller.setLaunchAtLogin(value),
|
||||||
|
),
|
||||||
|
_SwitchRow(
|
||||||
|
label: appText('托盘菜单', 'Tray menu'),
|
||||||
|
value: config.trayEnabled,
|
||||||
|
onChanged: (value) => controller.saveLinuxDesktopConfig(
|
||||||
|
config.copyWith(trayEnabled: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_EditableField(
|
||||||
|
label: appText('隧道连接名称', 'Tunnel Connection Name'),
|
||||||
|
value: config.vpnConnectionName,
|
||||||
|
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
|
||||||
|
config.copyWith(vpnConnectionName: value.trim()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _EditableField(
|
||||||
|
label: appText('代理主机', 'Proxy Host'),
|
||||||
|
value: config.proxyHost,
|
||||||
|
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
|
||||||
|
config.copyWith(proxyHost: value.trim()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _EditableField(
|
||||||
|
label: appText('代理端口', 'Proxy Port'),
|
||||||
|
value: config.proxyPort.toString(),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
final parsed = int.tryParse(value.trim());
|
||||||
|
if (parsed == null || parsed <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.saveLinuxDesktopConfig(
|
||||||
|
config.copyWith(proxyPort: parsed),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: [
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: controller.desktopPlatformBusy
|
||||||
|
? null
|
||||||
|
: () => controller.setDesktopVpnMode(VpnMode.proxy),
|
||||||
|
child: Text(appText('切换到代理', 'Use Proxy')),
|
||||||
|
),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: controller.desktopPlatformBusy
|
||||||
|
? null
|
||||||
|
: () => controller.setDesktopVpnMode(VpnMode.tunnel),
|
||||||
|
child: Text(appText('切换到隧道', 'Use Tunnel')),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: controller.desktopPlatformBusy
|
||||||
|
? null
|
||||||
|
: controller.connectDesktopTunnel,
|
||||||
|
child: Text(appText('连接隧道', 'Connect Tunnel')),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: controller.desktopPlatformBusy
|
||||||
|
? null
|
||||||
|
: controller.disconnectDesktopTunnel,
|
||||||
|
child: Text(appText('断开隧道', 'Disconnect Tunnel')),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: controller.desktopPlatformBusy
|
||||||
|
? null
|
||||||
|
: controller.refreshDesktopIntegration,
|
||||||
|
child: Text(appText('刷新状态', 'Refresh Status')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (desktop.statusMessage.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNotice(
|
||||||
|
context,
|
||||||
|
tone: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
title: appText('桌面状态', 'Desktop Status'),
|
||||||
|
message: desktop.statusMessage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> _buildWorkspace(
|
List<Widget> _buildWorkspace(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AppController controller,
|
AppController controller,
|
||||||
|
|||||||
169
lib/runtime/desktop_platform_service.dart
Normal file
169
lib/runtime/desktop_platform_service.dart
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'runtime_models.dart';
|
||||||
|
|
||||||
|
abstract class DesktopPlatformService {
|
||||||
|
DesktopIntegrationState get state;
|
||||||
|
|
||||||
|
bool get isSupported => state.isSupported;
|
||||||
|
|
||||||
|
Future<void> initialize(LinuxDesktopConfig config);
|
||||||
|
|
||||||
|
Future<void> syncConfig(LinuxDesktopConfig config);
|
||||||
|
|
||||||
|
Future<void> refresh();
|
||||||
|
|
||||||
|
Future<void> setMode(VpnMode mode);
|
||||||
|
|
||||||
|
Future<void> connectTunnel();
|
||||||
|
|
||||||
|
Future<void> disconnectTunnel();
|
||||||
|
|
||||||
|
Future<void> setLaunchAtLogin(bool enabled);
|
||||||
|
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
DesktopPlatformService createDesktopPlatformService() {
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
return MethodChannelDesktopPlatformService();
|
||||||
|
}
|
||||||
|
return UnsupportedDesktopPlatformService();
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnsupportedDesktopPlatformService implements DesktopPlatformService {
|
||||||
|
DesktopIntegrationState _state = DesktopIntegrationState.unsupported();
|
||||||
|
|
||||||
|
@override
|
||||||
|
DesktopIntegrationState get state => _state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSupported => state.isSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize(LinuxDesktopConfig config) async {
|
||||||
|
_state = DesktopIntegrationState.unsupported();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncConfig(LinuxDesktopConfig config) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refresh() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setMode(VpnMode mode) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connectTunnel() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnectTunnel() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setLaunchAtLogin(bool enabled) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MethodChannelDesktopPlatformService implements DesktopPlatformService {
|
||||||
|
static const MethodChannel _channel = MethodChannel(
|
||||||
|
'plus.svc.xworkmate/desktop_platform',
|
||||||
|
);
|
||||||
|
|
||||||
|
DesktopIntegrationState _state = DesktopIntegrationState.loading();
|
||||||
|
LinuxDesktopConfig _config = LinuxDesktopConfig.defaults();
|
||||||
|
|
||||||
|
@override
|
||||||
|
DesktopIntegrationState get state => _state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSupported => state.isSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize(LinuxDesktopConfig config) async {
|
||||||
|
_config = config;
|
||||||
|
await _invokeVoid('configure', _encodeConfig(config));
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncConfig(LinuxDesktopConfig config) async {
|
||||||
|
_config = config;
|
||||||
|
await _invokeVoid('configure', _encodeConfig(config));
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final payload = await _channel.invokeMethod<String>('getState');
|
||||||
|
_state = DesktopIntegrationState.fromJson(
|
||||||
|
_decodeJsonMap(payload),
|
||||||
|
fallbackConfig: _config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setMode(VpnMode mode) async {
|
||||||
|
await _invokeVoid('setMode', mode.name);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connectTunnel() async {
|
||||||
|
await _invokeVoid('connectTunnel');
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnectTunnel() async {
|
||||||
|
await _invokeVoid('disconnectTunnel');
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setLaunchAtLogin(bool enabled) async {
|
||||||
|
await _invokeVoid('setAutostart', enabled);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
|
||||||
|
Future<void> _invokeVoid(String method, [Object? arguments]) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
_state = DesktopIntegrationState.unsupported(
|
||||||
|
config: _config,
|
||||||
|
message: 'Desktop integration channel unavailable',
|
||||||
|
);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_state = _state.copyWith(statusMessage: error.message ?? error.code);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodeConfig(LinuxDesktopConfig config) {
|
||||||
|
return jsonEncode(config.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _decodeJsonMap(String? payload) {
|
||||||
|
if (payload == null || payload.trim().isEmpty) {
|
||||||
|
return const <String, dynamic>{};
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(payload);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
if (decoded is Map) {
|
||||||
|
return decoded.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
return const <String, dynamic>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -92,6 +92,373 @@ extension CodeAgentRuntimeModeCopy on CodeAgentRuntimeMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VpnMode { tunnel, proxy }
|
||||||
|
|
||||||
|
extension VpnModeCopy on VpnMode {
|
||||||
|
String get label => switch (this) {
|
||||||
|
VpnMode.tunnel => appText('隧道', 'Tunnel'),
|
||||||
|
VpnMode.proxy => appText('代理', 'Proxy'),
|
||||||
|
};
|
||||||
|
|
||||||
|
static VpnMode fromJsonValue(String? value) {
|
||||||
|
return VpnMode.values.firstWhere(
|
||||||
|
(item) => item.name == value,
|
||||||
|
orElse: () => VpnMode.proxy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DesktopEnvironment { unknown, gnome, kde }
|
||||||
|
|
||||||
|
extension DesktopEnvironmentCopy on DesktopEnvironment {
|
||||||
|
String get label => switch (this) {
|
||||||
|
DesktopEnvironment.unknown => appText('未知桌面', 'Unknown Desktop'),
|
||||||
|
DesktopEnvironment.gnome => 'GNOME',
|
||||||
|
DesktopEnvironment.kde => 'KDE Plasma',
|
||||||
|
};
|
||||||
|
|
||||||
|
static DesktopEnvironment fromJsonValue(String? value) {
|
||||||
|
return DesktopEnvironment.values.firstWhere(
|
||||||
|
(item) => item.name == value,
|
||||||
|
orElse: () => DesktopEnvironment.unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinuxDesktopConfig {
|
||||||
|
const LinuxDesktopConfig({
|
||||||
|
required this.preferredMode,
|
||||||
|
required this.vpnConnectionName,
|
||||||
|
required this.proxyHost,
|
||||||
|
required this.proxyPort,
|
||||||
|
required this.trayEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VpnMode preferredMode;
|
||||||
|
final String vpnConnectionName;
|
||||||
|
final String proxyHost;
|
||||||
|
final int proxyPort;
|
||||||
|
final bool trayEnabled;
|
||||||
|
|
||||||
|
factory LinuxDesktopConfig.defaults() {
|
||||||
|
return const LinuxDesktopConfig(
|
||||||
|
preferredMode: VpnMode.proxy,
|
||||||
|
vpnConnectionName: 'XWorkmate Tunnel',
|
||||||
|
proxyHost: '127.0.0.1',
|
||||||
|
proxyPort: 7890,
|
||||||
|
trayEnabled: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinuxDesktopConfig copyWith({
|
||||||
|
VpnMode? preferredMode,
|
||||||
|
String? vpnConnectionName,
|
||||||
|
String? proxyHost,
|
||||||
|
int? proxyPort,
|
||||||
|
bool? trayEnabled,
|
||||||
|
}) {
|
||||||
|
return LinuxDesktopConfig(
|
||||||
|
preferredMode: preferredMode ?? this.preferredMode,
|
||||||
|
vpnConnectionName: vpnConnectionName ?? this.vpnConnectionName,
|
||||||
|
proxyHost: proxyHost ?? this.proxyHost,
|
||||||
|
proxyPort: proxyPort ?? this.proxyPort,
|
||||||
|
trayEnabled: trayEnabled ?? this.trayEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'preferredMode': preferredMode.name,
|
||||||
|
'vpnConnectionName': vpnConnectionName,
|
||||||
|
'proxyHost': proxyHost,
|
||||||
|
'proxyPort': proxyPort,
|
||||||
|
'trayEnabled': trayEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory LinuxDesktopConfig.fromJson(Map<String, dynamic> json) {
|
||||||
|
final defaults = LinuxDesktopConfig.defaults();
|
||||||
|
return LinuxDesktopConfig(
|
||||||
|
preferredMode: VpnModeCopy.fromJsonValue(
|
||||||
|
json['preferredMode'] as String?,
|
||||||
|
),
|
||||||
|
vpnConnectionName:
|
||||||
|
json['vpnConnectionName'] as String? ?? defaults.vpnConnectionName,
|
||||||
|
proxyHost: json['proxyHost'] as String? ?? defaults.proxyHost,
|
||||||
|
proxyPort: json['proxyPort'] as int? ?? defaults.proxyPort,
|
||||||
|
trayEnabled: json['trayEnabled'] as bool? ?? defaults.trayEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemProxyState {
|
||||||
|
const SystemProxyState({
|
||||||
|
required this.enabled,
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
required this.backend,
|
||||||
|
required this.lastAppliedMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String backend;
|
||||||
|
final VpnMode lastAppliedMode;
|
||||||
|
|
||||||
|
factory SystemProxyState.defaults({LinuxDesktopConfig? config}) {
|
||||||
|
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
|
||||||
|
return SystemProxyState(
|
||||||
|
enabled: resolvedConfig.preferredMode == VpnMode.proxy,
|
||||||
|
host: resolvedConfig.proxyHost,
|
||||||
|
port: resolvedConfig.proxyPort,
|
||||||
|
backend: '',
|
||||||
|
lastAppliedMode: resolvedConfig.preferredMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemProxyState copyWith({
|
||||||
|
bool? enabled,
|
||||||
|
String? host,
|
||||||
|
int? port,
|
||||||
|
String? backend,
|
||||||
|
VpnMode? lastAppliedMode,
|
||||||
|
}) {
|
||||||
|
return SystemProxyState(
|
||||||
|
enabled: enabled ?? this.enabled,
|
||||||
|
host: host ?? this.host,
|
||||||
|
port: port ?? this.port,
|
||||||
|
backend: backend ?? this.backend,
|
||||||
|
lastAppliedMode: lastAppliedMode ?? this.lastAppliedMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'enabled': enabled,
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'backend': backend,
|
||||||
|
'lastAppliedMode': lastAppliedMode.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SystemProxyState.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
LinuxDesktopConfig? config,
|
||||||
|
}) {
|
||||||
|
final defaults = SystemProxyState.defaults(config: config);
|
||||||
|
return SystemProxyState(
|
||||||
|
enabled: json['enabled'] as bool? ?? defaults.enabled,
|
||||||
|
host: json['host'] as String? ?? defaults.host,
|
||||||
|
port: json['port'] as int? ?? defaults.port,
|
||||||
|
backend: json['backend'] as String? ?? defaults.backend,
|
||||||
|
lastAppliedMode: VpnModeCopy.fromJsonValue(
|
||||||
|
json['lastAppliedMode'] as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TunnelSessionState {
|
||||||
|
const TunnelSessionState({
|
||||||
|
required this.available,
|
||||||
|
required this.connected,
|
||||||
|
required this.connectionName,
|
||||||
|
required this.backend,
|
||||||
|
required this.lastError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool available;
|
||||||
|
final bool connected;
|
||||||
|
final String connectionName;
|
||||||
|
final String backend;
|
||||||
|
final String lastError;
|
||||||
|
|
||||||
|
factory TunnelSessionState.defaults({LinuxDesktopConfig? config}) {
|
||||||
|
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
|
||||||
|
return TunnelSessionState(
|
||||||
|
available: false,
|
||||||
|
connected: false,
|
||||||
|
connectionName: resolvedConfig.vpnConnectionName,
|
||||||
|
backend: '',
|
||||||
|
lastError: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TunnelSessionState copyWith({
|
||||||
|
bool? available,
|
||||||
|
bool? connected,
|
||||||
|
String? connectionName,
|
||||||
|
String? backend,
|
||||||
|
String? lastError,
|
||||||
|
}) {
|
||||||
|
return TunnelSessionState(
|
||||||
|
available: available ?? this.available,
|
||||||
|
connected: connected ?? this.connected,
|
||||||
|
connectionName: connectionName ?? this.connectionName,
|
||||||
|
backend: backend ?? this.backend,
|
||||||
|
lastError: lastError ?? this.lastError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'available': available,
|
||||||
|
'connected': connected,
|
||||||
|
'connectionName': connectionName,
|
||||||
|
'backend': backend,
|
||||||
|
'lastError': lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory TunnelSessionState.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
LinuxDesktopConfig? config,
|
||||||
|
}) {
|
||||||
|
final defaults = TunnelSessionState.defaults(config: config);
|
||||||
|
return TunnelSessionState(
|
||||||
|
available: json['available'] as bool? ?? defaults.available,
|
||||||
|
connected: json['connected'] as bool? ?? defaults.connected,
|
||||||
|
connectionName:
|
||||||
|
json['connectionName'] as String? ?? defaults.connectionName,
|
||||||
|
backend: json['backend'] as String? ?? defaults.backend,
|
||||||
|
lastError: json['lastError'] as String? ?? defaults.lastError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DesktopIntegrationState {
|
||||||
|
const DesktopIntegrationState({
|
||||||
|
required this.isSupported,
|
||||||
|
required this.environment,
|
||||||
|
required this.mode,
|
||||||
|
required this.trayAvailable,
|
||||||
|
required this.trayEnabled,
|
||||||
|
required this.autostartEnabled,
|
||||||
|
required this.networkManagerAvailable,
|
||||||
|
required this.systemProxy,
|
||||||
|
required this.tunnel,
|
||||||
|
required this.statusMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isSupported;
|
||||||
|
final DesktopEnvironment environment;
|
||||||
|
final VpnMode mode;
|
||||||
|
final bool trayAvailable;
|
||||||
|
final bool trayEnabled;
|
||||||
|
final bool autostartEnabled;
|
||||||
|
final bool networkManagerAvailable;
|
||||||
|
final SystemProxyState systemProxy;
|
||||||
|
final TunnelSessionState tunnel;
|
||||||
|
final String statusMessage;
|
||||||
|
|
||||||
|
factory DesktopIntegrationState.loading() {
|
||||||
|
final config = LinuxDesktopConfig.defaults();
|
||||||
|
return DesktopIntegrationState(
|
||||||
|
isSupported: true,
|
||||||
|
environment: DesktopEnvironment.unknown,
|
||||||
|
mode: config.preferredMode,
|
||||||
|
trayAvailable: false,
|
||||||
|
trayEnabled: config.trayEnabled,
|
||||||
|
autostartEnabled: false,
|
||||||
|
networkManagerAvailable: false,
|
||||||
|
systemProxy: SystemProxyState.defaults(config: config),
|
||||||
|
tunnel: TunnelSessionState.defaults(config: config),
|
||||||
|
statusMessage: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DesktopIntegrationState.unsupported({
|
||||||
|
LinuxDesktopConfig? config,
|
||||||
|
String message = '',
|
||||||
|
}) {
|
||||||
|
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
|
||||||
|
return DesktopIntegrationState(
|
||||||
|
isSupported: false,
|
||||||
|
environment: DesktopEnvironment.unknown,
|
||||||
|
mode: resolvedConfig.preferredMode,
|
||||||
|
trayAvailable: false,
|
||||||
|
trayEnabled: false,
|
||||||
|
autostartEnabled: false,
|
||||||
|
networkManagerAvailable: false,
|
||||||
|
systemProxy: SystemProxyState.defaults(config: resolvedConfig),
|
||||||
|
tunnel: TunnelSessionState.defaults(config: resolvedConfig),
|
||||||
|
statusMessage: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DesktopIntegrationState copyWith({
|
||||||
|
bool? isSupported,
|
||||||
|
DesktopEnvironment? environment,
|
||||||
|
VpnMode? mode,
|
||||||
|
bool? trayAvailable,
|
||||||
|
bool? trayEnabled,
|
||||||
|
bool? autostartEnabled,
|
||||||
|
bool? networkManagerAvailable,
|
||||||
|
SystemProxyState? systemProxy,
|
||||||
|
TunnelSessionState? tunnel,
|
||||||
|
String? statusMessage,
|
||||||
|
}) {
|
||||||
|
return DesktopIntegrationState(
|
||||||
|
isSupported: isSupported ?? this.isSupported,
|
||||||
|
environment: environment ?? this.environment,
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
trayAvailable: trayAvailable ?? this.trayAvailable,
|
||||||
|
trayEnabled: trayEnabled ?? this.trayEnabled,
|
||||||
|
autostartEnabled: autostartEnabled ?? this.autostartEnabled,
|
||||||
|
networkManagerAvailable:
|
||||||
|
networkManagerAvailable ?? this.networkManagerAvailable,
|
||||||
|
systemProxy: systemProxy ?? this.systemProxy,
|
||||||
|
tunnel: tunnel ?? this.tunnel,
|
||||||
|
statusMessage: statusMessage ?? this.statusMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'isSupported': isSupported,
|
||||||
|
'environment': environment.name,
|
||||||
|
'mode': mode.name,
|
||||||
|
'trayAvailable': trayAvailable,
|
||||||
|
'trayEnabled': trayEnabled,
|
||||||
|
'autostartEnabled': autostartEnabled,
|
||||||
|
'networkManagerAvailable': networkManagerAvailable,
|
||||||
|
'systemProxy': systemProxy.toJson(),
|
||||||
|
'tunnel': tunnel.toJson(),
|
||||||
|
'statusMessage': statusMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DesktopIntegrationState.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
LinuxDesktopConfig? fallbackConfig,
|
||||||
|
}) {
|
||||||
|
final config = fallbackConfig ?? LinuxDesktopConfig.defaults();
|
||||||
|
return DesktopIntegrationState(
|
||||||
|
isSupported: json['isSupported'] as bool? ?? true,
|
||||||
|
environment: DesktopEnvironmentCopy.fromJsonValue(
|
||||||
|
json['environment'] as String?,
|
||||||
|
),
|
||||||
|
mode: VpnModeCopy.fromJsonValue(json['mode'] as String?),
|
||||||
|
trayAvailable: json['trayAvailable'] as bool? ?? false,
|
||||||
|
trayEnabled: json['trayEnabled'] as bool? ?? config.trayEnabled,
|
||||||
|
autostartEnabled: json['autostartEnabled'] as bool? ?? false,
|
||||||
|
networkManagerAvailable:
|
||||||
|
json['networkManagerAvailable'] as bool? ?? false,
|
||||||
|
systemProxy: SystemProxyState.fromJson(
|
||||||
|
(json['systemProxy'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
config: config,
|
||||||
|
),
|
||||||
|
tunnel: TunnelSessionState.fromJson(
|
||||||
|
(json['tunnel'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
config: config,
|
||||||
|
),
|
||||||
|
statusMessage: json['statusMessage'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class GatewayConnectionProfile {
|
class GatewayConnectionProfile {
|
||||||
const GatewayConnectionProfile({
|
const GatewayConnectionProfile({
|
||||||
required this.mode,
|
required this.mode,
|
||||||
@ -526,6 +893,7 @@ class SettingsSnapshot {
|
|||||||
required this.accountUsername,
|
required this.accountUsername,
|
||||||
required this.accountWorkspace,
|
required this.accountWorkspace,
|
||||||
required this.accountLocalMode,
|
required this.accountLocalMode,
|
||||||
|
required this.linuxDesktop,
|
||||||
required this.assistantExecutionTarget,
|
required this.assistantExecutionTarget,
|
||||||
required this.assistantPermissionLevel,
|
required this.assistantPermissionLevel,
|
||||||
required this.assistantNavigationDestinations,
|
required this.assistantNavigationDestinations,
|
||||||
@ -554,6 +922,7 @@ class SettingsSnapshot {
|
|||||||
final String accountUsername;
|
final String accountUsername;
|
||||||
final String accountWorkspace;
|
final String accountWorkspace;
|
||||||
final bool accountLocalMode;
|
final bool accountLocalMode;
|
||||||
|
final LinuxDesktopConfig linuxDesktop;
|
||||||
final AssistantExecutionTarget assistantExecutionTarget;
|
final AssistantExecutionTarget assistantExecutionTarget;
|
||||||
final AssistantPermissionLevel assistantPermissionLevel;
|
final AssistantPermissionLevel assistantPermissionLevel;
|
||||||
final List<WorkspaceDestination> assistantNavigationDestinations;
|
final List<WorkspaceDestination> assistantNavigationDestinations;
|
||||||
@ -583,6 +952,7 @@ class SettingsSnapshot {
|
|||||||
accountUsername: '',
|
accountUsername: '',
|
||||||
accountWorkspace: 'Default Workspace',
|
accountWorkspace: 'Default Workspace',
|
||||||
accountLocalMode: true,
|
accountLocalMode: true,
|
||||||
|
linuxDesktop: LinuxDesktopConfig.defaults(),
|
||||||
assistantExecutionTarget: AssistantExecutionTarget.local,
|
assistantExecutionTarget: AssistantExecutionTarget.local,
|
||||||
assistantPermissionLevel: AssistantPermissionLevel.defaultAccess,
|
assistantPermissionLevel: AssistantPermissionLevel.defaultAccess,
|
||||||
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
|
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
|
||||||
@ -613,6 +983,7 @@ class SettingsSnapshot {
|
|||||||
String? accountUsername,
|
String? accountUsername,
|
||||||
String? accountWorkspace,
|
String? accountWorkspace,
|
||||||
bool? accountLocalMode,
|
bool? accountLocalMode,
|
||||||
|
LinuxDesktopConfig? linuxDesktop,
|
||||||
AssistantExecutionTarget? assistantExecutionTarget,
|
AssistantExecutionTarget? assistantExecutionTarget,
|
||||||
AssistantPermissionLevel? assistantPermissionLevel,
|
AssistantPermissionLevel? assistantPermissionLevel,
|
||||||
List<WorkspaceDestination>? assistantNavigationDestinations,
|
List<WorkspaceDestination>? assistantNavigationDestinations,
|
||||||
@ -641,6 +1012,7 @@ class SettingsSnapshot {
|
|||||||
accountUsername: accountUsername ?? this.accountUsername,
|
accountUsername: accountUsername ?? this.accountUsername,
|
||||||
accountWorkspace: accountWorkspace ?? this.accountWorkspace,
|
accountWorkspace: accountWorkspace ?? this.accountWorkspace,
|
||||||
accountLocalMode: accountLocalMode ?? this.accountLocalMode,
|
accountLocalMode: accountLocalMode ?? this.accountLocalMode,
|
||||||
|
linuxDesktop: linuxDesktop ?? this.linuxDesktop,
|
||||||
assistantExecutionTarget:
|
assistantExecutionTarget:
|
||||||
assistantExecutionTarget ?? this.assistantExecutionTarget,
|
assistantExecutionTarget ?? this.assistantExecutionTarget,
|
||||||
assistantPermissionLevel:
|
assistantPermissionLevel:
|
||||||
@ -676,6 +1048,7 @@ class SettingsSnapshot {
|
|||||||
'accountUsername': accountUsername,
|
'accountUsername': accountUsername,
|
||||||
'accountWorkspace': accountWorkspace,
|
'accountWorkspace': accountWorkspace,
|
||||||
'accountLocalMode': accountLocalMode,
|
'accountLocalMode': accountLocalMode,
|
||||||
|
'linuxDesktop': linuxDesktop.toJson(),
|
||||||
'assistantExecutionTarget': assistantExecutionTarget.name,
|
'assistantExecutionTarget': assistantExecutionTarget.name,
|
||||||
'assistantPermissionLevel': assistantPermissionLevel.name,
|
'assistantPermissionLevel': assistantPermissionLevel.name,
|
||||||
'assistantNavigationDestinations': assistantNavigationDestinations
|
'assistantNavigationDestinations': assistantNavigationDestinations
|
||||||
@ -753,6 +1126,9 @@ class SettingsSnapshot {
|
|||||||
json['accountWorkspace'] as String? ??
|
json['accountWorkspace'] as String? ??
|
||||||
SettingsSnapshot.defaults().accountWorkspace,
|
SettingsSnapshot.defaults().accountWorkspace,
|
||||||
accountLocalMode: json['accountLocalMode'] as bool? ?? true,
|
accountLocalMode: json['accountLocalMode'] as bool? ?? true,
|
||||||
|
linuxDesktop: LinuxDesktopConfig.fromJson(
|
||||||
|
(json['linuxDesktop'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
),
|
||||||
assistantExecutionTarget: AssistantExecutionTargetCopy.fromJsonValue(
|
assistantExecutionTarget: AssistantExecutionTargetCopy.fromJsonValue(
|
||||||
json['assistantExecutionTarget'] as String?,
|
json['assistantExecutionTarget'] as String?,
|
||||||
),
|
),
|
||||||
|
|||||||
10
linux/packaging/icons/xworkmate.svg
Normal file
10
linux/packaging/icons/xworkmate.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1b3a57"/>
|
||||||
|
<stop offset="100%" stop-color="#2c7da0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||||
|
<path d="M64 84h54l28 38 28-38h18l-37 50 37 52h-54l-28-38-28 38H64l37-52z" fill="#f6f7eb"/>
|
||||||
|
<circle cx="188" cy="72" r="16" fill="#f4d35e"/>
|
||||||
|
After Width: | Height: | Size: 461 B |
9
linux/packaging/xworkmate-autostart.desktop
Normal file
9
linux/packaging/xworkmate-autostart.desktop
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Version=1.0
|
||||||
|
Name=XWorkmate
|
||||||
|
Exec=/opt/xworkmate/xworkmate
|
||||||
|
Icon=xworkmate
|
||||||
|
Terminal=false
|
||||||
|
NoDisplay=true
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
10
linux/packaging/xworkmate.desktop
Normal file
10
linux/packaging/xworkmate.desktop
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Version=1.0
|
||||||
|
Name=XWorkmate
|
||||||
|
Comment=Desktop AI workspace shell with Linux proxy and tunnel integration
|
||||||
|
Exec=/opt/xworkmate/xworkmate
|
||||||
|
Icon=xworkmate
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;Utility;Development;
|
||||||
|
StartupNotify=true
|
||||||
@ -7,6 +7,7 @@ project(runner LANGUAGES CXX)
|
|||||||
#
|
#
|
||||||
# Any new source files that you add to the application should be added here.
|
# Any new source files that you add to the application should be added here.
|
||||||
add_executable(${BINARY_NAME}
|
add_executable(${BINARY_NAME}
|
||||||
|
"desktop_platform_channel.cc"
|
||||||
"main.cc"
|
"main.cc"
|
||||||
"my_application.cc"
|
"my_application.cc"
|
||||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||||
@ -23,4 +24,9 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
|||||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||||
|
|
||||||
|
set_source_files_properties(
|
||||||
|
desktop_platform_channel.cc
|
||||||
|
PROPERTIES COMPILE_OPTIONS "-Wno-deprecated-declarations"
|
||||||
|
)
|
||||||
|
|
||||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||||
|
|||||||
734
linux/runner/desktop_platform_channel.cc
Normal file
734
linux/runner/desktop_platform_channel.cc
Normal file
@ -0,0 +1,734 @@
|
|||||||
|
#include "desktop_platform_channel.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <glib.h>
|
||||||
|
#include <glib/gstdio.h>
|
||||||
|
#include <limits.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <regex>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr char kChannelName[] = "plus.svc.xworkmate/desktop_platform";
|
||||||
|
constexpr char kDesktopFileId[] = "plus.svc.xworkmate.desktop";
|
||||||
|
|
||||||
|
struct CommandResult {
|
||||||
|
bool ok = false;
|
||||||
|
int exit_status = -1;
|
||||||
|
std::string stdout_text;
|
||||||
|
std::string stderr_text;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DesktopPlatformChannel {
|
||||||
|
MyApplication* application;
|
||||||
|
GtkWindow* window;
|
||||||
|
FlView* view;
|
||||||
|
FlMethodChannel* channel;
|
||||||
|
GtkStatusIcon* status_icon;
|
||||||
|
GtkWidget* menu;
|
||||||
|
std::string preferred_mode = "proxy";
|
||||||
|
std::string vpn_connection_name = "XWorkmate Tunnel";
|
||||||
|
std::string proxy_host = "127.0.0.1";
|
||||||
|
int proxy_port = 7890;
|
||||||
|
bool tray_enabled = true;
|
||||||
|
bool tray_available = true;
|
||||||
|
bool autostart_enabled = false;
|
||||||
|
bool network_manager_available = false;
|
||||||
|
std::string desktop_environment = "unknown";
|
||||||
|
std::string status_message;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string json_escape(const std::string& input) {
|
||||||
|
std::ostringstream escaped;
|
||||||
|
for (const char ch : input) {
|
||||||
|
switch (ch) {
|
||||||
|
case '\\':
|
||||||
|
escaped << "\\\\";
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
escaped << "\\\"";
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
escaped << "\\n";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
escaped << ch;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim_quotes(const std::string& value) {
|
||||||
|
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
|
||||||
|
return value.substr(1, value.size() - 2);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> json_string(const std::string& payload,
|
||||||
|
const std::string& key) {
|
||||||
|
const std::regex pattern("\"" + key + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
|
||||||
|
std::smatch match;
|
||||||
|
if (!std::regex_search(payload, match, pattern) || match.size() < 2) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::string value = match[1].str();
|
||||||
|
value = std::regex_replace(value, std::regex("\\\\\""), "\"");
|
||||||
|
value = std::regex_replace(value, std::regex("\\\\\\\\"), "\\");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int> json_int(const std::string& payload, const std::string& key) {
|
||||||
|
const std::regex pattern("\"" + key + "\"\\s*:\\s*(\\d+)");
|
||||||
|
std::smatch match;
|
||||||
|
if (!std::regex_search(payload, match, pattern) || match.size() < 2) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return std::stoi(match[1].str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<bool> json_bool(const std::string& payload,
|
||||||
|
const std::string& key) {
|
||||||
|
const std::regex pattern("\"" + key + "\"\\s*:\\s*(true|false)");
|
||||||
|
std::smatch match;
|
||||||
|
if (!std::regex_search(payload, match, pattern) || match.size() < 2) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return match[1].str() == "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandResult run_command(const std::vector<std::string>& args) {
|
||||||
|
std::vector<std::unique_ptr<gchar, decltype(&g_free)>> quoted;
|
||||||
|
quoted.reserve(args.size());
|
||||||
|
std::ostringstream command;
|
||||||
|
for (size_t index = 0; index < args.size(); index++) {
|
||||||
|
if (index > 0) {
|
||||||
|
command << ' ';
|
||||||
|
}
|
||||||
|
quoted.emplace_back(g_shell_quote(args[index].c_str()), g_free);
|
||||||
|
command << quoted.back().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
gchar* stdout_text = nullptr;
|
||||||
|
gchar* stderr_text = nullptr;
|
||||||
|
gint exit_status = -1;
|
||||||
|
GError* error = nullptr;
|
||||||
|
const gboolean ok = g_spawn_command_line_sync(command.str().c_str(),
|
||||||
|
&stdout_text, &stderr_text,
|
||||||
|
&exit_status, &error);
|
||||||
|
CommandResult result;
|
||||||
|
result.ok = ok && error == nullptr;
|
||||||
|
result.exit_status = exit_status;
|
||||||
|
if (stdout_text != nullptr) {
|
||||||
|
result.stdout_text = stdout_text;
|
||||||
|
}
|
||||||
|
if (stderr_text != nullptr) {
|
||||||
|
result.stderr_text = stderr_text;
|
||||||
|
}
|
||||||
|
if (error != nullptr) {
|
||||||
|
result.stderr_text = error->message;
|
||||||
|
g_error_free(error);
|
||||||
|
}
|
||||||
|
g_free(stdout_text);
|
||||||
|
g_free(stderr_text);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool command_succeeds(const std::vector<std::string>& args) {
|
||||||
|
const CommandResult result = run_command(args);
|
||||||
|
return result.ok && result.exit_status == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string detect_desktop_environment() {
|
||||||
|
const char* current = g_getenv("XDG_CURRENT_DESKTOP");
|
||||||
|
const std::string desktop = current == nullptr ? "" : current;
|
||||||
|
std::unique_ptr<gchar, decltype(&g_free)> lowered_raw(
|
||||||
|
g_ascii_strdown(desktop.c_str(), -1), g_free);
|
||||||
|
const std::string lowered =
|
||||||
|
lowered_raw == nullptr ? std::string() : lowered_raw.get();
|
||||||
|
if (lowered.find("gnome") != std::string::npos) {
|
||||||
|
return "gnome";
|
||||||
|
}
|
||||||
|
if (lowered.find("kde") != std::string::npos ||
|
||||||
|
lowered.find("plasma") != std::string::npos) {
|
||||||
|
return "kde";
|
||||||
|
}
|
||||||
|
if (g_getenv("KDE_FULL_SESSION") != nullptr) {
|
||||||
|
return "kde";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string autostart_path() {
|
||||||
|
const char* config_home = g_get_user_config_dir();
|
||||||
|
std::ostringstream path;
|
||||||
|
path << config_home << "/autostart/" << kDesktopFileId;
|
||||||
|
return path.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string executable_path() {
|
||||||
|
gchar buffer[PATH_MAX];
|
||||||
|
const ssize_t size = readlink("/proc/self/exe", buffer, sizeof(buffer) - 1);
|
||||||
|
if (size <= 0) {
|
||||||
|
return "xworkmate";
|
||||||
|
}
|
||||||
|
buffer[size] = '\0';
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_autostart_file() {
|
||||||
|
const std::string path = autostart_path();
|
||||||
|
const std::string directory = path.substr(0, path.find_last_of('/'));
|
||||||
|
if (g_mkdir_with_parents(directory.c_str(), 0755) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::ostringstream contents;
|
||||||
|
contents << "[Desktop Entry]\n";
|
||||||
|
contents << "Type=Application\n";
|
||||||
|
contents << "Version=1.0\n";
|
||||||
|
contents << "Name=XWorkmate\n";
|
||||||
|
contents << "Exec=" << executable_path() << "\n";
|
||||||
|
contents << "Icon=xworkmate\n";
|
||||||
|
contents << "Terminal=false\n";
|
||||||
|
contents << "Categories=Network;Utility;\n";
|
||||||
|
contents << "StartupNotify=true\n";
|
||||||
|
return g_file_set_contents(path.c_str(), contents.str().c_str(), -1,
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool remove_autostart_file() {
|
||||||
|
return g_remove(autostart_path().c_str()) == 0 || errno == ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool autostart_enabled() {
|
||||||
|
return g_file_test(autostart_path().c_str(), G_FILE_TEST_EXISTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool network_manager_available() {
|
||||||
|
return command_succeeds({"nmcli", "--version"});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tunnel_profile_exists(const std::string& connection_name) {
|
||||||
|
const CommandResult result = run_command(
|
||||||
|
{"nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"});
|
||||||
|
if (!result.ok || result.exit_status != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::istringstream lines(result.stdout_text);
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(lines, line)) {
|
||||||
|
if (line.rfind(connection_name + ":", 0) == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tunnel_connected(const std::string& connection_name) {
|
||||||
|
const CommandResult result = run_command(
|
||||||
|
{"nmcli", "-t", "-f", "NAME", "connection", "show", "--active"});
|
||||||
|
if (!result.ok || result.exit_status != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::istringstream lines(result.stdout_text);
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(lines, line)) {
|
||||||
|
if (line == connection_name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string gsettings_read(const std::vector<std::string>& args) {
|
||||||
|
const CommandResult result = run_command(args);
|
||||||
|
if (!result.ok || result.exit_status != 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
std::string value = result.stdout_text;
|
||||||
|
value.erase(value.find_last_not_of(" \n\r\t") + 1);
|
||||||
|
return trim_quotes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool apply_gnome_proxy(const DesktopPlatformChannel* self) {
|
||||||
|
const bool mode_ok = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy", "mode", "manual"});
|
||||||
|
const bool http_host = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy.http", "host",
|
||||||
|
self->proxy_host});
|
||||||
|
const bool http_port = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy.http", "port",
|
||||||
|
std::to_string(self->proxy_port)});
|
||||||
|
const bool https_host = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy.https", "host",
|
||||||
|
self->proxy_host});
|
||||||
|
const bool https_port = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy.https", "port",
|
||||||
|
std::to_string(self->proxy_port)});
|
||||||
|
const bool socks_host = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy.socks", "host",
|
||||||
|
self->proxy_host});
|
||||||
|
const bool socks_port = command_succeeds({
|
||||||
|
"gsettings", "set", "org.gnome.system.proxy.socks", "port",
|
||||||
|
std::to_string(self->proxy_port)});
|
||||||
|
return mode_ok && http_host && http_port && https_host && https_port &&
|
||||||
|
socks_host && socks_port;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool disable_gnome_proxy() {
|
||||||
|
return command_succeeds(
|
||||||
|
{"gsettings", "set", "org.gnome.system.proxy", "mode", "none"});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool apply_kde_proxy(const DesktopPlatformChannel* self) {
|
||||||
|
const bool type_ok = command_succeeds({
|
||||||
|
"kwriteconfig5", "--file", "kioslaverc", "--group",
|
||||||
|
"Proxy Settings", "--key", "ProxyType", "1"});
|
||||||
|
const std::string proxy_value =
|
||||||
|
"http://" + self->proxy_host + " " + std::to_string(self->proxy_port);
|
||||||
|
const bool http_ok = command_succeeds({
|
||||||
|
"kwriteconfig5", "--file", "kioslaverc", "--group",
|
||||||
|
"Proxy Settings", "--key", "httpProxy", proxy_value});
|
||||||
|
const bool https_ok = command_succeeds({
|
||||||
|
"kwriteconfig5", "--file", "kioslaverc", "--group",
|
||||||
|
"Proxy Settings", "--key", "httpsProxy", proxy_value});
|
||||||
|
const bool socks_ok = command_succeeds({
|
||||||
|
"kwriteconfig5", "--file", "kioslaverc", "--group",
|
||||||
|
"Proxy Settings", "--key", "socksProxy", proxy_value});
|
||||||
|
command_succeeds({"qdbus", "org.kde.KIO", "/KIO/Scheduler",
|
||||||
|
"org.kde.KIO.Scheduler.reparseConfiguration", ""});
|
||||||
|
return type_ok && http_ok && https_ok && socks_ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool disable_kde_proxy() {
|
||||||
|
const bool ok = command_succeeds({
|
||||||
|
"kwriteconfig5", "--file", "kioslaverc", "--group", "Proxy Settings",
|
||||||
|
"--key", "ProxyType", "0"});
|
||||||
|
command_succeeds({"qdbus", "org.kde.KIO", "/KIO/Scheduler",
|
||||||
|
"org.kde.KIO.Scheduler.reparseConfiguration", ""});
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool apply_proxy_mode(DesktopPlatformChannel* self) {
|
||||||
|
if (self->desktop_environment == "gnome") {
|
||||||
|
return apply_gnome_proxy(self);
|
||||||
|
}
|
||||||
|
if (self->desktop_environment == "kde") {
|
||||||
|
return apply_kde_proxy(self);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool disable_system_proxy(DesktopPlatformChannel* self) {
|
||||||
|
if (self->desktop_environment == "gnome") {
|
||||||
|
return disable_gnome_proxy();
|
||||||
|
}
|
||||||
|
if (self->desktop_environment == "kde") {
|
||||||
|
return disable_kde_proxy();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string gnome_proxy_mode() {
|
||||||
|
return gsettings_read(
|
||||||
|
{"gsettings", "get", "org.gnome.system.proxy", "mode"});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string gnome_proxy_host(const std::string& group) {
|
||||||
|
const std::string schema = "org.gnome.system.proxy." + group;
|
||||||
|
return gsettings_read({"gsettings", "get", schema, "host"});
|
||||||
|
}
|
||||||
|
|
||||||
|
int gnome_proxy_port(const std::string& group) {
|
||||||
|
const std::string schema = "org.gnome.system.proxy." + group;
|
||||||
|
const std::string value =
|
||||||
|
gsettings_read({"gsettings", "get", schema, "port"});
|
||||||
|
return value.empty() ? 0 : std::atoi(value.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string kde_proxy_value(const char* key) {
|
||||||
|
const CommandResult result = run_command({"kreadconfig5", "--file", "kioslaverc",
|
||||||
|
"--group", "Proxy Settings",
|
||||||
|
"--key", key});
|
||||||
|
if (!result.ok || result.exit_status != 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
std::string value = result.stdout_text;
|
||||||
|
value.erase(value.find_last_not_of(" \n\r\t") + 1);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh_runtime_state(DesktopPlatformChannel* self) {
|
||||||
|
self->desktop_environment = detect_desktop_environment();
|
||||||
|
self->network_manager_available = network_manager_available();
|
||||||
|
self->autostart_enabled = autostart_enabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string state_json(DesktopPlatformChannel* self) {
|
||||||
|
refresh_runtime_state(self);
|
||||||
|
|
||||||
|
bool proxy_enabled = false;
|
||||||
|
std::string proxy_backend;
|
||||||
|
std::string proxy_host = self->proxy_host;
|
||||||
|
int proxy_port = self->proxy_port;
|
||||||
|
if (self->desktop_environment == "gnome") {
|
||||||
|
proxy_backend = "gsettings";
|
||||||
|
proxy_enabled = gnome_proxy_mode() == "manual";
|
||||||
|
if (proxy_enabled) {
|
||||||
|
const std::string detected_host = gnome_proxy_host("http");
|
||||||
|
const int detected_port = gnome_proxy_port("http");
|
||||||
|
if (!detected_host.empty()) {
|
||||||
|
proxy_host = detected_host;
|
||||||
|
}
|
||||||
|
if (detected_port > 0) {
|
||||||
|
proxy_port = detected_port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (self->desktop_environment == "kde") {
|
||||||
|
proxy_backend = "kioslaverc";
|
||||||
|
const std::string detected = kde_proxy_value("httpProxy");
|
||||||
|
proxy_enabled = !detected.empty();
|
||||||
|
if (proxy_enabled) {
|
||||||
|
const std::regex pattern(R"(http://([^ ]+)\s+(\d+))");
|
||||||
|
std::smatch match;
|
||||||
|
if (std::regex_search(detected, match, pattern) && match.size() >= 3) {
|
||||||
|
proxy_host = match[1].str();
|
||||||
|
proxy_port = std::stoi(match[2].str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool tunnel_available =
|
||||||
|
self->network_manager_available &&
|
||||||
|
tunnel_profile_exists(self->vpn_connection_name);
|
||||||
|
const bool tunnel_is_connected =
|
||||||
|
tunnel_available && tunnel_connected(self->vpn_connection_name);
|
||||||
|
|
||||||
|
const std::string mode =
|
||||||
|
tunnel_is_connected ? "tunnel" : (proxy_enabled ? "proxy" : self->preferred_mode);
|
||||||
|
|
||||||
|
std::ostringstream json;
|
||||||
|
json << "{";
|
||||||
|
json << "\"isSupported\":true,";
|
||||||
|
json << "\"environment\":\"" << json_escape(self->desktop_environment) << "\",";
|
||||||
|
json << "\"mode\":\"" << json_escape(mode) << "\",";
|
||||||
|
json << "\"trayAvailable\":" << (self->tray_available ? "true" : "false") << ",";
|
||||||
|
json << "\"trayEnabled\":" << (self->tray_enabled ? "true" : "false") << ",";
|
||||||
|
json << "\"autostartEnabled\":" << (self->autostart_enabled ? "true" : "false") << ",";
|
||||||
|
json << "\"networkManagerAvailable\":"
|
||||||
|
<< (self->network_manager_available ? "true" : "false") << ",";
|
||||||
|
json << "\"systemProxy\":{";
|
||||||
|
json << "\"enabled\":" << (proxy_enabled ? "true" : "false") << ",";
|
||||||
|
json << "\"host\":\"" << json_escape(proxy_host) << "\",";
|
||||||
|
json << "\"port\":" << proxy_port << ",";
|
||||||
|
json << "\"backend\":\"" << json_escape(proxy_backend) << "\",";
|
||||||
|
json << "\"lastAppliedMode\":\"" << json_escape(self->preferred_mode) << "\"";
|
||||||
|
json << "},";
|
||||||
|
json << "\"tunnel\":{";
|
||||||
|
json << "\"available\":" << (tunnel_available ? "true" : "false") << ",";
|
||||||
|
json << "\"connected\":" << (tunnel_is_connected ? "true" : "false") << ",";
|
||||||
|
json << "\"connectionName\":\"" << json_escape(self->vpn_connection_name) << "\",";
|
||||||
|
json << "\"backend\":\"nmcli\",";
|
||||||
|
json << "\"lastError\":\"" << json_escape(self->status_message) << "\"";
|
||||||
|
json << "},";
|
||||||
|
json << "\"statusMessage\":\"" << json_escape(self->status_message) << "\"";
|
||||||
|
json << "}";
|
||||||
|
return json.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_status_icon(DesktopPlatformChannel* self) {
|
||||||
|
if (self->status_icon == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gtk_status_icon_set_visible(self->status_icon, self->tray_enabled);
|
||||||
|
gtk_status_icon_set_from_icon_name(self->status_icon, "network-vpn-symbolic");
|
||||||
|
const std::string json = state_json(self);
|
||||||
|
const std::string tooltip =
|
||||||
|
"XWorkmate • " + self->desktop_environment + " • " + self->preferred_mode;
|
||||||
|
gtk_status_icon_set_tooltip_text(self->status_icon, tooltip.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void show_window(DesktopPlatformChannel* self) {
|
||||||
|
gtk_widget_show_all(GTK_WIDGET(self->window));
|
||||||
|
gtk_window_present(self->window);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_open_activate(GtkMenuItem*, gpointer user_data) {
|
||||||
|
show_window(static_cast<DesktopPlatformChannel*>(user_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_status_icon_activate(GtkStatusIcon*, gpointer user_data) {
|
||||||
|
show_window(static_cast<DesktopPlatformChannel*>(user_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_quit_activate(GtkMenuItem*, gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
g_application_quit(G_APPLICATION(self->application));
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_use_proxy_activate(GtkMenuItem*, gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
self->preferred_mode = "proxy";
|
||||||
|
if (!apply_proxy_mode(self)) {
|
||||||
|
self->status_message =
|
||||||
|
"Failed to apply system proxy; verify gsettings/kwriteconfig5";
|
||||||
|
} else {
|
||||||
|
self->status_message = "System proxy enabled";
|
||||||
|
}
|
||||||
|
update_status_icon(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_use_tunnel_activate(GtkMenuItem*, gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
self->preferred_mode = "tunnel";
|
||||||
|
if (!disable_system_proxy(self)) {
|
||||||
|
self->status_message = "Tunnel mode selected; proxy disable may require manual follow-up";
|
||||||
|
} else {
|
||||||
|
self->status_message = "Tunnel mode selected";
|
||||||
|
}
|
||||||
|
update_status_icon(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_connect_tunnel_activate(GtkMenuItem*, gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
self->preferred_mode = "tunnel";
|
||||||
|
disable_system_proxy(self);
|
||||||
|
if (!command_succeeds({"nmcli", "connection", "up", "id",
|
||||||
|
self->vpn_connection_name})) {
|
||||||
|
self->status_message = "Failed to connect NetworkManager tunnel";
|
||||||
|
} else {
|
||||||
|
self->status_message = "Tunnel connected";
|
||||||
|
}
|
||||||
|
update_status_icon(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_disconnect_tunnel_activate(GtkMenuItem*, gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
if (!command_succeeds({"nmcli", "connection", "down", "id",
|
||||||
|
self->vpn_connection_name})) {
|
||||||
|
self->status_message = "Failed to disconnect tunnel";
|
||||||
|
} else {
|
||||||
|
self->status_message = "Tunnel disconnected";
|
||||||
|
}
|
||||||
|
update_status_icon(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_status_icon_popup(GtkStatusIcon* status_icon,
|
||||||
|
guint button,
|
||||||
|
guint activate_time,
|
||||||
|
gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
gtk_menu_popup(GTK_MENU(self->menu), nullptr, nullptr,
|
||||||
|
gtk_status_icon_position_menu, status_icon, button,
|
||||||
|
activate_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* build_menu(DesktopPlatformChannel* self) {
|
||||||
|
GtkWidget* menu = gtk_menu_new();
|
||||||
|
|
||||||
|
GtkWidget* open_item = gtk_menu_item_new_with_label("Open XWorkmate");
|
||||||
|
GtkWidget* connect_item = gtk_menu_item_new_with_label("Connect Tunnel");
|
||||||
|
GtkWidget* disconnect_item = gtk_menu_item_new_with_label("Disconnect Tunnel");
|
||||||
|
GtkWidget* proxy_item = gtk_menu_item_new_with_label("Use Proxy Mode");
|
||||||
|
GtkWidget* tunnel_item = gtk_menu_item_new_with_label("Use Tunnel Mode");
|
||||||
|
GtkWidget* quit_item = gtk_menu_item_new_with_label("Quit");
|
||||||
|
|
||||||
|
g_signal_connect(open_item, "activate", G_CALLBACK(on_open_activate), self);
|
||||||
|
g_signal_connect(connect_item, "activate",
|
||||||
|
G_CALLBACK(on_connect_tunnel_activate), self);
|
||||||
|
g_signal_connect(disconnect_item, "activate",
|
||||||
|
G_CALLBACK(on_disconnect_tunnel_activate), self);
|
||||||
|
g_signal_connect(proxy_item, "activate", G_CALLBACK(on_use_proxy_activate),
|
||||||
|
self);
|
||||||
|
g_signal_connect(tunnel_item, "activate", G_CALLBACK(on_use_tunnel_activate),
|
||||||
|
self);
|
||||||
|
g_signal_connect(quit_item, "activate", G_CALLBACK(on_quit_activate), self);
|
||||||
|
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), open_item);
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), connect_item);
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), disconnect_item);
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), proxy_item);
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), tunnel_item);
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
|
||||||
|
gtk_menu_shell_append(GTK_MENU_SHELL(menu), quit_item);
|
||||||
|
gtk_widget_show_all(menu);
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ensure_status_icon(DesktopPlatformChannel* self) {
|
||||||
|
if (self->status_icon == nullptr) {
|
||||||
|
self->status_icon = gtk_status_icon_new();
|
||||||
|
self->menu = build_menu(self);
|
||||||
|
g_signal_connect(self->status_icon, "popup-menu",
|
||||||
|
G_CALLBACK(on_status_icon_popup), self);
|
||||||
|
g_signal_connect(self->status_icon, "activate",
|
||||||
|
G_CALLBACK(on_status_icon_activate),
|
||||||
|
self);
|
||||||
|
}
|
||||||
|
update_status_icon(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlMethodResponse* success_response_with_json(const std::string& payload) {
|
||||||
|
g_autoptr(FlValue) result = fl_value_new_string(payload.c_str());
|
||||||
|
return FL_METHOD_RESPONSE(fl_method_success_response_new(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
FlMethodResponse* method_error(const char* code, const std::string& message) {
|
||||||
|
return FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new(code, message.c_str(), nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
FlMethodResponse* handle_method_call(DesktopPlatformChannel* self,
|
||||||
|
FlMethodCall* method_call) {
|
||||||
|
const gchar* method = fl_method_call_get_name(method_call);
|
||||||
|
FlValue* args = fl_method_call_get_args(method_call);
|
||||||
|
|
||||||
|
if (strcmp(method, "getState") == 0) {
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "configure") == 0) {
|
||||||
|
const char* payload = args == nullptr ? nullptr : fl_value_get_string(args);
|
||||||
|
const std::string json = payload == nullptr ? "" : payload;
|
||||||
|
if (const auto value = json_string(json, "preferredMode"); value.has_value()) {
|
||||||
|
self->preferred_mode = *value;
|
||||||
|
}
|
||||||
|
if (const auto value = json_string(json, "vpnConnectionName"); value.has_value()) {
|
||||||
|
self->vpn_connection_name = *value;
|
||||||
|
}
|
||||||
|
if (const auto value = json_string(json, "proxyHost"); value.has_value()) {
|
||||||
|
self->proxy_host = *value;
|
||||||
|
}
|
||||||
|
if (const auto value = json_int(json, "proxyPort"); value.has_value()) {
|
||||||
|
self->proxy_port = *value;
|
||||||
|
}
|
||||||
|
if (const auto value = json_bool(json, "trayEnabled"); value.has_value()) {
|
||||||
|
self->tray_enabled = *value;
|
||||||
|
}
|
||||||
|
ensure_status_icon(self);
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "setMode") == 0) {
|
||||||
|
const char* value = args == nullptr ? nullptr : fl_value_get_string(args);
|
||||||
|
if (value == nullptr) {
|
||||||
|
return method_error("INVALID_ARGS", "mode is required");
|
||||||
|
}
|
||||||
|
self->preferred_mode = value;
|
||||||
|
if (self->preferred_mode == "proxy") {
|
||||||
|
if (!apply_proxy_mode(self)) {
|
||||||
|
self->status_message = "Failed to apply system proxy";
|
||||||
|
} else {
|
||||||
|
self->status_message = "System proxy enabled";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disable_system_proxy(self);
|
||||||
|
self->status_message = "Tunnel mode selected";
|
||||||
|
}
|
||||||
|
update_status_icon(self);
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "connectTunnel") == 0) {
|
||||||
|
self->preferred_mode = "tunnel";
|
||||||
|
disable_system_proxy(self);
|
||||||
|
if (!command_succeeds({"nmcli", "connection", "up", "id",
|
||||||
|
self->vpn_connection_name})) {
|
||||||
|
return method_error("NM_CONNECT_FAILED",
|
||||||
|
"Failed to connect NetworkManager tunnel");
|
||||||
|
}
|
||||||
|
self->status_message = "Tunnel connected";
|
||||||
|
update_status_icon(self);
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "disconnectTunnel") == 0) {
|
||||||
|
if (!command_succeeds({"nmcli", "connection", "down", "id",
|
||||||
|
self->vpn_connection_name})) {
|
||||||
|
return method_error("NM_DISCONNECT_FAILED", "Failed to disconnect tunnel");
|
||||||
|
}
|
||||||
|
self->status_message = "Tunnel disconnected";
|
||||||
|
update_status_icon(self);
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "setAutostart") == 0) {
|
||||||
|
const bool enabled = args != nullptr && fl_value_get_bool(args);
|
||||||
|
const bool ok = enabled ? write_autostart_file() : remove_autostart_file();
|
||||||
|
if (!ok) {
|
||||||
|
return method_error("AUTOSTART_FAILED", "Failed to update autostart");
|
||||||
|
}
|
||||||
|
self->status_message =
|
||||||
|
enabled ? "Autostart enabled" : "Autostart disabled";
|
||||||
|
update_status_icon(self);
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "showWindow") == 0) {
|
||||||
|
show_window(self);
|
||||||
|
return success_response_with_json(state_json(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
||||||
|
}
|
||||||
|
|
||||||
|
void method_call_cb(FlMethodChannel* channel,
|
||||||
|
FlMethodCall* method_call,
|
||||||
|
gpointer user_data) {
|
||||||
|
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
|
||||||
|
g_autoptr(FlMethodResponse) response = handle_method_call(self, method_call);
|
||||||
|
GError* error = nullptr;
|
||||||
|
if (!fl_method_call_respond(method_call, response, &error) && error != nullptr) {
|
||||||
|
g_warning("Failed to send response: %s", error->message);
|
||||||
|
g_error_free(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DesktopPlatformChannel* desktop_platform_channel_new(MyApplication* application,
|
||||||
|
GtkWindow* window,
|
||||||
|
FlView* view) {
|
||||||
|
auto* self = new DesktopPlatformChannel();
|
||||||
|
self->application = application;
|
||||||
|
self->window = window;
|
||||||
|
self->view = view;
|
||||||
|
self->desktop_environment = detect_desktop_environment();
|
||||||
|
ensure_status_icon(self);
|
||||||
|
|
||||||
|
FlEngine* engine = fl_view_get_engine(view);
|
||||||
|
FlBinaryMessenger* messenger = fl_engine_get_binary_messenger(engine);
|
||||||
|
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||||
|
self->channel =
|
||||||
|
fl_method_channel_new(messenger, kChannelName, FL_METHOD_CODEC(codec));
|
||||||
|
fl_method_channel_set_method_call_handler(self->channel, method_call_cb, self,
|
||||||
|
nullptr);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
void desktop_platform_channel_free(DesktopPlatformChannel* channel) {
|
||||||
|
if (channel == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel->status_icon != nullptr) {
|
||||||
|
gtk_status_icon_set_visible(channel->status_icon, FALSE);
|
||||||
|
g_object_unref(channel->status_icon);
|
||||||
|
}
|
||||||
|
if (channel->menu != nullptr) {
|
||||||
|
gtk_widget_destroy(channel->menu);
|
||||||
|
}
|
||||||
|
if (channel->channel != nullptr) {
|
||||||
|
g_object_unref(channel->channel);
|
||||||
|
}
|
||||||
|
delete channel;
|
||||||
|
}
|
||||||
23
linux/runner/desktop_platform_channel.h
Normal file
23
linux/runner/desktop_platform_channel.h
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#ifndef RUNNER_DESKTOP_PLATFORM_CHANNEL_H_
|
||||||
|
#define RUNNER_DESKTOP_PLATFORM_CHANNEL_H_
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
typedef struct _MyApplication MyApplication;
|
||||||
|
|
||||||
|
G_BEGIN_DECLS
|
||||||
|
|
||||||
|
typedef struct _DesktopPlatformChannel DesktopPlatformChannel;
|
||||||
|
|
||||||
|
DesktopPlatformChannel* desktop_platform_channel_new(
|
||||||
|
MyApplication* application,
|
||||||
|
GtkWindow* window,
|
||||||
|
FlView* view);
|
||||||
|
|
||||||
|
void desktop_platform_channel_free(DesktopPlatformChannel* channel);
|
||||||
|
|
||||||
|
G_END_DECLS
|
||||||
|
|
||||||
|
#endif // RUNNER_DESKTOP_PLATFORM_CHANNEL_H_
|
||||||
@ -5,11 +5,13 @@
|
|||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#include "desktop_platform_channel.h"
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
struct _MyApplication {
|
struct _MyApplication {
|
||||||
GtkApplication parent_instance;
|
GtkApplication parent_instance;
|
||||||
char** dart_entrypoint_arguments;
|
char** dart_entrypoint_arguments;
|
||||||
|
DesktopPlatformChannel* desktop_platform_channel;
|
||||||
};
|
};
|
||||||
|
|
||||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||||
@ -75,6 +77,8 @@ static void my_application_activate(GApplication* application) {
|
|||||||
gtk_widget_realize(GTK_WIDGET(view));
|
gtk_widget_realize(GTK_WIDGET(view));
|
||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
self->desktop_platform_channel =
|
||||||
|
desktop_platform_channel_new(self, window, view);
|
||||||
|
|
||||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
}
|
}
|
||||||
@ -111,9 +115,10 @@ static void my_application_startup(GApplication* application) {
|
|||||||
|
|
||||||
// Implements GApplication::shutdown.
|
// Implements GApplication::shutdown.
|
||||||
static void my_application_shutdown(GApplication* application) {
|
static void my_application_shutdown(GApplication* application) {
|
||||||
// MyApplication* self = MY_APPLICATION(object);
|
MyApplication* self = MY_APPLICATION(application);
|
||||||
|
|
||||||
// Perform any actions required at application shutdown.
|
desktop_platform_channel_free(self->desktop_platform_channel);
|
||||||
|
self->desktop_platform_channel = nullptr;
|
||||||
|
|
||||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||||
}
|
}
|
||||||
|
|||||||
10
scripts/linux-postinst.sh
Normal file
10
scripts/linux-postinst.sh
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
10
scripts/linux-postrm.sh
Normal file
10
scripts/linux-postrm.sh
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
52
scripts/package-linux-deb.sh
Normal file
52
scripts/package-linux-deb.sh
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
app_name="xworkmate"
|
||||||
|
version="$(python3 - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
text = Path("pubspec.yaml").read_text()
|
||||||
|
match = re.search(r"^version:\s*([^\n+]+)", text, re.M)
|
||||||
|
print(match.group(1) if match else "0.0.0")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
build_dir="$repo_root/build/linux/x64/release/bundle"
|
||||||
|
stage_dir="$repo_root/build/linux/deb-stage"
|
||||||
|
out_dir="$repo_root/dist/linux"
|
||||||
|
|
||||||
|
cd "$repo_root"
|
||||||
|
flutter build linux --release
|
||||||
|
|
||||||
|
rm -rf "$stage_dir"
|
||||||
|
mkdir -p "$stage_dir/DEBIAN"
|
||||||
|
mkdir -p "$stage_dir/opt/$app_name"
|
||||||
|
mkdir -p "$stage_dir/usr/share/applications"
|
||||||
|
mkdir -p "$stage_dir/usr/share/icons/hicolor/scalable/apps"
|
||||||
|
mkdir -p "$stage_dir/usr/share/$app_name/autostart"
|
||||||
|
|
||||||
|
cp -R "$build_dir/." "$stage_dir/opt/$app_name/"
|
||||||
|
cp "$repo_root/linux/packaging/xworkmate.desktop" \
|
||||||
|
"$stage_dir/usr/share/applications/$app_name.desktop"
|
||||||
|
cp "$repo_root/linux/packaging/xworkmate-autostart.desktop" \
|
||||||
|
"$stage_dir/usr/share/$app_name/autostart/$app_name.desktop"
|
||||||
|
cp "$repo_root/linux/packaging/icons/xworkmate.svg" \
|
||||||
|
"$stage_dir/usr/share/icons/hicolor/scalable/apps/$app_name.svg"
|
||||||
|
cp "$repo_root/scripts/linux-postinst.sh" "$stage_dir/DEBIAN/postinst"
|
||||||
|
cp "$repo_root/scripts/linux-postrm.sh" "$stage_dir/DEBIAN/postrm"
|
||||||
|
chmod 0755 "$stage_dir/DEBIAN/postinst" "$stage_dir/DEBIAN/postrm"
|
||||||
|
|
||||||
|
cat > "$stage_dir/DEBIAN/control" <<EOF
|
||||||
|
Package: $app_name
|
||||||
|
Version: $version
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: XWorkmate
|
||||||
|
Depends: network-manager, libgtk-3-0
|
||||||
|
Description: XWorkmate Linux desktop shell with GNOME/KDE proxy and tunnel integration
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
dpkg-deb --build "$stage_dir" "$out_dir/${app_name}_${version}_amd64.deb"
|
||||||
75
scripts/package-linux-rpm.sh
Normal file
75
scripts/package-linux-rpm.sh
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
app_name="xworkmate"
|
||||||
|
version="$(python3 - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
text = Path("pubspec.yaml").read_text()
|
||||||
|
match = re.search(r"^version:\s*([^\n+]+)", text, re.M)
|
||||||
|
print(match.group(1) if match else "0.0.0")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
bundle_dir="$repo_root/build/linux/x64/release/bundle"
|
||||||
|
rpm_root="$repo_root/build/linux/rpm"
|
||||||
|
spec_file="$rpm_root/SPECS/${app_name}.spec"
|
||||||
|
out_dir="$repo_root/dist/linux"
|
||||||
|
|
||||||
|
cd "$repo_root"
|
||||||
|
flutter build linux --release
|
||||||
|
|
||||||
|
rm -rf "$rpm_root"
|
||||||
|
mkdir -p "$rpm_root/BUILD" "$rpm_root/RPMS" "$rpm_root/SOURCES" \
|
||||||
|
"$rpm_root/SPECS" "$rpm_root/SRPMS"
|
||||||
|
|
||||||
|
cp -R "$bundle_dir" "$rpm_root/SOURCES/bundle"
|
||||||
|
cp "$repo_root/linux/packaging/xworkmate.desktop" \
|
||||||
|
"$rpm_root/SOURCES/$app_name.desktop"
|
||||||
|
cp "$repo_root/linux/packaging/xworkmate-autostart.desktop" \
|
||||||
|
"$rpm_root/SOURCES/$app_name-autostart.desktop"
|
||||||
|
cp "$repo_root/linux/packaging/icons/xworkmate.svg" \
|
||||||
|
"$rpm_root/SOURCES/$app_name.svg"
|
||||||
|
|
||||||
|
cat > "$spec_file" <<EOF
|
||||||
|
Name: $app_name
|
||||||
|
Version: $version
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: XWorkmate Linux desktop shell
|
||||||
|
License: Proprietary
|
||||||
|
BuildArch: x86_64
|
||||||
|
Requires: NetworkManager, gtk3
|
||||||
|
|
||||||
|
%description
|
||||||
|
XWorkmate Linux desktop shell with GNOME/KDE proxy and tunnel integration.
|
||||||
|
|
||||||
|
%install
|
||||||
|
mkdir -p %{buildroot}/opt/$app_name
|
||||||
|
mkdir -p %{buildroot}/usr/share/applications
|
||||||
|
mkdir -p %{buildroot}/usr/share/icons/hicolor/scalable/apps
|
||||||
|
mkdir -p %{buildroot}/usr/share/$app_name/autostart
|
||||||
|
cp -a %{_sourcedir}/bundle/. %{buildroot}/opt/$app_name/
|
||||||
|
cp %{_sourcedir}/$app_name.desktop %{buildroot}/usr/share/applications/$app_name.desktop
|
||||||
|
cp %{_sourcedir}/$app_name-autostart.desktop %{buildroot}/usr/share/$app_name/autostart/$app_name.desktop
|
||||||
|
cp %{_sourcedir}/$app_name.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/$app_name.svg
|
||||||
|
|
||||||
|
%post
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%postun
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%files
|
||||||
|
/opt/$app_name
|
||||||
|
/usr/share/applications/$app_name.desktop
|
||||||
|
/usr/share/icons/hicolor/scalable/apps/$app_name.svg
|
||||||
|
/usr/share/$app_name/autostart/$app_name.desktop
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
rpmbuild --define "_topdir $rpm_root" --define "__spec_install_post %{nil}" \
|
||||||
|
-bb "$spec_file"
|
||||||
|
find "$rpm_root/RPMS" -name '*.rpm' -exec cp {} "$out_dir/" \;
|
||||||
7
scripts/package-linux.sh
Normal file
7
scripts/package-linux.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
"$repo_root/scripts/package-linux-deb.sh"
|
||||||
|
"$repo_root/scripts/package-linux-rpm.sh"
|
||||||
@ -1,9 +1,67 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:xworkmate/features/settings/settings_page.dart';
|
import 'package:xworkmate/features/settings/settings_page.dart';
|
||||||
|
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||||
|
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||||
|
|
||||||
import '../test_support.dart';
|
import '../test_support.dart';
|
||||||
|
|
||||||
|
class _DesktopServiceStub implements DesktopPlatformService {
|
||||||
|
@override
|
||||||
|
DesktopIntegrationState get state =>
|
||||||
|
DesktopIntegrationState.fromJson(const <String, dynamic>{
|
||||||
|
'isSupported': true,
|
||||||
|
'environment': 'kde',
|
||||||
|
'mode': 'proxy',
|
||||||
|
'trayAvailable': true,
|
||||||
|
'trayEnabled': true,
|
||||||
|
'autostartEnabled': false,
|
||||||
|
'networkManagerAvailable': true,
|
||||||
|
'systemProxy': {
|
||||||
|
'enabled': true,
|
||||||
|
'host': '127.0.0.1',
|
||||||
|
'port': 7890,
|
||||||
|
'backend': 'kioslaverc',
|
||||||
|
'lastAppliedMode': 'proxy',
|
||||||
|
},
|
||||||
|
'tunnel': {
|
||||||
|
'available': true,
|
||||||
|
'connected': false,
|
||||||
|
'connectionName': 'XWorkmate Tunnel',
|
||||||
|
'backend': 'nmcli',
|
||||||
|
'lastError': '',
|
||||||
|
},
|
||||||
|
'statusMessage': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSupported => state.isSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize(LinuxDesktopConfig config) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncConfig(LinuxDesktopConfig config) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refresh() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setMode(VpnMode mode) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connectTunnel() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnectTunnel() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setLaunchAtLogin(bool enabled) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('SettingsPage theme chips update controller theme mode', (
|
testWidgets('SettingsPage theme chips update controller theme mode', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
@ -41,6 +99,25 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('SettingsPage shows Linux desktop integration controls', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final controller = await createTestController(
|
||||||
|
tester,
|
||||||
|
desktopPlatformService: _DesktopServiceStub(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('linux-desktop-integration-card')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('Linux 桌面集成'), findsOneWidget);
|
||||||
|
expect(find.text('切换到代理'), findsOneWidget);
|
||||||
|
expect(find.text('连接隧道'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', (
|
testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
) async {
|
) async {
|
||||||
@ -77,6 +154,6 @@ void main() {
|
|||||||
await tester.tap(find.text('清空'));
|
await tester.tap(find.text('清空'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('当前没有运行日志。'), findsOneWidget);
|
expect(controller.runtimeLogs, isEmpty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
137
test/runtime/app_controller_desktop_platform_test.dart
Normal file
137
test/runtime/app_controller_desktop_platform_test.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:xworkmate/app/app_controller.dart';
|
||||||
|
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||||
|
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||||
|
|
||||||
|
class _FakeDesktopPlatformService implements DesktopPlatformService {
|
||||||
|
_FakeDesktopPlatformService()
|
||||||
|
: _state = DesktopIntegrationState.fromJson(const <String, dynamic>{
|
||||||
|
'isSupported': true,
|
||||||
|
'environment': 'gnome',
|
||||||
|
'mode': 'proxy',
|
||||||
|
'trayAvailable': true,
|
||||||
|
'trayEnabled': true,
|
||||||
|
'autostartEnabled': false,
|
||||||
|
'networkManagerAvailable': true,
|
||||||
|
'systemProxy': {
|
||||||
|
'enabled': true,
|
||||||
|
'host': '127.0.0.1',
|
||||||
|
'port': 7890,
|
||||||
|
'backend': 'gsettings',
|
||||||
|
'lastAppliedMode': 'proxy',
|
||||||
|
},
|
||||||
|
'tunnel': {
|
||||||
|
'available': true,
|
||||||
|
'connected': false,
|
||||||
|
'connectionName': 'XWorkmate Tunnel',
|
||||||
|
'backend': 'nmcli',
|
||||||
|
'lastError': '',
|
||||||
|
},
|
||||||
|
'statusMessage': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
DesktopIntegrationState _state;
|
||||||
|
LinuxDesktopConfig config = LinuxDesktopConfig.defaults();
|
||||||
|
bool autostartEnabled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DesktopIntegrationState get state =>
|
||||||
|
_state.copyWith(autostartEnabled: autostartEnabled);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSupported => state.isSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize(LinuxDesktopConfig config) async {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncConfig(LinuxDesktopConfig config) async {
|
||||||
|
this.config = config;
|
||||||
|
_state = _state.copyWith(
|
||||||
|
mode: config.preferredMode,
|
||||||
|
trayEnabled: config.trayEnabled,
|
||||||
|
tunnel: _state.tunnel.copyWith(connectionName: config.vpnConnectionName),
|
||||||
|
systemProxy: _state.systemProxy.copyWith(
|
||||||
|
host: config.proxyHost,
|
||||||
|
port: config.proxyPort,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refresh() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setMode(VpnMode mode) async {
|
||||||
|
_state = _state.copyWith(
|
||||||
|
mode: mode,
|
||||||
|
systemProxy: _state.systemProxy.copyWith(enabled: mode == VpnMode.proxy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connectTunnel() async {
|
||||||
|
_state = _state.copyWith(
|
||||||
|
mode: VpnMode.tunnel,
|
||||||
|
tunnel: _state.tunnel.copyWith(connected: true),
|
||||||
|
systemProxy: _state.systemProxy.copyWith(enabled: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnectTunnel() async {
|
||||||
|
_state = _state.copyWith(tunnel: _state.tunnel.copyWith(connected: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setLaunchAtLogin(bool enabled) async {
|
||||||
|
autostartEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'AppController syncs Linux desktop settings into platform service',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
final service = _FakeDesktopPlatformService();
|
||||||
|
final controller = AppController(desktopPlatformService: service);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(controller.supportsDesktopIntegration, isTrue);
|
||||||
|
expect(
|
||||||
|
controller.desktopIntegration.environment,
|
||||||
|
DesktopEnvironment.gnome,
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.saveLinuxDesktopConfig(
|
||||||
|
controller.settings.linuxDesktop.copyWith(
|
||||||
|
vpnConnectionName: 'Corp Tunnel',
|
||||||
|
proxyHost: '10.0.0.2',
|
||||||
|
proxyPort: 8080,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(service.config.vpnConnectionName, 'Corp Tunnel');
|
||||||
|
expect(service.config.proxyHost, '10.0.0.2');
|
||||||
|
expect(service.config.proxyPort, 8080);
|
||||||
|
|
||||||
|
await controller.setDesktopVpnMode(VpnMode.tunnel);
|
||||||
|
expect(controller.desktopIntegration.mode, VpnMode.tunnel);
|
||||||
|
|
||||||
|
await controller.connectDesktopTunnel();
|
||||||
|
expect(controller.desktopIntegration.tunnel.connected, isTrue);
|
||||||
|
|
||||||
|
await controller.setLaunchAtLogin(true);
|
||||||
|
expect(service.autostartEnabled, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,10 +4,16 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:xworkmate/app/app_controller.dart';
|
import 'package:xworkmate/app/app_controller.dart';
|
||||||
import 'package:xworkmate/theme/app_theme.dart';
|
import 'package:xworkmate/theme/app_theme.dart';
|
||||||
|
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||||
|
|
||||||
Future<AppController> createTestController(WidgetTester tester) async {
|
Future<AppController> createTestController(
|
||||||
|
WidgetTester tester, {
|
||||||
|
DesktopPlatformService? desktopPlatformService,
|
||||||
|
}) async {
|
||||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
final controller = AppController();
|
final controller = AppController(
|
||||||
|
desktopPlatformService: desktopPlatformService,
|
||||||
|
);
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user