diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eeb3037 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +EasyNode is a Linux server management panel with WebSSH, WebSFTP, RDP, Docker, AI chat, and batch operations. It's a monorepo with three modules: + +- **`server/`** — Koa.js backend (Node.js, CommonJS), REST API + Socket.IO WebSockets +- **`web/`** — Vue 3 + Vite SPA frontend +- **`native/`** — Flutter mobile app (Android/iOS/macOS/Linux, HarmonyOS in progress) + +## Common Commands + +### Development +```bash +yarn dev # Run web + server concurrently +yarn workspace server run local # Server only (nodemon, EXEC_ENV=local) +yarn workspace web run dev # Web only (Vite, port 18090) +cd native && flutter run # Native app on connected device +``` + +### Build +```bash +yarn workspace web run build # Vite production build +cd native && flutter build apk --release --split-per-abi # Android APK +``` + +### Lint +```bash +yarn workspace server run lint # Server ESLint +yarn workspace web run lint # Web ESLint +cd native && flutter analyze # Dart static analysis +``` + +### Test +```bash +yarn workspace server run test # All server tests +yarn workspace server run test:api # REST API auth tests only +yarn workspace server run test:ws # WebSocket tests only +cd native && flutter test # Flutter unit tests +``` + +## Architecture + +### Server +- **Koa 2** with middleware chain: IP filter → compression → history fallback → static files → response handler → body parser → access logging → auth → router +- **API prefix**: `/api/v1`, HTTP port 8082 (configurable via `HTTP_PORT`) +- **Database**: NeDB (embedded document DB), files in `server/app/db/`. Each domain has its own `.db` file. DB classes use singleton pattern via `server/app/utils/db-class.js` +- **WebSocket namespaces** (Socket.IO): `/terminal`, `/sftp-v2`, `/docker`, `/onekey`, `/server-status`, `/file-transfer` — all require auth via `verifyWsAuthSync` +- **Auth flow**: RSA-2048 keypair generated on first boot → client encrypts password with public key → server decrypts → JWT issued (AES-encrypted before sending) → session cookie stored in `session.db` +- **Global `logger`** object (log4js) available everywhere — no need to import + +### Web +- **Vue 3** Composition API + **Pinia** store + **Vue Router 4** (history mode) +- **Element Plus** components auto-imported via `unplugin-vue-components` +- **Path aliases**: `@` → `src/`, `@views`, `@utils`, `@store` +- **Vite proxy**: `/api/v1` and `/sftp-cache` → `http://localhost:8082` +- Terminal: xterm.js (`@xterm/xterm`), RDP: `guacamole-common-js`, AI: `ant-design-x-vue` + +### Native +- **Flutter** (SDK ^3.11.0) with **Riverpod** state management +- **Direct SSH**: connects to servers via `dartssh2` natively — does NOT go through the server's WebSocket. Fetches AES-GCM encrypted credentials from `POST /api/v1/native/ssh-connection`, decrypts locally +- **Feature-based layout**: `lib/features/` (`auth/`, `servers/`, `terminal/`, `shell/`, `docker/`, `scripts/`, `settings/`), state in `lib/state/` +- **Storage**: `AppStorage` (SharedPreferences) for preferences, `SecureAppStorage` (flutter_secure_storage) for tokens/passwords +- **i18n**: `lib/l10n/strings_en.dart` and `strings_zh.dart` with `AppLocalizations` — dotted key notation (`'sftp.newFile'`) +- **Theme**: Material 3, `AppColorTheme` extension accessed via `context.colors` + +## Code Conventions + +### Server & Web (JavaScript) +- CommonJS in server, ES modules in web — no TypeScript +- Single quotes, no semicolons, 2-space indent (`eslint.config.cjs` in each) +- Equality: `eqeqeq` enforced + +### Native (Dart) +- `flutter_lints` analysis rules +- snake_case filenames, PascalCase classes +- Riverpod providers in `lib/state/`, features in `lib/features/` + +## Key Patterns + +- **Web ↔ Server**: Axios for REST (token in header, `withCredentials` for cookies), Socket.IO for real-time ops (terminal I/O, SFTP, Docker) +- **Native ↔ Server**: Dio + cookie manager for REST API calls; SSH/SFTP operations happen directly on-device via `dartssh2` +- **Encryption interop**: Server uses `node-rsa` + `crypto-js`; native uses `pointycastle` + `basic_utils` — must stay compatible (RSA PKCS1, AES-GCM) +- **DB singleton**: All NeDB collections accessed via class instances from `db-class.js` — never instantiate DB directly diff --git a/native/lib/app.dart b/native/lib/app.dart index 33ecae6..b85633f 100644 --- a/native/lib/app.dart +++ b/native/lib/app.dart @@ -18,7 +18,7 @@ import 'features/shell/main_shell_page.dart'; import 'l10n/app_localizations.dart'; import 'state/auth_notifier.dart'; import 'state/auth_state.dart'; -import 'core/ui/app_color_theme.dart'; +import 'state/color_theme_notifier.dart'; import 'state/locale_notifier.dart'; import 'state/storage_providers.dart'; import 'state/theme_mode_notifier.dart'; @@ -257,6 +257,10 @@ class _AppRootState extends ConsumerState<_AppRoot> { ); } + final palette = ref.watch(colorThemeProvider); + final lightColors = palette.colorsFor(Brightness.light); + final darkColors = palette.colorsFor(Brightness.dark); + return MaterialApp( title: 'EasyNode', debugShowCheckedModeBanner: false, @@ -265,30 +269,33 @@ class _AppRootState extends ConsumerState<_AppRoot> { themeMode: ref.watch(themeModeProvider), theme: ThemeData( useMaterial3: true, - colorSchemeSeed: Colors.amber, - extensions: const [AppColorTheme.light], - appBarTheme: const AppBarTheme( + colorScheme: ColorScheme.fromSeed(seedColor: palette.seed), + extensions: [lightColors], + appBarTheme: AppBarTheme( centerTitle: false, titleSpacing: 4, titleTextStyle: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, - color: Color(0xFF2A2418), + color: lightColors.text, ), ), ), darkTheme: ThemeData( useMaterial3: true, brightness: Brightness.dark, - colorSchemeSeed: Colors.amber, - extensions: const [AppColorTheme.dark], - appBarTheme: const AppBarTheme( + colorScheme: ColorScheme.fromSeed( + seedColor: palette.seed, + brightness: Brightness.dark, + ), + extensions: [darkColors], + appBarTheme: AppBarTheme( centerTitle: false, titleSpacing: 4, titleTextStyle: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, - color: Color(0xFFE8E0D4), + color: darkColors.text, ), ), ), diff --git a/native/lib/core/storage/app_storage.dart b/native/lib/core/storage/app_storage.dart index 4587fe7..6ee4b9e 100644 --- a/native/lib/core/storage/app_storage.dart +++ b/native/lib/core/storage/app_storage.dart @@ -97,10 +97,15 @@ class AppStorage { // ── App theme ── static const _keyThemeMode = 'app.themeMode'; + static const _keyColorTheme = 'app.colorTheme'; String get themeMode => _prefs.getString(_keyThemeMode) ?? 'system'; Future setThemeMode(String v) => _prefs.setString(_keyThemeMode, v); + /// The default is the neutral Light theme; `amber` keeps the original palette. + String get colorTheme => _prefs.getString(_keyColorTheme) ?? 'light'; + Future setColorTheme(String v) => _prefs.setString(_keyColorTheme, v); + // ── SFTP create suggestions cache ── static const _keySftpFileCache = 'sftp.createFileCache'; @@ -119,7 +124,13 @@ class AppStorage { // ── Tab order ── static const _keyTabOrder = 'app.tabOrder'; - static const defaultTabOrder = ['servers', 'sftp', 'docker', 'scripts', 'settings']; + static const defaultTabOrder = [ + 'servers', + 'sftp', + 'docker', + 'scripts', + 'settings', + ]; List get tabOrder { final stored = _prefs.getStringList(_keyTabOrder); diff --git a/native/lib/core/ui/app_color_theme.dart b/native/lib/core/ui/app_color_theme.dart index 3b45789..d65ffe0 100644 --- a/native/lib/core/ui/app_color_theme.dart +++ b/native/lib/core/ui/app_color_theme.dart @@ -41,7 +41,51 @@ class AppColorTheme extends ThemeExtension { final Color dangerBorder; final Color fontOnPrimary; - static const light = AppColorTheme( + /// The default neutral light palette. + static const defaultLight = AppColorTheme( + canvas: Color(0xFFF7F9FC), + card: Color(0xFFFFFFFF), + chip: Color(0xFFF0F4F9), + banner: Color(0xFFEAF2FD), + primary: Color(0xFF2B6FCB), + text: Color(0xFF182235), + muted: Color(0xFF596A82), + softMuted: Color(0xFF73849B), + border: Color(0xFFDDE6F0), + strongBorder: Color(0xFFBFCEE0), + accent: Color(0xFF3B82F6), + accentSoft: Color(0xFFEAF2FD), + success: Color(0xFF1E825A), + warning: Color(0xFFA96208), + danger: Color(0xFFC23F42), + dangerSoft: Color(0xFFFFF0F0), + dangerBorder: Color(0xFFF2C7C8), + fontOnPrimary: Color(0xFFFFFFFF), + ); + + static const defaultDark = AppColorTheme( + canvas: Color(0xFF111A29), + card: Color(0xFF192538), + chip: Color(0xFF223149), + banner: Color(0xFF1B3961), + primary: Color(0xFF91C2FF), + text: Color(0xFFF2F6FC), + muted: Color(0xFFB4C2D6), + softMuted: Color(0xFF8B9AB0), + border: Color(0xFF33455F), + strongBorder: Color(0xFF4A5F7C), + accent: Color(0xFF68A6FF), + accentSoft: Color(0xFF1B3961), + success: Color(0xFF49C58D), + warning: Color(0xFFF2BD4A), + danger: Color(0xFFF27A7C), + dangerSoft: Color(0xFF40242D), + dangerBorder: Color(0xFF6E3B47), + fontOnPrimary: Color(0xFF0E1726), + ); + + /// The original warm amber palette. + static const amberLight = AppColorTheme( canvas: Color(0xFFF7EFE0), card: Color(0xFFFBF5E6), chip: Color(0xFFF4ECD7), @@ -62,7 +106,7 @@ class AppColorTheme extends ThemeExtension { fontOnPrimary: Color(0xFFF7EFE0), ); - static const dark = AppColorTheme( + static const amberDark = AppColorTheme( canvas: Color(0xFF121212), card: Color(0xFF1E1E1E), chip: Color(0xFF2A2A2A), diff --git a/native/lib/features/shell/settings_tab.dart b/native/lib/features/shell/settings_tab.dart index 3c8943b..944a542 100644 --- a/native/lib/features/shell/settings_tab.dart +++ b/native/lib/features/shell/settings_tab.dart @@ -12,6 +12,7 @@ import '../../state/tab_order_notifier.dart'; import '../../state/credential_list_notifier.dart'; import '../../state/host_list_notifier.dart'; import '../../state/locale_notifier.dart'; +import '../../state/color_theme_notifier.dart'; import '../../state/theme_mode_notifier.dart'; import '../../state/package_info_provider.dart'; import '../../state/plus_discount_notifier.dart'; @@ -186,11 +187,50 @@ class SettingsTab extends ConsumerWidget { void _showThemePicker(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); final current = ref.read(themeModeProvider); + final currentPalette = ref.read(colorThemeProvider); showDialog( context: context, builder: (ctx) => SimpleDialog( title: Text(l.tr('settings.theme.title')), children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + child: Text( + l.tr('settings.theme.colorTheme'), + style: Theme.of(ctx).textTheme.labelLarge, + ), + ), + for (final entry in [ + ( + AppThemePalette.light, + l.tr('settings.theme.lightTheme'), + Icons.light_mode, + ), + ( + AppThemePalette.amber, + l.tr('settings.theme.amberTheme'), + Icons.wb_sunny_outlined, + ), + ]) + ListTile( + leading: Icon(entry.$3), + title: Text(entry.$2), + trailing: entry.$1 == currentPalette + ? Icon(Icons.check, color: context.colors.primary) + : null, + onTap: () { + ref.read(colorThemeProvider.notifier).setPalette(entry.$1); + Navigator.of(ctx).pop(); + }, + ), + const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + child: Text( + l.tr('settings.theme.mode'), + style: Theme.of(ctx).textTheme.labelLarge, + ), + ), for (final entry in [ ( ThemeMode.system, @@ -224,6 +264,21 @@ class SettingsTab extends ConsumerWidget { }; } + static String _themeSubtitle( + AppLocalizations l, + AppThemePalette palette, + ThemeMode mode, + ) { + final paletteLabel = switch (palette) { + AppThemePalette.light => l.tr('settings.theme.lightTheme'), + AppThemePalette.amber => l.tr('settings.theme.amberTheme'), + }; + return l.trf('settings.theme.summary', [ + paletteLabel, + _themeModeLabel(l, mode), + ]); + } + void _showTabOrderDialog(BuildContext context, WidgetRef ref) { showDialog( context: context, @@ -415,8 +470,9 @@ class SettingsTab extends ConsumerWidget { SettingsRow( icon: Icons.palette_outlined, title: l.tr('settings.theme.title'), - subtitle: _themeModeLabel( + subtitle: _themeSubtitle( l, + ref.watch(colorThemeProvider), ref.watch(themeModeProvider), ), onTap: () => _showThemePicker(context, ref), diff --git a/native/lib/l10n/strings_en.dart b/native/lib/l10n/strings_en.dart index 878ef99..9fa3801 100644 --- a/native/lib/l10n/strings_en.dart +++ b/native/lib/l10n/strings_en.dart @@ -311,6 +311,11 @@ const Map stringsEn = { 'settings.proxy.subtitle': 'HTTP / SOCKS5 outbound proxies', 'settings.theme.title': 'Appearance', + 'settings.theme.colorTheme': 'Color theme', + 'settings.theme.mode': 'Display mode', + 'settings.theme.lightTheme': 'Default theme', + 'settings.theme.amberTheme': 'Amber theme', + 'settings.theme.summary': '{0} · {1}', 'settings.theme.system': 'Follow System', 'settings.theme.light': 'Light', 'settings.theme.dark': 'Dark', diff --git a/native/lib/l10n/strings_zh.dart b/native/lib/l10n/strings_zh.dart index ff53165..ae1c33c 100644 --- a/native/lib/l10n/strings_zh.dart +++ b/native/lib/l10n/strings_zh.dart @@ -297,6 +297,11 @@ const Map stringsZh = { 'settings.proxy.subtitle': 'HTTP / SOCKS5 出站代理', 'settings.theme.title': '主题外观', + 'settings.theme.colorTheme': '配色主题', + 'settings.theme.mode': '显示模式', + 'settings.theme.lightTheme': '默认主题', + 'settings.theme.amberTheme': '琥珀色主题', + 'settings.theme.summary': '{0} · {1}', 'settings.theme.system': '跟随系统', 'settings.theme.light': '亮色', 'settings.theme.dark': '暗黑', diff --git a/native/lib/state/color_theme_notifier.dart b/native/lib/state/color_theme_notifier.dart new file mode 100644 index 0000000..e01da1e --- /dev/null +++ b/native/lib/state/color_theme_notifier.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/storage/app_storage.dart'; +import '../core/ui/app_color_theme.dart'; +import 'storage_providers.dart'; + +enum AppThemePalette { + light, + amber; + + AppColorTheme colorsFor(Brightness brightness) { + return switch ((this, brightness)) { + (AppThemePalette.light, Brightness.light) => AppColorTheme.defaultLight, + (AppThemePalette.light, Brightness.dark) => AppColorTheme.defaultDark, + (AppThemePalette.amber, Brightness.light) => AppColorTheme.amberLight, + (AppThemePalette.amber, Brightness.dark) => AppColorTheme.amberDark, + }; + } + + Color get seed => switch (this) { + AppThemePalette.light => const Color(0xFF2B6FCB), + AppThemePalette.amber => Colors.amber, + }; +} + +class ColorThemeNotifier extends StateNotifier { + ColorThemeNotifier(this._storage) : super(_fromString(_storage.colorTheme)); + + final AppStorage _storage; + + static AppThemePalette _fromString(String value) { + return switch (value) { + 'amber' => AppThemePalette.amber, + _ => AppThemePalette.light, + }; + } + + Future setPalette(AppThemePalette palette) async { + if (state == palette) return; + await _storage.setColorTheme(palette.name); + state = palette; + } +} + +final colorThemeProvider = + StateNotifierProvider((ref) { + return ColorThemeNotifier(ref.watch(appStorageProvider)); + });