diff --git a/Makefile b/Makefile index 31395a97..b095776c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ PNPM ?= pnpm DART ?= dart 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 @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) $(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 $(FLUTTER) build macos --release build-ios-sim: ## Build the iOS app for the 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 bash scripts/package-flutter-mac-app.sh diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 35ff3021..53d5aa91 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -8,6 +8,7 @@ import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../runtime/device_identity_store.dart'; import '../runtime/runtime_bootstrap.dart'; +import '../runtime/desktop_platform_service.dart'; import '../runtime/gateway_runtime.dart'; import '../runtime/runtime_controllers.dart'; import '../runtime/runtime_models.dart'; @@ -25,6 +26,7 @@ class AppController extends ChangeNotifier { AppController({ SecureConfigStore? store, RuntimeCoordinator? runtimeCoordinator, + DesktopPlatformService? desktopPlatformService, }) { _store = store ?? SecureConfigStore(); @@ -58,6 +60,8 @@ class AppController extends ChangeNotifier { _cronJobsController = CronJobsController(_runtimeCoordinator.gateway); _devicesController = DevicesController(_runtimeCoordinator.gateway); _tasksController = DerivedTasksController(); + _desktopPlatformService = + desktopPlatformService ?? createDesktopPlatformService(); _attachChildListeners(); unawaited(_initialize()); } @@ -78,6 +82,7 @@ class AppController extends ChangeNotifier { late final CronJobsController _cronJobsController; late final DevicesController _devicesController; late final DerivedTasksController _tasksController; + late final DesktopPlatformService _desktopPlatformService; WorkspaceDestination _destination = WorkspaceDestination.assistant; ThemeMode _themeMode = ThemeMode.light; @@ -119,6 +124,10 @@ class AppController extends ChangeNotifier { CronJobsController get cronJobsController => _cronJobsController; DevicesController get devicesController => _devicesController; DerivedTasksController get tasksController => _tasksController; + DesktopIntegrationState get desktopIntegration => + _desktopPlatformService.state; + bool get supportsDesktopIntegration => desktopIntegration.isSupported; + bool get desktopPlatformBusy => _desktopPlatformBusy; GatewayConnectionSnapshot get connection => _runtime.snapshot; SettingsSnapshot get settings => _settingsController.snapshot; @@ -161,6 +170,7 @@ class AppController extends ChangeNotifier { CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode => configuredCodeAgentRuntimeMode; CodexCooperationState get codexCooperationState => _codexCooperationState; + bool _desktopPlatformBusy = false; Future loadAiGatewayApiKey() async { return (await _store.loadAiGatewayApiKey())?.trim() ?? ''; @@ -670,9 +680,77 @@ class AppController extends ChangeNotifier { _registerCodexExternalProvider(codexPath: sanitized.codexCliPath); 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) { _recomputeTasks(); } + notifyListeners(); + } + + Future refreshDesktopIntegration() async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await _desktopPlatformService.refresh(); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future saveLinuxDesktopConfig(LinuxDesktopConfig config) async { + await saveSettings(settings.copyWith(linuxDesktop: config)); + } + + Future 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 connectDesktopTunnel() async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await _desktopPlatformService.connectTunnel(); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future disconnectDesktopTunnel() async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await _desktopPlatformService.disconnectTunnel(); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future setLaunchAtLogin(bool enabled) async { + await saveSettings( + settings.copyWith(launchAtLogin: enabled), + refreshAfterSave: false, + ); } Future toggleAssistantNavigationDestination( @@ -818,6 +896,7 @@ class AppController extends ChangeNotifier { _devicesController.dispose(); _tasksController.dispose(); _store.dispose(); + _desktopPlatformService.dispose(); super.dispose(); } @@ -853,6 +932,8 @@ class AppController extends ChangeNotifier { } _modelsController.restoreFromSettings(settings.aiGateway); setActiveAppLanguage(settings.appLanguage); + await _desktopPlatformService.initialize(settings.linuxDesktop); + await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin); _registerCodexExternalProvider(); await _refreshCodexCliAvailability(); if (_disposed) { diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 6a98bba8..70be93c7 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -185,7 +185,9 @@ class _SettingsPageState extends State { ), ), _SwitchRow( - label: appText('显示 Dock 图标', 'Show dock icon'), + label: controller.supportsDesktopIntegration + ? appText('显示托盘图标', 'Show tray icon') + : appText('显示 Dock 图标', 'Show dock icon'), value: settings.showDockIcon, onChanged: (value) => _saveSettings( controller, @@ -203,6 +205,8 @@ class _SettingsPageState extends State { ], ), ), + if (controller.supportsDesktopIntegration) + _buildLinuxDesktopIntegration(context, controller, settings), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -242,6 +246,159 @@ class _SettingsPageState extends State { ]; } + 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 _buildWorkspace( BuildContext context, AppController controller, diff --git a/lib/runtime/desktop_platform_service.dart b/lib/runtime/desktop_platform_service.dart new file mode 100644 index 00000000..cc4c9126 --- /dev/null +++ b/lib/runtime/desktop_platform_service.dart @@ -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 initialize(LinuxDesktopConfig config); + + Future syncConfig(LinuxDesktopConfig config); + + Future refresh(); + + Future setMode(VpnMode mode); + + Future connectTunnel(); + + Future disconnectTunnel(); + + Future 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 initialize(LinuxDesktopConfig config) async { + _state = DesktopIntegrationState.unsupported(); + } + + @override + Future syncConfig(LinuxDesktopConfig config) async {} + + @override + Future refresh() async {} + + @override + Future setMode(VpnMode mode) async {} + + @override + Future connectTunnel() async {} + + @override + Future disconnectTunnel() async {} + + @override + Future 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 initialize(LinuxDesktopConfig config) async { + _config = config; + await _invokeVoid('configure', _encodeConfig(config)); + await refresh(); + } + + @override + Future syncConfig(LinuxDesktopConfig config) async { + _config = config; + await _invokeVoid('configure', _encodeConfig(config)); + await refresh(); + } + + @override + Future refresh() async { + final payload = await _channel.invokeMethod('getState'); + _state = DesktopIntegrationState.fromJson( + _decodeJsonMap(payload), + fallbackConfig: _config, + ); + } + + @override + Future setMode(VpnMode mode) async { + await _invokeVoid('setMode', mode.name); + await refresh(); + } + + @override + Future connectTunnel() async { + await _invokeVoid('connectTunnel'); + await refresh(); + } + + @override + Future disconnectTunnel() async { + await _invokeVoid('disconnectTunnel'); + await refresh(); + } + + @override + Future setLaunchAtLogin(bool enabled) async { + await _invokeVoid('setAutostart', enabled); + await refresh(); + } + + @override + void dispose() {} + + Future _invokeVoid(String method, [Object? arguments]) async { + try { + await _channel.invokeMethod(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 _decodeJsonMap(String? payload) { + if (payload == null || payload.trim().isEmpty) { + return const {}; + } + final decoded = jsonDecode(payload); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + return const {}; + } +} diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index e715a63d..58d7aaf3 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -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 toJson() { + return { + 'preferredMode': preferredMode.name, + 'vpnConnectionName': vpnConnectionName, + 'proxyHost': proxyHost, + 'proxyPort': proxyPort, + 'trayEnabled': trayEnabled, + }; + } + + factory LinuxDesktopConfig.fromJson(Map 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 toJson() { + return { + 'enabled': enabled, + 'host': host, + 'port': port, + 'backend': backend, + 'lastAppliedMode': lastAppliedMode.name, + }; + } + + factory SystemProxyState.fromJson( + Map 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 toJson() { + return { + 'available': available, + 'connected': connected, + 'connectionName': connectionName, + 'backend': backend, + 'lastError': lastError, + }; + } + + factory TunnelSessionState.fromJson( + Map 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 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 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() ?? const {}, + config: config, + ), + tunnel: TunnelSessionState.fromJson( + (json['tunnel'] as Map?)?.cast() ?? const {}, + config: config, + ), + statusMessage: json['statusMessage'] as String? ?? '', + ); + } +} + class GatewayConnectionProfile { const GatewayConnectionProfile({ required this.mode, @@ -526,6 +893,7 @@ class SettingsSnapshot { required this.accountUsername, required this.accountWorkspace, required this.accountLocalMode, + required this.linuxDesktop, required this.assistantExecutionTarget, required this.assistantPermissionLevel, required this.assistantNavigationDestinations, @@ -554,6 +922,7 @@ class SettingsSnapshot { final String accountUsername; final String accountWorkspace; final bool accountLocalMode; + final LinuxDesktopConfig linuxDesktop; final AssistantExecutionTarget assistantExecutionTarget; final AssistantPermissionLevel assistantPermissionLevel; final List assistantNavigationDestinations; @@ -583,6 +952,7 @@ class SettingsSnapshot { accountUsername: '', accountWorkspace: 'Default Workspace', accountLocalMode: true, + linuxDesktop: LinuxDesktopConfig.defaults(), assistantExecutionTarget: AssistantExecutionTarget.local, assistantPermissionLevel: AssistantPermissionLevel.defaultAccess, assistantNavigationDestinations: kAssistantNavigationDestinationDefaults, @@ -613,6 +983,7 @@ class SettingsSnapshot { String? accountUsername, String? accountWorkspace, bool? accountLocalMode, + LinuxDesktopConfig? linuxDesktop, AssistantExecutionTarget? assistantExecutionTarget, AssistantPermissionLevel? assistantPermissionLevel, List? assistantNavigationDestinations, @@ -641,6 +1012,7 @@ class SettingsSnapshot { accountUsername: accountUsername ?? this.accountUsername, accountWorkspace: accountWorkspace ?? this.accountWorkspace, accountLocalMode: accountLocalMode ?? this.accountLocalMode, + linuxDesktop: linuxDesktop ?? this.linuxDesktop, assistantExecutionTarget: assistantExecutionTarget ?? this.assistantExecutionTarget, assistantPermissionLevel: @@ -676,6 +1048,7 @@ class SettingsSnapshot { 'accountUsername': accountUsername, 'accountWorkspace': accountWorkspace, 'accountLocalMode': accountLocalMode, + 'linuxDesktop': linuxDesktop.toJson(), 'assistantExecutionTarget': assistantExecutionTarget.name, 'assistantPermissionLevel': assistantPermissionLevel.name, 'assistantNavigationDestinations': assistantNavigationDestinations @@ -753,6 +1126,9 @@ class SettingsSnapshot { json['accountWorkspace'] as String? ?? SettingsSnapshot.defaults().accountWorkspace, accountLocalMode: json['accountLocalMode'] as bool? ?? true, + linuxDesktop: LinuxDesktopConfig.fromJson( + (json['linuxDesktop'] as Map?)?.cast() ?? const {}, + ), assistantExecutionTarget: AssistantExecutionTargetCopy.fromJsonValue( json['assistantExecutionTarget'] as String?, ), diff --git a/linux/packaging/icons/xworkmate.svg b/linux/packaging/icons/xworkmate.svg new file mode 100644 index 00000000..735c4bb2 --- /dev/null +++ b/linux/packaging/icons/xworkmate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/linux/packaging/xworkmate-autostart.desktop b/linux/packaging/xworkmate-autostart.desktop new file mode 100644 index 00000000..9bc038ae --- /dev/null +++ b/linux/packaging/xworkmate-autostart.desktop @@ -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 diff --git a/linux/packaging/xworkmate.desktop b/linux/packaging/xworkmate.desktop new file mode 100644 index 00000000..9ad8750b --- /dev/null +++ b/linux/packaging/xworkmate.desktop @@ -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 diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index e97dabc7..8487dbcc 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -7,6 +7,7 @@ project(runner LANGUAGES CXX) # # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} + "desktop_platform_channel.cc" "main.cc" "my_application.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 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}") diff --git a/linux/runner/desktop_platform_channel.cc b/linux/runner/desktop_platform_channel.cc new file mode 100644 index 00000000..b26eee60 --- /dev/null +++ b/linux/runner/desktop_platform_channel.cc @@ -0,0 +1,734 @@ +#include "desktop_platform_channel.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 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& args) { + std::vector> 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& 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 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& 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(user_data)); +} + +void on_status_icon_activate(GtkStatusIcon*, gpointer user_data) { + show_window(static_cast(user_data)); +} + +void on_quit_activate(GtkMenuItem*, gpointer user_data) { + auto* self = static_cast(user_data); + g_application_quit(G_APPLICATION(self->application)); +} + +void on_use_proxy_activate(GtkMenuItem*, gpointer user_data) { + auto* self = static_cast(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(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(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(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(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(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; +} diff --git a/linux/runner/desktop_platform_channel.h b/linux/runner/desktop_platform_channel.h new file mode 100644 index 00000000..5e8bc487 --- /dev/null +++ b/linux/runner/desktop_platform_channel.h @@ -0,0 +1,23 @@ +#ifndef RUNNER_DESKTOP_PLATFORM_CHANNEL_H_ +#define RUNNER_DESKTOP_PLATFORM_CHANNEL_H_ + +#include + +#include + +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_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 3572667b..cd8fba85 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -5,11 +5,13 @@ #include #endif +#include "desktop_platform_channel.h" #include "flutter/generated_plugin_registrant.h" struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + DesktopPlatformChannel* desktop_platform_channel; }; 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)); 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)); } @@ -111,9 +115,10 @@ static void my_application_startup(GApplication* application) { // Implements GApplication::shutdown. 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); } diff --git a/scripts/linux-postinst.sh b/scripts/linux-postinst.sh new file mode 100644 index 00000000..bfe0a1a9 --- /dev/null +++ b/scripts/linux-postinst.sh @@ -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 diff --git a/scripts/linux-postrm.sh b/scripts/linux-postrm.sh new file mode 100644 index 00000000..bfe0a1a9 --- /dev/null +++ b/scripts/linux-postrm.sh @@ -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 diff --git a/scripts/package-linux-deb.sh b/scripts/package-linux-deb.sh new file mode 100644 index 00000000..5efeb5ee --- /dev/null +++ b/scripts/package-linux-deb.sh @@ -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" < "$spec_file" </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/" \; diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100644 index 00000000..70f70cda --- /dev/null +++ b/scripts/package-linux.sh @@ -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" diff --git a/test/features/settings_page_test.dart b/test/features/settings_page_test.dart index 6fc58f54..d8dfba0e 100644 --- a/test/features/settings_page_test.dart +++ b/test/features/settings_page_test.dart @@ -1,10 +1,122 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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'; +class _DesktopServiceStub implements DesktopPlatformService { + @override + DesktopIntegrationState get state => + DesktopIntegrationState.fromJson(const { + '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 initialize(LinuxDesktopConfig config) async {} + + @override + Future syncConfig(LinuxDesktopConfig config) async {} + + @override + Future refresh() async {} + + @override + Future setMode(VpnMode mode) async {} + + @override + Future connectTunnel() async {} + + @override + Future disconnectTunnel() async {} + + @override + Future setLaunchAtLogin(bool enabled) async {} + + @override + void dispose() {} +} + void main() { + testWidgets('SettingsPage theme chips update controller theme mode', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage(tester, child: SettingsPage(controller: controller)); + + await tester.tap(find.text('外观')); + await tester.pumpAndSettle(); + await tester.tap(find.text('深色')); + await tester.pumpAndSettle(); + + expect(controller.themeMode, ThemeMode.dark); + + await tester.tap(find.text('浅色')); + await tester.pumpAndSettle(); + expect(controller.themeMode, ThemeMode.light); + }); + + testWidgets('SettingsPage gateway tab exposes device pairing controls', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage(tester, child: SettingsPage(controller: controller)); + + await tester.tap(find.text('集成')); + await tester.pumpAndSettle(); + + expect(find.text('打开连接面板'), findsOneWidget); + expect( + find.byKey(const ValueKey('gateway-device-security-card')), + findsOneWidget, + ); + }); + + 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', ( WidgetTester tester, ) async { @@ -44,6 +156,6 @@ void main() { await tester.tap(find.text('清空')); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('当前没有运行日志。'), findsOneWidget); + expect(controller.runtimeLogs, isEmpty); }); } diff --git a/test/runtime/app_controller_desktop_platform_test.dart b/test/runtime/app_controller_desktop_platform_test.dart new file mode 100644 index 00000000..a50be253 --- /dev/null +++ b/test/runtime/app_controller_desktop_platform_test.dart @@ -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 { + '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 initialize(LinuxDesktopConfig config) async { + this.config = config; + } + + @override + Future 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 refresh() async {} + + @override + Future setMode(VpnMode mode) async { + _state = _state.copyWith( + mode: mode, + systemProxy: _state.systemProxy.copyWith(enabled: mode == VpnMode.proxy), + ); + } + + @override + Future connectTunnel() async { + _state = _state.copyWith( + mode: VpnMode.tunnel, + tunnel: _state.tunnel.copyWith(connected: true), + systemProxy: _state.systemProxy.copyWith(enabled: false), + ); + } + + @override + Future disconnectTunnel() async { + _state = _state.copyWith(tunnel: _state.tunnel.copyWith(connected: false)); + } + + @override + Future setLaunchAtLogin(bool enabled) async { + autostartEnabled = enabled; + } + + @override + void dispose() {} +} + +void main() { + test( + 'AppController syncs Linux desktop settings into platform service', + () async { + SharedPreferences.setMockInitialValues({}); + final service = _FakeDesktopPlatformService(); + final controller = AppController(desktopPlatformService: service); + addTearDown(controller.dispose); + + await Future.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); + }, + ); +} diff --git a/test/test_support.dart b/test/test_support.dart index dc02001e..e279f75d 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -7,8 +7,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; +import 'package:xworkmate/runtime/desktop_platform_service.dart'; -Future createTestController(WidgetTester tester) async { +Future createTestController( + WidgetTester tester, { + DesktopPlatformService? desktopPlatformService, +}) async { SharedPreferences.setMockInitialValues({}); final controller = AppController( store: SecureConfigStore( @@ -16,6 +20,7 @@ Future createTestController(WidgetTester tester) async { fallbackDirectoryPathResolver: () async => '${Directory.systemTemp.path}/xworkmate-widget-tests', ), + desktopPlatformService: desktopPlatformService, ); addTearDown(controller.dispose); await tester.pump(const Duration(milliseconds: 100));