From 10891f6cb1cf6e35fb896790077c75a9a192246b Mon Sep 17 00:00:00 2001 From: chaos-zhu Date: Mon, 25 May 2026 23:25:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4docks-superpowers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-mobile-native-terminal-implementation.md | 1802 ----------------- ...-19-mobile-redesign-i18n-implementation.md | 1529 -------------- ...e-native-proxy-jump-host-implementation.md | 1519 -------------- ...-mobile-sftp-text-editor-implementation.md | 1756 ---------------- .../2026-05-16-mobile-iteration-1-design.md | 268 --- ...026-05-16-mobile-native-terminal-design.md | 357 ---- .../2026-05-19-mobile-redesign-i18n-design.md | 277 --- ...23-mobile-native-proxy-jump-host-design.md | 259 --- ...26-05-23-mobile-sftp-text-editor-design.md | 291 --- 9 files changed, 8058 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-16-mobile-native-terminal-implementation.md delete mode 100644 docs/superpowers/plans/2026-05-19-mobile-redesign-i18n-implementation.md delete mode 100644 docs/superpowers/plans/2026-05-23-mobile-native-proxy-jump-host-implementation.md delete mode 100644 docs/superpowers/plans/2026-05-23-mobile-sftp-text-editor-implementation.md delete mode 100644 docs/superpowers/specs/2026-05-16-mobile-iteration-1-design.md delete mode 100644 docs/superpowers/specs/2026-05-16-mobile-native-terminal-design.md delete mode 100644 docs/superpowers/specs/2026-05-19-mobile-redesign-i18n-design.md delete mode 100644 docs/superpowers/specs/2026-05-23-mobile-native-proxy-jump-host-design.md delete mode 100644 docs/superpowers/specs/2026-05-23-mobile-sftp-text-editor-design.md diff --git a/docs/superpowers/plans/2026-05-16-mobile-native-terminal-implementation.md b/docs/superpowers/plans/2026-05-16-mobile-native-terminal-implementation.md deleted file mode 100644 index 8f70e0b..0000000 --- a/docs/superpowers/plans/2026-05-16-mobile-native-terminal-implementation.md +++ /dev/null @@ -1,1802 +0,0 @@ -# Mobile Native Terminal Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build the first EasyNode Flutter mobile app with login, server list, and native SSH terminal connection. - -**Architecture:** Reuse existing EasyNode login and host-list APIs, add only `POST /api/v1/mobile/ssh-connection` for encrypted SSH credentials, and keep native SSH entirely inside the Flutter app. Mobile secrets use platform secure storage, ordinary preferences store only low-sensitivity values, and HTTP users receive an explicit risk warning before login. - -**Tech Stack:** Flutter/Dart, `dio`, `cookie_jar`, `dio_cookie_manager`, `flutter_secure_storage`, `shared_preferences`, `pointycastle`, `basic_utils`, `dartssh2`, `xterm`, Node/Koa, Node `crypto`. - ---- - -## File Structure Map - -Server files: - -- Create `server/app/utils/mobile-crypto.js`: AES-256-GCM response envelope helpers and temporary key validation. -- Create `server/app/controller/mobile.js`: mobile-only SSH credential controller. -- Modify `server/app/router/routes.js`: register `POST /mobile/ssh-connection`. -- Create `server/test/test-mobile-crypto.js`: pure crypto helper tests. -- Create `server/test/test-mobile-ssh-payload.js`: SSH payload shaping tests. -- Modify `server/package.json`: add `test:mobile` script. - -Flutter core files: - -- Replace `mobile/lib/main.dart`: app bootstrap. -- Create `mobile/lib/app.dart`: Material app, routes, and top-level controllers. -- Create `mobile/lib/core/utils/validators.dart`: server URL normalization and HTTP risk detection. -- Create `mobile/lib/core/utils/jwt_expiry.dart`: Web-compatible login-expiry conversion. -- Create `mobile/lib/core/storage/device_id.dart`: generate and persist a per-install UUID v4 device id. -- Create `mobile/lib/core/crypto/aes_gcm_crypto.dart`: AES-GCM decrypt helper for mobile credential responses. -- Create `mobile/lib/core/crypto/rsa_crypto.dart`: RSA public-key encryption compatible with EasyNode login. -- Create `mobile/lib/core/storage/app_storage.dart`: ordinary preference storage. -- Create `mobile/lib/core/storage/secure_storage.dart`: secure storage wrapper. -- Create `mobile/lib/core/api/cookie_store.dart`: session cookie persistence. -- Create `mobile/lib/core/api/api_client.dart`: EasyNode HTTP client. -- Create `mobile/lib/core/api/api_result.dart`: typed API error/result model. - -Flutter feature files: - -- Create `mobile/lib/features/auth/auth_session.dart`: token, device id, session state. -- Create `mobile/lib/features/auth/login_controller.dart`: login orchestration. -- Create `mobile/lib/features/auth/login_page.dart`: login UI. -- Create `mobile/lib/features/servers/server_model.dart`: host-list model. -- Create `mobile/lib/features/servers/server_repository.dart`: host-list and SSH credential requests. -- Create `mobile/lib/features/servers/server_list_page.dart`: mobile server list UI. -- Create `mobile/lib/features/terminal/ssh_connection_config.dart`: decrypted SSH config model. -- Create `mobile/lib/features/terminal/ssh_terminal_controller.dart`: `dartssh2` to xterm bridge. -- Create `mobile/lib/features/terminal/terminal_page.dart`: terminal screen. -- Create `mobile/lib/features/terminal/terminal_toolbar.dart`: mobile terminal shortcut controls. - -Flutter test files: - -- Create `mobile/test/core/utils/validators_test.dart`. -- Create `mobile/test/core/utils/jwt_expiry_test.dart`. -- Create `mobile/test/core/storage/device_id_test.dart`. -- Create `mobile/test/core/crypto/aes_gcm_crypto_test.dart`. -- Create `mobile/test/features/auth/login_controller_test.dart`. -- Create `mobile/test/features/servers/server_model_test.dart`. -- Create `mobile/test/features/servers/server_repository_test.dart`. -- Create `mobile/test/features/auth/login_page_test.dart`. -- Create `mobile/test/features/servers/server_list_page_test.dart`. - -Platform files: - -- Modify `mobile/pubspec.yaml`: add mobile dependencies. -- Modify `mobile/android/app/src/main/AndroidManifest.xml`: add `INTERNET` and cleartext policy. -- Create `mobile/android/app/src/main/res/xml/network_security_config.xml`: allow user-provided HTTP servers. -- Modify `mobile/ios/Runner/Info.plist`: add ATS exception for user-provided HTTP. - -## Task 1: Server AES-GCM Mobile Envelope - -**Files:** -- Create: `server/app/utils/mobile-crypto.js` -- Create: `server/test/test-mobile-crypto.js` -- Modify: `server/package.json` - -- [ ] **Step 1: Write failing crypto tests** - -Create `server/test/test-mobile-crypto.js`: - -```js -const assert = require('assert') -const { encryptJsonForMobile, decryptMobileJsonForTest, assertTempKey } = require('../app/utils/mobile-crypto') - -function testRejectsShortKey() { - assert.throws(() => assertTempKey(Buffer.alloc(16)), /temporary key must be 32 bytes/) -} - -function testEncryptsAndDecryptsJson() { - const key = Buffer.from('0123456789abcdef0123456789abcdef') - const payload = { host: '127.0.0.1', password: 'secret' } - const envelope = encryptJsonForMobile(payload, key) - - assert.strictEqual(envelope.alg, 'AES-256-GCM') - assert.ok(envelope.iv) - assert.ok(envelope.tag) - assert.ok(envelope.ciphertext) - assert.ok(!JSON.stringify(envelope).includes('secret')) - - const decoded = decryptMobileJsonForTest(envelope, key) - assert.deepStrictEqual(decoded, payload) -} - -testRejectsShortKey() -testEncryptsAndDecryptsJson() -console.log('test-mobile-crypto passed') -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `node server/test/test-mobile-crypto.js` - -Expected: FAIL with `Cannot find module '../app/utils/mobile-crypto'`. - -- [ ] **Step 3: Implement the crypto helper** - -Create `server/app/utils/mobile-crypto.js`: - -```js -const crypto = require('crypto') - -function assertTempKey(key) { - if (!Buffer.isBuffer(key) || key.length !== 32) { - throw new Error('temporary key must be 32 bytes') - } -} - -function encryptJsonForMobile(payload, key) { - assertTempKey(key) - const iv = crypto.randomBytes(12) - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) - const plaintext = Buffer.from(JSON.stringify(payload), 'utf8') - const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) - const tag = cipher.getAuthTag() - - return { - alg: 'AES-256-GCM', - iv: iv.toString('base64'), - tag: tag.toString('base64'), - ciphertext: ciphertext.toString('base64') - } -} - -function decryptMobileJsonForTest(envelope, key) { - assertTempKey(key) - const decipher = crypto.createDecipheriv( - 'aes-256-gcm', - key, - Buffer.from(envelope.iv, 'base64') - ) - decipher.setAuthTag(Buffer.from(envelope.tag, 'base64')) - const plaintext = Buffer.concat([ - decipher.update(Buffer.from(envelope.ciphertext, 'base64')), - decipher.final() - ]) - return JSON.parse(plaintext.toString('utf8')) -} - -module.exports = { - assertTempKey, - encryptJsonForMobile, - decryptMobileJsonForTest -} -``` - -- [ ] **Step 4: Add the server test script** - -Modify `server/package.json` scripts: - -```json -"test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js" -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `node server/test/test-mobile-crypto.js` - -Expected: PASS and prints `test-mobile-crypto passed`. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add server/app/utils/mobile-crypto.js server/test/test-mobile-crypto.js server/package.json -git commit -m "test: add mobile crypto envelope" -``` - -## Task 2: Server SSH Payload Shaping - -**Files:** -- Create: `server/app/controller/mobile.js` -- Create: `server/test/test-mobile-ssh-payload.js` - -- [ ] **Step 1: Write failing payload tests** - -Create `server/test/test-mobile-ssh-payload.js`: - -```js -const assert = require('assert') -const { toMobileSshPayload } = require('../app/controller/mobile') - -function testPasswordPayload() { - const payload = toMobileSshPayload('h1', 'prod', { - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'p@ss' - }) - - assert.deepStrictEqual(payload, { - hostId: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'p@ss', - privateKey: '', - passphrase: '' - }) -} - -function testPrivateKeyPayload() { - const payload = toMobileSshPayload('h2', 'keyhost', { - host: '10.0.0.3', - port: 2222, - username: 'ubuntu', - authType: 'privateKey', - privateKey: 'KEY', - passphrase: 'phrase' - }) - - assert.strictEqual(payload.authType, 'privateKey') - assert.strictEqual(payload.privateKey, 'KEY') - assert.strictEqual(payload.password, '') - assert.strictEqual(payload.passphrase, 'phrase') -} - -function testRejectsUnsupportedAuth() { - assert.throws(() => toMobileSshPayload('h3', 'unsupported', { - host: '10.0.0.4', - port: 22, - username: 'root', - authType: 'keyboard' - }), /unsupported mobile ssh auth type/) -} - -testPasswordPayload() -testPrivateKeyPayload() -testRejectsUnsupportedAuth() -console.log('test-mobile-ssh-payload passed') -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `node server/test/test-mobile-ssh-payload.js` - -Expected: FAIL because `server/app/controller/mobile.js` does not exist. - -- [ ] **Step 3: Implement payload helper and controller skeleton** - -Create `server/app/controller/mobile.js`: - -```js -const { RSADecryptAsync } = require('../utils/encrypt') -const { getConnectionOptions } = require('../socket/terminal') -const { encryptJsonForMobile } = require('../utils/mobile-crypto') - -function toMobileSshPayload(hostId, name, authInfo) { - const { host, port, username, authType } = authInfo - if (!['password', 'privateKey'].includes(authType)) { - throw new Error(`unsupported mobile ssh auth type: ${ authType || 'empty' }`) - } - - const numericPort = Number(port) - return { - hostId, - name, - host, - port: Number.isFinite(numericPort) && numericPort > 0 ? numericPort : 22, - username, - authType, - password: authType === 'password' ? authInfo.password || '' : '', - privateKey: authType === 'privateKey' ? authInfo.privateKey || '' : '', - passphrase: authType === 'privateKey' ? authInfo.passphrase || '' : '' - } -} - -async function getMobileSshConnection({ request, res }) { - try { - const { hostId, encryptedKey } = request.body || {} - if (!hostId || !encryptedKey) { - return res.fail({ msg: 'missing params' }) - } - - const tempKeyText = await RSADecryptAsync(encryptedKey) - const tempKey = Buffer.from(tempKeyText, 'base64') - const { authInfo, name } = await getConnectionOptions(hostId) - const payload = toMobileSshPayload(hostId, name, authInfo) - const data = encryptJsonForMobile(payload, tempKey) - - return res.success({ data, msg: 'success' }) - } catch (error) { - // Detail goes to the server log; the wire response is intentionally generic. - logger.error('getMobileSshConnection error:', error.message) - return res.fail({ msg: 'mobile ssh connection failed' }) - } -} - -module.exports = { - getMobileSshConnection, - toMobileSshPayload -} -``` - -> **Logger note:** the EasyNode runtime exposes `global.logger`, so this controller calls `logger.*` directly without an explicit `require`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `node server/test/test-mobile-ssh-payload.js` - -Expected: PASS and prints `test-mobile-ssh-payload passed`. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add server/app/controller/mobile.js server/test/test-mobile-ssh-payload.js -git commit -m "test: add mobile ssh payload shaping" -``` - -## Task 3: Register Mobile SSH API - -**Files:** -- Modify: `server/app/router/routes.js` - -- [ ] **Step 1: Add route import** - -Modify the top of `server/app/router/routes.js`: - -```js -const { getMobileSshConnection } = require('../controller/mobile') -``` - -- [ ] **Step 2: Add route group** - -Add near the terminal routes: - -```js -const mobile = [ - { - method: 'post', - path: '/mobile/ssh-connection', - controller: getMobileSshConnection - } -] -``` - -- [ ] **Step 3: Include route group in export** - -Modify the final `module.exports = [].concat(...)` call to include `mobile`: - -```js -module.exports = [].concat( - ssh, - host, - user, - notify, - group, - scripts, - scriptGroup, - onekey, - log, - aiConfig, - proxy, - terminalConfig, - serverListConfig, - terminal, - mobile -) -``` - -- [ ] **Step 4: Run server mobile tests** - -Run: `yarn workspace server run test:mobile` - -Expected: both mobile tests pass. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add server/app/router/routes.js -git commit -m "feat: register mobile ssh connection API" -``` - -## Task 4: Flutter Dependencies and Platform Network Policy - -**Files:** -- Modify: `mobile/pubspec.yaml` -- Modify: `mobile/android/app/src/main/AndroidManifest.xml` -- Create: `mobile/android/app/src/main/res/xml/network_security_config.xml` -- Modify: `mobile/ios/Runner/Info.plist` - -- [ ] **Step 1: Add dependencies** - -Run from `mobile`: - -```bash -flutter pub add dio cookie_jar dio_cookie_manager flutter_secure_storage shared_preferences pointycastle basic_utils dartssh2 xterm -``` - -Expected: `mobile/pubspec.yaml` and `mobile/pubspec.lock` update. - -- [ ] **Step 2: Add Android network permission and config reference** - -Modify `mobile/android/app/src/main/AndroidManifest.xml`: - -```xml - - - -``` - -- [ ] **Step 3: Add Android network security config** - -Create `mobile/android/app/src/main/res/xml/network_security_config.xml`: - -```xml - - - - -``` - -- [ ] **Step 4: Add iOS ATS exception** - -Inside the root `` in `mobile/ios/Runner/Info.plist`, add: - -```xml - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - -``` - -- [ ] **Step 5: Run dependency verification** - -Run from `mobile`: `flutter pub get` - -Expected: exits 0. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add mobile/pubspec.yaml mobile/pubspec.lock mobile/android/app/src/main/AndroidManifest.xml mobile/android/app/src/main/res/xml/network_security_config.xml mobile/ios/Runner/Info.plist -git commit -m "chore: add mobile app network dependencies" -``` - -## Task 5: Flutter Core Utilities - -**Files:** -- Create: `mobile/lib/core/utils/validators.dart` -- Create: `mobile/lib/core/utils/jwt_expiry.dart` -- Create: `mobile/lib/core/storage/device_id.dart` -- Create: `mobile/test/core/utils/validators_test.dart` -- Create: `mobile/test/core/utils/jwt_expiry_test.dart` -- Create: `mobile/test/core/storage/device_id_test.dart` - -> **Note on `jwtExpiresFor` values:** the strings returned by this helper are passed directly to the existing EasyNode `/api/v1/login` endpoint as the `jwtExpires` field. The server forwards `jwtExpires` to `jsonwebtoken`'s `sign({ expiresIn })`, which accepts the string formats produced here (`1h`, `s`, `3d`, `7d`). Verify against `server/app/controller/user.js` (`beforeLoginHandler`, around line 97) before changing the values. - -- [ ] **Step 1: Write utility tests** - -Create `mobile/test/core/utils/validators_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/utils/validators.dart'; - -void main() { - test('normalizes server address by trimming and removing trailing slash', () { - expect(normalizeServerAddress(' http://127.0.0.1:8082/ '), 'http://127.0.0.1:8082'); - }); - - test('detects http risk', () { - expect(isHttpAddress('http://127.0.0.1:8082'), isTrue); - expect(isHttpAddress('https://example.com'), isFalse); - }); - - test('rejects unsupported scheme', () { - expect(() => normalizeServerAddress('ftp://example.com'), throwsA(isA())); - }); -} -``` - -Create `mobile/test/core/utils/jwt_expiry_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/utils/jwt_expiry.dart'; - -void main() { - test('maps temporary expiry to one hour', () { - expect(jwtExpiresFor(LoginExpiry.temporary), '1h'); - }); - - test('maps three days and seven days', () { - expect(jwtExpiresFor(LoginExpiry.threeDays), '3d'); - expect(jwtExpiresFor(LoginExpiry.sevenDays), '7d'); - }); -} -``` - -Create `mobile/test/core/storage/device_id_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/storage/device_id.dart'; - -class _FakeStore implements DeviceIdStore { - String? _value; - - @override - Future read() async => _value; - - @override - Future write(String value) async { - _value = value; - } -} - -void main() { - test('generates and persists a uuid v4 device id on first read', () async { - final store = _FakeStore(); - final id = await loadOrCreateDeviceId(store); - expect(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$').hasMatch(id), isTrue); - final again = await loadOrCreateDeviceId(store); - expect(again, id); - }); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run from `mobile`: `flutter test test/core` - -Expected: FAIL because utility files do not exist. - -- [ ] **Step 3: Implement utilities** - -Create `mobile/lib/core/utils/validators.dart`: - -```dart -String normalizeServerAddress(String input) { - final value = input.trim(); - final uri = Uri.tryParse(value); - if (uri == null || !uri.hasScheme || uri.host.isEmpty) { - throw const FormatException('请输入有效的服务端地址'); - } - if (uri.scheme != 'http' && uri.scheme != 'https') { - throw const FormatException('服务端地址仅支持 http 或 https'); - } - return value.endsWith('/') ? value.substring(0, value.length - 1) : value; -} - -bool isHttpAddress(String input) { - final uri = Uri.tryParse(input.trim()); - return uri?.scheme == 'http'; -} -``` - -Create `mobile/lib/core/utils/jwt_expiry.dart`: - -```dart -enum LoginExpiry { temporary, currentDay, threeDays, sevenDays } - -String jwtExpiresFor(LoginExpiry expiry, {DateTime? now}) { - switch (expiry) { - case LoginExpiry.temporary: - return '1h'; - case LoginExpiry.currentDay: - final current = now ?? DateTime.now(); - final tomorrow = DateTime(current.year, current.month, current.day + 1); - final seconds = tomorrow.difference(current).inSeconds; - return '${seconds}s'; - case LoginExpiry.threeDays: - return '3d'; - case LoginExpiry.sevenDays: - return '7d'; - } -} - -int jwtExpireAtFor(LoginExpiry expiry, {DateTime? now}) { - final current = now ?? DateTime.now(); - final expires = jwtExpiresFor(expiry, now: current); - final match = RegExp(r'^(\d+)([smhd])$').firstMatch(expires); - if (match == null) throw const FormatException('invalid jwt expiry'); - final count = int.parse(match.group(1)!); - final unit = match.group(2)!; - final multiplier = switch (unit) { - 's' => 1000, - 'm' => 60 * 1000, - 'h' => 60 * 60 * 1000, - 'd' => 24 * 60 * 60 * 1000, - _ => 1000, - }; - return current.millisecondsSinceEpoch + count * multiplier; -} -``` - -Create `mobile/lib/core/storage/device_id.dart`: - -```dart -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:uuid/uuid.dart'; - -abstract class DeviceIdStore { - Future read(); - Future write(String value); -} - -class SecureDeviceIdStore implements DeviceIdStore { - SecureDeviceIdStore(this._storage); - final FlutterSecureStorage _storage; - static const _key = 'mobileDeviceId'; - - @override - Future read() => _storage.read(key: _key); - - @override - Future write(String value) => _storage.write(key: _key, value: value); -} - -Future loadOrCreateDeviceId(DeviceIdStore store) async { - final existing = await store.read(); - if (existing != null && existing.isNotEmpty) return existing; - final id = const Uuid().v4(); - await store.write(id); - return id; -} -``` - -- [ ] **Step 4: Add missing `uuid` dependency** - -Run from `mobile`: `flutter pub add uuid` - -Expected: `uuid` is added because `loadOrCreateDeviceId` uses it. - -- [ ] **Step 5: Run tests to verify they pass** - -Run from `mobile`: `flutter test test/core` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add mobile/lib/core mobile/test/core mobile/pubspec.yaml mobile/pubspec.lock -git commit -m "test: add mobile core utilities" -``` - -## Task 6: AES-GCM and RSA Crypto in Flutter - -**Files:** -- Create: `mobile/lib/core/crypto/aes_gcm_crypto.dart` -- Create: `mobile/lib/core/crypto/rsa_crypto.dart` -- Create: `mobile/test/core/crypto/aes_gcm_crypto_test.dart` - -- [ ] **Step 1: Write AES-GCM response decrypt test** - -Create `mobile/test/core/crypto/aes_gcm_crypto_test.dart`: - -```dart -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pointycastle/export.dart'; -import 'package:mobile/core/crypto/aes_gcm_crypto.dart'; - -void main() { - test('decrypts AES-GCM envelope', () { - final key = utf8.encode('0123456789abcdef0123456789abcdef'); - final iv = List.generate(12, (i) => i); - final cipher = GCMBlockCipher(AESEngine()) - ..init(true, AEADParameters(KeyParameter(Uint8List.fromList(key)), 128, Uint8List.fromList(iv), Uint8List(0))); - final plain = utf8.encode('{"host":"127.0.0.1"}'); - final encrypted = cipher.process(Uint8List.fromList(plain)); - final tag = encrypted.sublist(encrypted.length - 16); - final ciphertext = encrypted.sublist(0, encrypted.length - 16); - - final decoded = decryptAesGcmJson( - key: Uint8List.fromList(key), - ivBase64: base64Encode(iv), - tagBase64: base64Encode(tag), - ciphertextBase64: base64Encode(ciphertext), - ); - - expect(decoded['host'], '127.0.0.1'); - }); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `mobile`: `flutter test test/core/crypto/aes_gcm_crypto_test.dart` - -Expected: FAIL because `aes_gcm_crypto.dart` does not exist. - -- [ ] **Step 3: Implement AES-GCM helper** - -Create `mobile/lib/core/crypto/aes_gcm_crypto.dart`: - -```dart -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:basic_utils/basic_utils.dart'; -import 'package:pointycastle/export.dart'; - -Map decryptAesGcmJson({ - required Uint8List key, - required String ivBase64, - required String tagBase64, - required String ciphertextBase64, -}) { - if (key.length != 32) { - throw ArgumentError('temporary key must be 32 bytes'); - } - final iv = base64Decode(ivBase64); - final tag = base64Decode(tagBase64); - final ciphertext = base64Decode(ciphertextBase64); - final cipher = GCMBlockCipher(AESEngine()) - ..init(false, AEADParameters(KeyParameter(key), 128, Uint8List.fromList(iv), Uint8List(0))); - final combined = Uint8List.fromList([...ciphertext, ...tag]); - final plaintext = cipher.process(combined); - return jsonDecode(utf8.decode(plaintext)) as Map; -} -``` - -- [ ] **Step 4: Implement RSA helper API** - -Create `mobile/lib/core/crypto/rsa_crypto.dart`: - -```dart -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:pointycastle/export.dart'; - -class RsaCrypto { - String encryptPassword(String publicKeyPem, String plaintext) { - final key = _parsePublicKey(publicKeyPem); - final engine = PKCS1Encoding(RSAEngine())..init(true, PublicKeyParameter(key)); - return base64Encode(engine.process(Uint8List.fromList(utf8.encode(plaintext)))); - } - - String encryptTemporaryKey(String publicKeyPem, Uint8List keyBytes) { - // Server side decrypts RSA to a utf8 string via `rsakey.decrypt(ct, 'utf8')` - // and then does `Buffer.from(text, 'base64')`. We therefore base64-encode the - // raw key bytes before RSA-encrypting, so the round trip restores the 32B key. - final key = _parsePublicKey(publicKeyPem); - final engine = PKCS1Encoding(RSAEngine())..init(true, PublicKeyParameter(key)); - final base64Text = base64Encode(keyBytes); - final encrypted = engine.process(Uint8List.fromList(utf8.encode(base64Text))); - return base64Encode(encrypted); - } - - RSAPublicKey _parsePublicKey(String publicKeyPem) { - return CryptoUtils.rsaPublicKeyFromPem(publicKeyPem); - } -} -``` - -- [ ] **Step 5: Run AES-GCM test to verify it passes** - -Run from `mobile`: `flutter test test/core/crypto/aes_gcm_crypto_test.dart` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add mobile/lib/core/crypto mobile/test/core/crypto mobile/pubspec.yaml mobile/pubspec.lock -git commit -m "test: add mobile response crypto" -``` - -## Task 7: Storage and Cookie Persistence - -**Files:** -- Create: `mobile/lib/core/storage/app_storage.dart` -- Create: `mobile/lib/core/storage/secure_storage.dart` -- Create: `mobile/lib/core/api/cookie_store.dart` - -- [ ] **Step 1: Implement ordinary storage wrapper** - -Create `mobile/lib/core/storage/app_storage.dart`: - -```dart -import 'package:shared_preferences/shared_preferences.dart'; - -class AppStorage { - AppStorage(this._prefs); - final SharedPreferences _prefs; - - String get serverAddress => _prefs.getString('serverAddress') ?? ''; - Future setServerAddress(String value) => _prefs.setString('serverAddress', value); - - String get username => _prefs.getString('username') ?? ''; - Future setUsername(String value) => _prefs.setString('username', value); - - bool get savePassword => _prefs.getBool('savePassword') ?? false; - Future setSavePassword(bool value) => _prefs.setBool('savePassword', value); -} -``` - -- [ ] **Step 2: Implement secure storage wrapper** - -Create `mobile/lib/core/storage/secure_storage.dart`: - -```dart -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -class SecureAppStorage { - SecureAppStorage(this._storage); - final FlutterSecureStorage _storage; - - Future readPassword(String serverAddress, String username) { - return _storage.read(key: 'password:$serverAddress:$username'); - } - - Future writePassword(String serverAddress, String username, String password) { - return _storage.write(key: 'password:$serverAddress:$username', value: password); - } - - Future deletePassword(String serverAddress, String username) { - return _storage.delete(key: 'password:$serverAddress:$username'); - } - - Future readToken() => _storage.read(key: 'token'); - Future writeToken(String token) => _storage.write(key: 'token', value: token); - Future deleteToken() => _storage.delete(key: 'token'); - - Future readSessionCookie() => _storage.read(key: 'sessionCookie'); - Future writeSessionCookie(String value) => _storage.write(key: 'sessionCookie', value: value); - Future deleteSessionCookie() => _storage.delete(key: 'sessionCookie'); -} -``` - -- [ ] **Step 3: Implement cookie persistence helper** - -Create `mobile/lib/core/api/cookie_store.dart`: - -```dart -import '../storage/secure_storage.dart'; - -class SessionCookieStore { - SessionCookieStore(this._storage); - final SecureAppStorage _storage; - - Future saveFromSetCookieHeaders(List headers) async { - for (final header in headers) { - final firstPart = header.split(';').first.trim(); - if (firstPart.startsWith('session=')) { - await _storage.writeSessionCookie(firstPart); - return; - } - } - } - - Future readCookieHeader() => _storage.readSessionCookie(); -} -``` - -- [ ] **Step 4: Run analyzer** - -Run from `mobile`: `flutter analyze` - -Expected: no errors from the new storage files. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add mobile/lib/core/storage mobile/lib/core/api/cookie_store.dart -git commit -m "feat: add mobile credential storage" -``` - -## Task 8: API Client and Login Controller - -**Files:** -- Create: `mobile/lib/core/api/api_client.dart` -- Create: `mobile/lib/core/api/api_result.dart` -- Create: `mobile/lib/features/auth/auth_session.dart` -- Create: `mobile/lib/features/auth/login_controller.dart` -- Create: `mobile/test/features/auth/login_controller_test.dart` - -- [ ] **Step 1: Write login controller test with fake API** - -Create `mobile/test/features/auth/login_controller_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/auth/login_controller.dart'; - -void main() { - test('blocks http login until user confirms risk', () async { - final controller = LoginController.fake(); - final result = await controller.login( - serverAddress: 'http://127.0.0.1:8082', - username: 'root', - password: 'secret', - mfa2Token: '', - httpRiskAccepted: false, - savePassword: false, - ); - - expect(result.requiresHttpRiskConfirmation, isTrue); - }); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `mobile`: `flutter test test/features/auth/login_controller_test.dart` - -Expected: FAIL because `login_controller.dart` does not exist. - -- [ ] **Step 3: Implement API result model** - -Create `mobile/lib/core/api/api_result.dart`: - -```dart -class ApiFailure implements Exception { - ApiFailure(this.message, {this.statusCode}); - final String message; - final int? statusCode; - - @override - String toString() => message; -} -``` - -- [ ] **Step 4: Implement auth session** - -Create `mobile/lib/features/auth/auth_session.dart`: - -```dart -class AuthSession { - const AuthSession({ - required this.token, - required this.deviceId, - }); - - final String token; - final String deviceId; -} -``` - -- [ ] **Step 5: Implement minimal login controller** - -Create `mobile/lib/features/auth/login_controller.dart`: - -```dart -import '../../core/utils/validators.dart'; - -class LoginResult { - const LoginResult({ - this.success = false, - this.requiresHttpRiskConfirmation = false, - this.message = '', - }); - - final bool success; - final bool requiresHttpRiskConfirmation; - final String message; -} - -class LoginController { - LoginController(); - factory LoginController.fake() => LoginController(); - - Future login({ - required String serverAddress, - required String username, - required String password, - required String mfa2Token, - required bool httpRiskAccepted, - required bool savePassword, - }) async { - final normalized = normalizeServerAddress(serverAddress); - if (isHttpAddress(normalized) && !httpRiskAccepted) { - return const LoginResult(requiresHttpRiskConfirmation: true); - } - if (username.trim().isEmpty) { - return const LoginResult(message: '请输入用户名'); - } - if (password.isEmpty) { - return const LoginResult(message: '请输入密码'); - } - return const LoginResult(success: true); - } -} -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run from `mobile`: `flutter test test/features/auth/login_controller_test.dart` - -Expected: PASS. - -- [ ] **Step 7: Implement HTTP API client shell** - -Create `mobile/lib/core/api/api_client.dart`: - -```dart -import 'package:dio/dio.dart'; -import 'api_result.dart'; -import 'cookie_store.dart'; - -class ApiClient { - ApiClient({ - required String serverAddress, - required SessionCookieStore cookieStore, - String? token, - }) : _cookieStore = cookieStore, - _dio = Dio(BaseOptions(baseUrl: '$serverAddress/api/v1', connectTimeout: const Duration(seconds: 30))) { - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) async { - if (token != null && token.isNotEmpty) options.headers['token'] = token; - final cookie = await _cookieStore.readCookieHeader(); - if (cookie != null && cookie.isNotEmpty) options.headers['Cookie'] = cookie; - handler.next(options); - }, - onResponse: (response, handler) async { - final cookies = response.headers.map['set-cookie']; - if (cookies != null) await _cookieStore.saveFromSetCookieHeaders(cookies); - handler.next(response); - }, - )); - } - - final Dio _dio; - final SessionCookieStore _cookieStore; - - Future getPublicKey() async { - final response = await _dio.get('/get-pub-pem'); - return response.data['data'] as String; - } - - Future> getJson(String path) async { - try { - final response = await _dio.get(path); - return response.data as Map; - } on DioException catch (error) { - throw ApiFailure(error.response?.data?['msg']?.toString() ?? error.message ?? '网络错误', - statusCode: error.response?.statusCode); - } - } - - Future> postJson(String path, Map data) async { - try { - final response = await _dio.post(path, data: data); - return response.data as Map; - } on DioException catch (error) { - throw ApiFailure(error.response?.data?['msg']?.toString() ?? error.message ?? '网络错误', - statusCode: error.response?.statusCode); - } - } -} -``` - -- [ ] **Step 8: Commit** - -Run: - -```bash -git add mobile/lib/core/api mobile/lib/features/auth mobile/test/features/auth -git commit -m "test: add mobile login controller" -``` - -## Task 9: Server Model and Repository - -**Files:** -- Create: `mobile/lib/features/servers/server_model.dart` -- Create: `mobile/lib/features/servers/server_repository.dart` -- Create: `mobile/test/features/servers/server_model_test.dart` -- Create: `mobile/test/features/servers/server_repository_test.dart` -- Create: `mobile/lib/features/terminal/ssh_connection_config.dart` - -- [ ] **Step 1: Write server model test** - -Create `mobile/test/features/servers/server_model_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/servers/server_model.dart'; - -void main() { - test('parses host-list item', () { - final server = ServerModel.fromJson({ - 'id': 'h1', - 'name': 'prod', - 'host': '10.0.0.2', - 'port': 22, - 'username': 'root', - 'authType': 'password', - 'isConfig': true, - }); - - expect(server.id, 'h1'); - expect(server.connectionLabel, 'root@10.0.0.2:22'); - expect(server.canConnect, isTrue); - }); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `mobile`: `flutter test test/features/servers/server_model_test.dart` - -Expected: FAIL because `server_model.dart` does not exist. - -- [ ] **Step 3: Implement server model** - -Create `mobile/lib/features/servers/server_model.dart`: - -```dart -class ServerModel { - const ServerModel({ - required this.id, - required this.name, - required this.host, - required this.port, - required this.username, - required this.authType, - required this.isConfig, - }); - - final String id; - final String name; - final String host; - final int port; - final String username; - final String authType; - final bool isConfig; - - String get connectionLabel => '$username@$host:$port'; - bool get canConnect => isConfig && id.isNotEmpty; - - factory ServerModel.fromJson(Map json) { - return ServerModel( - id: (json['id'] ?? json['_id'] ?? '').toString(), - name: (json['name'] ?? '').toString(), - host: (json['host'] ?? '').toString(), - port: int.tryParse((json['port'] ?? 22).toString()) ?? 22, - username: (json['username'] ?? '').toString(), - authType: (json['authType'] ?? '').toString(), - isConfig: json['isConfig'] == true, - ); - } -} -``` - -- [ ] **Step 4: Implement SSH config model** - -Create `mobile/lib/features/terminal/ssh_connection_config.dart`: - -```dart -class SshConnectionConfig { - const SshConnectionConfig({ - required this.hostId, - required this.name, - required this.host, - required this.port, - required this.username, - required this.authType, - required this.password, - required this.privateKey, - required this.passphrase, - }); - - final String hostId; - final String name; - final String host; - final int port; - final String username; - final String authType; - final String password; - final String privateKey; - final String passphrase; - - factory SshConnectionConfig.fromJson(Map json) { - return SshConnectionConfig( - hostId: json['hostId'].toString(), - name: json['name'].toString(), - host: json['host'].toString(), - port: int.tryParse(json['port'].toString()) ?? 22, - username: json['username'].toString(), - authType: json['authType'].toString(), - password: (json['password'] ?? '').toString(), - privateKey: (json['privateKey'] ?? '').toString(), - passphrase: (json['passphrase'] ?? '').toString(), - ); - } -} -``` - -- [ ] **Step 5: Implement repository shell** - -Create `mobile/lib/features/servers/server_repository.dart`: - -```dart -import 'dart:typed_data'; -import '../../core/api/api_client.dart'; -import '../../core/crypto/aes_gcm_crypto.dart'; -import '../terminal/ssh_connection_config.dart'; -import 'server_model.dart'; - -class ServerRepository { - ServerRepository(this._apiClient); - final ApiClient _apiClient; - - Future> fetchServers() async { - final response = await _apiClient.getJson('/host-list'); - final data = response['data'] as List; - return data.map((item) => ServerModel.fromJson(item as Map)).toList(); - } - - SshConnectionConfig decodeEncryptedConnection(Map envelope, Uint8List tempKey) { - final data = decryptAesGcmJson( - key: tempKey, - ivBase64: envelope['iv'].toString(), - tagBase64: envelope['tag'].toString(), - ciphertextBase64: envelope['ciphertext'].toString(), - ); - return SshConnectionConfig.fromJson(data); - } -} -``` - -- [ ] **Step 6: Run model test** - -Run from `mobile`: `flutter test test/features/servers/server_model_test.dart` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add mobile/lib/features/servers mobile/lib/features/terminal/ssh_connection_config.dart mobile/test/features/servers -git commit -m "test: add mobile server models" -``` - -## Task 10: Login Page and App Routing - -**Files:** -- Replace: `mobile/lib/main.dart` -- Create: `mobile/lib/app.dart` -- Create: `mobile/lib/features/auth/login_page.dart` -- Create: `mobile/test/features/auth/login_page_test.dart` - -- [ ] **Step 1: Write login page widget test** - -Create `mobile/test/features/auth/login_page_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/auth/login_page.dart'; - -void main() { - testWidgets('renders login fields', (tester) async { - await tester.pumpWidget(const MaterialApp(home: LoginPage())); - - expect(find.text('EasyNode'), findsOneWidget); - expect(find.byKey(const Key('serverAddressField')), findsOneWidget); - expect(find.byKey(const Key('usernameField')), findsOneWidget); - expect(find.byKey(const Key('passwordField')), findsOneWidget); - expect(find.text('登录'), findsOneWidget); - }); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `mobile`: `flutter test test/features/auth/login_page_test.dart` - -Expected: FAIL because `login_page.dart` does not exist. - -- [ ] **Step 3: Implement app entry** - -Replace `mobile/lib/main.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'app.dart'; - -void main() { - runApp(const EasyNodeMobileApp()); -} -``` - -Create `mobile/lib/app.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'features/auth/login_page.dart'; - -class EasyNodeMobileApp extends StatelessWidget { - const EasyNodeMobileApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'EasyNode', - theme: ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff2563eb)), - home: const LoginPage(), - ); - } -} -``` - -- [ ] **Step 4: Implement login page UI skeleton** - -Create `mobile/lib/features/auth/login_page.dart`: - -```dart -import 'package:flutter/material.dart'; - -class LoginPage extends StatelessWidget { - const LoginPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(20), - children: [ - const SizedBox(height: 24), - Text('EasyNode', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 24), - const TextField( - key: Key('serverAddressField'), - decoration: InputDecoration(labelText: '服务端地址'), - ), - const SizedBox(height: 12), - const TextField( - key: Key('usernameField'), - decoration: InputDecoration(labelText: '用户名'), - ), - const SizedBox(height: 12), - const TextField( - key: Key('passwordField'), - obscureText: true, - decoration: InputDecoration(labelText: '密码'), - ), - const SizedBox(height: 12), - SwitchListTile( - value: false, - onChanged: (_) {}, - title: const Text('保存密码'), - contentPadding: EdgeInsets.zero, - ), - const SizedBox(height: 16), - FilledButton(onPressed: () {}, child: const Text('登录')), - ], - ), - ), - ); - } -} -``` - -- [ ] **Step 5: Run widget test** - -Run from `mobile`: `flutter test test/features/auth/login_page_test.dart` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add mobile/lib/main.dart mobile/lib/app.dart mobile/lib/features/auth/login_page.dart mobile/test/features/auth/login_page_test.dart -git commit -m "test: add mobile login page" -``` - -## Task 11: Server List Page - -**Files:** -- Create: `mobile/lib/features/servers/server_list_page.dart` -- Create: `mobile/test/features/servers/server_list_page_test.dart` - -- [ ] **Step 1: Write server list widget test** - -Create `mobile/test/features/servers/server_list_page_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/servers/server_list_page.dart'; -import 'package:mobile/features/servers/server_model.dart'; - -void main() { - testWidgets('renders server and connect action', (tester) async { - const servers = [ - ServerModel( - id: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - isConfig: true, - ), - ]; - - await tester.pumpWidget(const MaterialApp(home: ServerListPage(initialServers: servers))); - - expect(find.text('prod'), findsOneWidget); - expect(find.text('root@10.0.0.2:22'), findsOneWidget); - expect(find.text('连接'), findsOneWidget); - }); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `mobile`: `flutter test test/features/servers/server_list_page_test.dart` - -Expected: FAIL because `server_list_page.dart` does not exist. - -- [ ] **Step 3: Implement server list page** - -Create `mobile/lib/features/servers/server_list_page.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'server_model.dart'; - -class ServerListPage extends StatelessWidget { - const ServerListPage({ - super.key, - this.initialServers = const [], - }); - - final List initialServers; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('服务器')), - body: initialServers.isEmpty - ? const Center(child: Text('暂无服务器')) - : ListView.separated( - itemCount: initialServers.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final server = initialServers[index]; - return ListTile( - title: Text(server.name), - subtitle: Text(server.connectionLabel), - trailing: FilledButton( - onPressed: server.canConnect ? () {} : null, - child: const Text('连接'), - ), - ); - }, - ), - ); - } -} -``` - -- [ ] **Step 4: Run widget test** - -Run from `mobile`: `flutter test test/features/servers/server_list_page_test.dart` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add mobile/lib/features/servers/server_list_page.dart mobile/test/features/servers/server_list_page_test.dart -git commit -m "test: add mobile server list page" -``` - -## Task 12: Terminal Controller and Terminal Page - -**Files:** -- Create: `mobile/lib/features/terminal/ssh_terminal_controller.dart` -- Create: `mobile/lib/features/terminal/terminal_page.dart` -- Create: `mobile/lib/features/terminal/terminal_toolbar.dart` - -- [ ] **Step 1: Implement terminal controller interface** - -Create `mobile/lib/features/terminal/ssh_terminal_controller.dart`: - -```dart -import 'dart:async'; -import 'dart:convert'; -import 'package:dartssh2/dartssh2.dart'; -import 'package:xterm/xterm.dart'; -import 'ssh_connection_config.dart'; - -class SshTerminalController { - SshTerminalController({ - required this.config, - Terminal? terminal, - }) : terminal = terminal ?? Terminal(); - - final SshConnectionConfig config; - final Terminal terminal; - SSHClient? _client; - SSHSession? _session; - StreamSubscription>? _stdoutSub; - - Future connect() async { - final socket = await SSHSocket.connect(config.host, config.port); - // dartssh2 `SSHKeyPair.fromPem` returns `List` already, so we - // assign the call result directly without wrapping it in another list. - final identities = config.authType == 'privateKey' - ? SSHKeyPair.fromPem(config.privateKey, config.passphrase) - : null; - _client = SSHClient( - socket, - username: config.username, - onPasswordRequest: config.authType == 'password' ? () => config.password : null, - identities: identities, - ); - _session = await _client!.shell(); - _stdoutSub = _session!.stdout.listen((data) { - terminal.write(utf8.decode(data, allowMalformed: true)); - }); - terminal.onOutput = (data) { - _session?.write(utf8.encode(data)); - }; - } - - void resize(int columns, int rows) { - _session?.resizeTerminal(columns, rows); - } - - void writeInput(String data) { - // Toolbar shortcuts must reach the SSH session, not the local xterm buffer, - // so they are written through the session here. - _session?.write(utf8.encode(data)); - } - - Future disconnect() async { - await _stdoutSub?.cancel(); - _session?.close(); - _client?.close(); - } -} -``` - -- [ ] **Step 2: Implement terminal toolbar** - -Create `mobile/lib/features/terminal/terminal_toolbar.dart`: - -```dart -import 'package:flutter/material.dart'; - -class TerminalToolbar extends StatelessWidget { - const TerminalToolbar({ - super.key, - required this.onInput, - required this.onDisconnect, - }); - - final ValueChanged onInput; - final VoidCallback onDisconnect; - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - children: [ - TextButton(onPressed: () => onInput('\x1b'), child: const Text('Esc')), - TextButton(onPressed: () => onInput('\t'), child: const Text('Tab')), - TextButton(onPressed: () => onInput('\r'), child: const Text('Enter')), - TextButton(onPressed: onDisconnect, child: const Text('断开')), - ], - ), - ), - ); - } -} -``` - -- [ ] **Step 3: Implement terminal page** - -Create `mobile/lib/features/terminal/terminal_page.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:xterm/ui.dart'; -import 'ssh_connection_config.dart'; -import 'ssh_terminal_controller.dart'; -import 'terminal_toolbar.dart'; - -class TerminalPage extends StatefulWidget { - const TerminalPage({super.key, required this.config}); - final SshConnectionConfig config; - - @override - State createState() => _TerminalPageState(); -} - -class _TerminalPageState extends State { - late final SshTerminalController controller; - - @override - void initState() { - super.initState(); - controller = SshTerminalController(config: widget.config); - controller.connect(); - } - - @override - void dispose() { - controller.disconnect(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(widget.config.name)), - body: Column( - children: [ - Expanded(child: TerminalView(controller.terminal)), - TerminalToolbar( - onInput: controller.writeInput, - onDisconnect: () => Navigator.of(context).pop(), - ), - ], - ), - ); - } -} -``` - -- [ ] **Step 4: Run analyzer** - -Run from `mobile`: `flutter analyze` - -Expected: no analyzer errors from terminal files. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add mobile/lib/features/terminal -git commit -m "feat: add native ssh terminal screen" -``` - -## Task 13: End-to-End Wiring - -**Files:** -- Modify: `mobile/lib/features/auth/login_controller.dart` -- Modify: `mobile/lib/features/auth/login_page.dart` -- Modify: `mobile/lib/features/servers/server_repository.dart` -- Modify: `mobile/lib/features/servers/server_list_page.dart` -- Modify: `mobile/lib/app.dart` - -- [ ] **Step 1: Wire successful login to server list** - -Modify `mobile/lib/app.dart` so the root can switch from `LoginPage` to `ServerListPage` after login. Use a small `StatefulWidget` and pass a callback instead of adding a routing framework. - -- [ ] **Step 2: Wire login controller to real API** - -Modify `LoginController.login` to: - -1. normalize server address -2. enforce HTTP confirmation -3. fetch `/get-pub-pem` -4. RSA-encrypt password -5. post `/login` (request body: `loginName`, `ciphertext`, `jwtExpires`, `jwtExpireAt`, optional `mfa2Token` — matches the existing Web payload; the server returns its own `deviceId` in the response) -6. persist the response `deviceId` plus a per-install secure-storage `deviceId` (UUID v4) for future use -7. store token, session cookie, address, username, and optional password - -- [ ] **Step 3: Wire server list refresh** - -Modify `ServerListPage` to accept a `ServerRepository`, load servers on `initState`, and provide pull-to-refresh. - -- [ ] **Step 4: Wire connect action** - -Modify `ServerRepository` to: - -1. generate a 32-byte temporary key -2. RSA-encrypt it with the saved public key -3. call `POST /mobile/ssh-connection` -4. decrypt the returned AES-GCM envelope -5. return `SshConnectionConfig` - -- [ ] **Step 5: Navigate to terminal** - -Modify `ServerListPage` connect button to call the repository and push `TerminalPage(config: config)`. - -- [ ] **Step 6: Run mobile tests** - -Run from `mobile`: `flutter test` - -Expected: all Flutter tests pass. - -- [ ] **Step 7: Run server mobile tests** - -Run: `yarn workspace server run test:mobile` - -Expected: all server mobile tests pass. - -- [ ] **Step 8: Commit** - -Run: - -```bash -git add mobile/lib mobile/test -git commit -m "feat: wire mobile login server list and terminal" -``` - -## Task 14: Final Verification - -**Files:** -- Verify changed files only. - -- [ ] **Step 1: Run server tests** - -Run: `yarn workspace server run test:mobile` - -Expected: PASS. - -- [ ] **Step 2: Run Flutter tests** - -Run from `mobile`: `flutter test` - -Expected: PASS. - -- [ ] **Step 3: Run Flutter analyzer** - -Run from `mobile`: `flutter analyze` - -Expected: no errors. - -- [ ] **Step 4: Build Android debug APK** - -Run from `mobile`: `flutter build apk --debug` - -Expected: debug APK builds. - -- [ ] **Step 5: Manual smoke test** - -Use a local EasyNode server with at least one password-auth host and one private-key host: - -1. Launch app on Android emulator or device. -2. Enter HTTP server address and verify the warning appears. -3. Accept warning and log in. -4. Verify server address and username remain on returning to login. -5. Open server list. -6. Connect password-auth host. -7. Disconnect and return. -8. Connect private-key host. - -- [ ] **Step 6: Commit final fixes** - -If verification required changes, commit them: - -```bash -git add mobile server -git commit -m "fix: stabilize mobile native terminal verification" -``` - -If no changes were required, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-05-19-mobile-redesign-i18n-implementation.md b/docs/superpowers/plans/2026-05-19-mobile-redesign-i18n-implementation.md deleted file mode 100644 index 0488a14..0000000 --- a/docs/superpowers/plans/2026-05-19-mobile-redesign-i18n-implementation.md +++ /dev/null @@ -1,1529 +0,0 @@ -# Mobile Redesign and I18n Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Redesign all existing Flutter mobile screens using the confirmed DESIGN.md-inspired visual system and add English / Simplified Chinese language switching on Login and Settings. - -**Architecture:** Add focused theme and i18n units under `mobile/lib/core`, then apply them to existing pages without changing auth, host-list, SSH, or terminal-session behavior. Locale state is owned by `_AppRoot`, persisted through `AppStorage`, and passed to Login/Main Shell for switch controls. - -**Tech Stack:** Flutter 3 / Dart, Material 3, Riverpod, SharedPreferences, flutter_test. - ---- - -## File Structure - -- Create `mobile/lib/core/i18n/app_locale.dart`: locale enum, system-locale resolver, display names. -- Create `mobile/lib/core/i18n/app_strings.dart`: English and Simplified Chinese string bundles plus lookup helpers. -- Create `mobile/lib/core/i18n/app_localizations.dart`: inherited localization scope and `context.strings`. -- Modify `mobile/lib/core/storage/app_storage.dart`: persist optional locale code. -- Create `mobile/lib/core/ui/app_tokens.dart`: semantic colors, spacing, radii, and terminal surface tokens. -- Create `mobile/lib/core/ui/app_theme.dart`: Material 3 light/dark-compatible theme definitions. -- Create `mobile/lib/core/ui/language_switcher.dart`: reusable language switch button/sheet. -- Create `mobile/lib/core/ui/notice_box.dart`: shared warning/error notice. -- Create `mobile/lib/core/ui/empty_state.dart`: shared empty-state view for SFTP/Scripts and list states. -- Modify `mobile/lib/app.dart`: bootstrap locale, install app theme/localization scope, pass language controls. -- Modify `mobile/lib/features/auth/login_page.dart`: redesign and localize Login. -- Modify `mobile/lib/features/shell/main_shell_page.dart`: localize navigation and pass locale callback to Settings. -- Modify `mobile/lib/features/shell/settings_tab.dart`: redesign and add language switching. -- Modify `mobile/lib/features/shell/sftp_tab.dart`: replace garbled text with localized empty state. -- Modify `mobile/lib/features/shell/scripts_tab.dart`: replace garbled text with localized empty state. -- Modify `mobile/lib/features/servers/servers_tab.dart`: redesign and localize Servers tab. -- Modify `mobile/lib/features/terminal/terminal_shell_page.dart`: token-driven toolbar styling. -- Modify `mobile/lib/features/terminal/terminal_toolbar.dart`: token-driven shortcut styling. -- Update tests under `mobile/test/features` and add tests under `mobile/test/core`. - -## Task 1: I18n Model and Storage - -**Files:** -- Create: `mobile/lib/core/i18n/app_locale.dart` -- Create: `mobile/lib/core/i18n/app_strings.dart` -- Create: `mobile/lib/core/i18n/app_localizations.dart` -- Modify: `mobile/lib/core/storage/app_storage.dart` -- Test: `mobile/test/core/i18n/app_locale_test.dart` -- Test: `mobile/test/core/storage/app_storage_locale_test.dart` - -- [ ] **Step 1: Write failing locale resolver tests** - -Create `mobile/test/core/i18n/app_locale_test.dart`: - -```dart -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/i18n/app_locale.dart'; - -void main() { - test('resolves saved locale before system locale', () { - expect( - AppLocale.resolve(savedCode: 'en', systemLocale: const Locale('zh', 'CN')), - AppLocale.english, - ); - expect( - AppLocale.resolve(savedCode: 'zh_Hans', systemLocale: const Locale('en')), - AppLocale.simplifiedChinese, - ); - }); - - test('falls back to system Chinese when no saved locale exists', () { - expect( - AppLocale.resolve(savedCode: null, systemLocale: const Locale('zh', 'TW')), - AppLocale.simplifiedChinese, - ); - }); - - test('falls back to English for unsupported saved or system locale', () { - expect( - AppLocale.resolve(savedCode: 'fr', systemLocale: const Locale('ja')), - AppLocale.english, - ); - }); -} -``` - -- [ ] **Step 2: Run locale resolver test and verify it fails** - -Run: - -```bash -cd mobile -flutter test test/core/i18n/app_locale_test.dart -``` - -Expected: FAIL because `mobile/core/i18n/app_locale.dart` does not exist. - -- [ ] **Step 3: Implement locale enum and resolver** - -Create `mobile/lib/core/i18n/app_locale.dart`: - -```dart -import 'package:flutter/widgets.dart'; - -enum AppLocale { - english('en', Locale('en'), 'English'), - simplifiedChinese('zh_Hans', Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), '中文'); - - const AppLocale(this.storageCode, this.flutterLocale, this.label); - - final String storageCode; - final Locale flutterLocale; - final String label; - - static AppLocale fromStorageCode(String? code) { - return switch (code) { - 'zh_Hans' => AppLocale.simplifiedChinese, - 'en' => AppLocale.english, - _ => AppLocale.english, - }; - } - - static AppLocale resolve({ - required String? savedCode, - required Locale? systemLocale, - }) { - if (savedCode == AppLocale.english.storageCode || - savedCode == AppLocale.simplifiedChinese.storageCode) { - return fromStorageCode(savedCode); - } - if (systemLocale?.languageCode.toLowerCase() == 'zh') { - return AppLocale.simplifiedChinese; - } - return AppLocale.english; - } -} -``` - -- [ ] **Step 4: Run locale resolver test and verify it passes** - -Run: - -```bash -cd mobile -flutter test test/core/i18n/app_locale_test.dart -``` - -Expected: PASS. - -- [ ] **Step 5: Write failing storage locale tests** - -Create `mobile/test/core/storage/app_storage_locale_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/storage/app_storage.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - test('persists and clears locale code', () async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final storage = AppStorage(prefs); - - expect(storage.localeCode, isNull); - - await storage.setLocaleCode('zh_Hans'); - expect(storage.localeCode, 'zh_Hans'); - - await storage.setLocaleCode(null); - expect(storage.localeCode, isNull); - }); -} -``` - -- [ ] **Step 6: Run storage locale test and verify it fails** - -Run: - -```bash -cd mobile -flutter test test/core/storage/app_storage_locale_test.dart -``` - -Expected: FAIL because `AppStorage.localeCode` is not implemented. - -- [ ] **Step 7: Add locale persistence to AppStorage** - -Modify `mobile/lib/core/storage/app_storage.dart`: - -```dart -static const _keyLocaleCode = 'localeCode'; - -String? get localeCode => _prefs.getString(_keyLocaleCode); -Future setLocaleCode(String? value) { - if (value == null || value.isEmpty) { - return _prefs.remove(_keyLocaleCode); - } - return _prefs.setString(_keyLocaleCode, value); -} -``` - -Keep the existing server address, username, and save-password methods unchanged. - -- [ ] **Step 8: Run Task 1 tests** - -Run: - -```bash -cd mobile -flutter test test/core/i18n/app_locale_test.dart test/core/storage/app_storage_locale_test.dart -``` - -Expected: PASS. - -- [ ] **Step 9: Commit Task 1** - -Run: - -```bash -git add mobile/lib/core/i18n/app_locale.dart mobile/lib/core/storage/app_storage.dart mobile/test/core/i18n/app_locale_test.dart mobile/test/core/storage/app_storage_locale_test.dart -git commit -m "feat(mobile): add locale model and storage" -``` - -## Task 2: String Bundles and Localization Scope - -**Files:** -- Modify: `mobile/lib/core/i18n/app_strings.dart` -- Modify: `mobile/lib/core/i18n/app_localizations.dart` -- Test: `mobile/test/core/i18n/app_strings_test.dart` - -- [ ] **Step 1: Write failing string bundle tests** - -Create `mobile/test/core/i18n/app_strings_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/i18n/app_locale.dart'; -import 'package:mobile/core/i18n/app_strings.dart'; - -void main() { - test('returns English strings', () { - final strings = AppStrings.forLocale(AppLocale.english); - expect(strings.loginTitle, 'Connect to your servers.'); - expect(strings.settingsTitle, 'Settings'); - expect(strings.languageSimplifiedChinese, 'Chinese'); - }); - - test('returns Simplified Chinese strings', () { - final strings = AppStrings.forLocale(AppLocale.simplifiedChinese); - expect(strings.loginTitle, '连接到你的服务器。'); - expect(strings.settingsTitle, '设置'); - expect(strings.languageSimplifiedChinese, '中文'); - }); -} -``` - -- [ ] **Step 2: Run string bundle test and verify it fails** - -Run: - -```bash -cd mobile -flutter test test/core/i18n/app_strings_test.dart -``` - -Expected: FAIL because `app_strings.dart` does not exist. - -- [ ] **Step 3: Implement AppStrings** - -Create `mobile/lib/core/i18n/app_strings.dart` with a const class containing at least these fields: - -```dart -import 'app_locale.dart'; - -class AppStrings { - const AppStrings({ - required this.appName, - required this.loginTitle, - required this.loginSubtitle, - required this.serverAddress, - required this.username, - required this.password, - required this.mfaCodeOptional, - required this.sessionDuration, - required this.savePassword, - required this.loginButton, - required this.httpWarningTitle, - required this.httpWarningBody, - required this.continueButton, - required this.serversTitle, - required this.searchHosts, - required this.activeTerminal, - required this.activeTerminals, - required this.closeAllTerminals, - required this.connect, - required this.notConfigured, - required this.retry, - required this.settingsTitle, - required this.language, - required this.languageEnglish, - required this.languageSimplifiedChinese, - required this.logout, - required this.logoutTitle, - required this.logoutBody, - required this.cancel, - required this.sftpTitle, - required this.sftpEmptyTitle, - required this.sftpEmptyBody, - required this.scriptsTitle, - required this.scriptsEmptyTitle, - required this.scriptsEmptyBody, - }); - - final String appName; - final String loginTitle; - final String loginSubtitle; - final String serverAddress; - final String username; - final String password; - final String mfaCodeOptional; - final String sessionDuration; - final String savePassword; - final String loginButton; - final String httpWarningTitle; - final String httpWarningBody; - final String continueButton; - final String serversTitle; - final String searchHosts; - final String activeTerminal; - final String activeTerminals; - final String closeAllTerminals; - final String connect; - final String notConfigured; - final String retry; - final String settingsTitle; - final String language; - final String languageEnglish; - final String languageSimplifiedChinese; - final String logout; - final String logoutTitle; - final String logoutBody; - final String cancel; - final String sftpTitle; - final String sftpEmptyTitle; - final String sftpEmptyBody; - final String scriptsTitle; - final String scriptsEmptyTitle; - final String scriptsEmptyBody; - - static const english = AppStrings( - appName: 'EasyNode', - loginTitle: 'Connect to your servers.', - loginSubtitle: 'Secure mobile access for SSH operations.', - serverAddress: 'Server address', - username: 'Username', - password: 'Password', - mfaCodeOptional: 'MFA code (optional)', - sessionDuration: 'Session duration', - savePassword: 'Save password securely', - loginButton: 'Log in', - httpWarningTitle: 'HTTP is not encrypted', - httpWarningBody: 'Your token and session cookie can be intercepted. Use HTTPS when possible.', - continueButton: 'Continue', - serversTitle: 'Servers', - searchHosts: 'Search hosts', - activeTerminal: '1 active terminal', - activeTerminals: '{count} active terminals', - closeAllTerminals: 'Close all terminals', - connect: 'Connect', - notConfigured: 'Not configured', - retry: 'Retry', - settingsTitle: 'Settings', - language: 'Language', - languageEnglish: 'English', - languageSimplifiedChinese: 'Chinese', - logout: 'Log out', - logoutTitle: 'Log out?', - logoutBody: 'This will clear the saved login session.', - cancel: 'Cancel', - sftpTitle: 'SFTP', - sftpEmptyTitle: 'SFTP is not available yet', - sftpEmptyBody: 'Remote file browsing and management will be added in a later version.', - scriptsTitle: 'Scripts', - scriptsEmptyTitle: 'Scripts are not available yet', - scriptsEmptyBody: 'Script library features will be added in a later version.', - ); - - static const simplifiedChinese = AppStrings( - appName: 'EasyNode', - loginTitle: '连接到你的服务器。', - loginSubtitle: '为 SSH 运维提供安全的移动端访问。', - serverAddress: '服务器地址', - username: '用户名', - password: '密码', - mfaCodeOptional: 'MFA 验证码(可选)', - sessionDuration: '会话有效期', - savePassword: '安全保存密码', - loginButton: '登录', - httpWarningTitle: 'HTTP 未加密', - httpWarningBody: '登录 token 和 session cookie 可能被截获。建议尽量使用 HTTPS。', - continueButton: '继续', - serversTitle: '服务器', - searchHosts: '搜索主机', - activeTerminal: '1 个终端运行中', - activeTerminals: '{count} 个终端运行中', - closeAllTerminals: '关闭所有终端', - connect: '连接', - notConfigured: '未配置', - retry: '重试', - settingsTitle: '设置', - language: '语言', - languageEnglish: '英语', - languageSimplifiedChinese: '中文', - logout: '退出登录', - logoutTitle: '退出登录?', - logoutBody: '这会清除当前保存的登录会话。', - cancel: '取消', - sftpTitle: 'SFTP', - sftpEmptyTitle: 'SFTP 暂未开放', - sftpEmptyBody: '远程文件浏览和管理将在后续版本加入。', - scriptsTitle: '脚本', - scriptsEmptyTitle: '脚本库暂未开放', - scriptsEmptyBody: '脚本库功能将在后续版本加入。', - ); - - static AppStrings forLocale(AppLocale locale) { - return switch (locale) { - AppLocale.english => english, - AppLocale.simplifiedChinese => simplifiedChinese, - }; - } -} -``` - -- [ ] **Step 4: Implement localization scope** - -Create `mobile/lib/core/i18n/app_localizations.dart`: - -```dart -import 'package:flutter/widgets.dart'; - -import 'app_locale.dart'; -import 'app_strings.dart'; - -class AppLocalizations extends InheritedWidget { - const AppLocalizations({ - super.key, - required this.locale, - required this.strings, - required super.child, - }); - - final AppLocale locale; - final AppStrings strings; - - static AppLocalizations of(BuildContext context) { - final result = context.dependOnInheritedWidgetOfExactType(); - assert(result != null, 'No AppLocalizations found in context'); - return result!; - } - - @override - bool updateShouldNotify(AppLocalizations oldWidget) { - return locale != oldWidget.locale || strings != oldWidget.strings; - } -} - -extension AppLocalizationsContext on BuildContext { - AppStrings get strings => AppLocalizations.of(this).strings; - AppLocale get appLocale => AppLocalizations.of(this).locale; -} -``` - -- [ ] **Step 5: Run Task 2 tests** - -Run: - -```bash -cd mobile -flutter test test/core/i18n/app_strings_test.dart -``` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 2** - -Run: - -```bash -git add mobile/lib/core/i18n/app_strings.dart mobile/lib/core/i18n/app_localizations.dart mobile/test/core/i18n/app_strings_test.dart -git commit -m "feat(mobile): add localized string bundles" -``` - -## Task 3: Theme Tokens and Shared UI Components - -**Files:** -- Create: `mobile/lib/core/ui/app_tokens.dart` -- Create: `mobile/lib/core/ui/app_theme.dart` -- Create: `mobile/lib/core/ui/language_switcher.dart` -- Create: `mobile/lib/core/ui/notice_box.dart` -- Create: `mobile/lib/core/ui/empty_state.dart` -- Test: `mobile/test/core/ui/app_theme_test.dart` - -- [ ] **Step 1: Write failing theme smoke test** - -Create `mobile/test/core/ui/app_theme_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/core/ui/app_theme.dart'; -import 'package:mobile/core/ui/app_tokens.dart'; - -void main() { - test('light theme exposes EasyNode tokens and black primary button color', () { - final theme = EasyNodeTheme.light(); - final tokens = theme.extension(); - - expect(tokens, isNotNull); - expect(tokens!.canvas, const Color(0xffffffff)); - expect(theme.filledButtonTheme.style?.backgroundColor?.resolve({}), const Color(0xff000000)); - }); -} -``` - -- [ ] **Step 2: Run theme test and verify it fails** - -Run: - -```bash -cd mobile -flutter test test/core/ui/app_theme_test.dart -``` - -Expected: FAIL because theme files do not exist. - -- [ ] **Step 3: Implement tokens** - -Create `mobile/lib/core/ui/app_tokens.dart`: - -```dart -import 'package:flutter/material.dart'; - -@immutable -class EasyNodeTokens extends ThemeExtension { - const EasyNodeTokens({ - required this.canvas, - required this.canvasSoft, - required this.card, - required this.ink, - required this.body, - required this.muted, - required this.hairline, - required this.hairlineStrong, - required this.primaryAction, - required this.onPrimaryAction, - required this.terminalBackground, - required this.terminalForeground, - required this.success, - required this.warning, - }); - - final Color canvas; - final Color canvasSoft; - final Color card; - final Color ink; - final Color body; - final Color muted; - final Color hairline; - final Color hairlineStrong; - final Color primaryAction; - final Color onPrimaryAction; - final Color terminalBackground; - final Color terminalForeground; - final Color success; - final Color warning; - - static const light = EasyNodeTokens( - canvas: Color(0xffffffff), - canvasSoft: Color(0xfffafafa), - card: Color(0xffffffff), - ink: Color(0xff171717), - body: Color(0xff60646c), - muted: Color(0xff999999), - hairline: Color(0xfff0f0f3), - hairlineStrong: Color(0xffdcdee0), - primaryAction: Color(0xff000000), - onPrimaryAction: Color(0xffffffff), - terminalBackground: Color(0xff171717), - terminalForeground: Color(0xfff5f5f7), - success: Color(0xff16a34a), - warning: Color(0xffab6400), - ); - - static const dark = EasyNodeTokens( - canvas: Color(0xff0f0f0f), - canvasSoft: Color(0xff171717), - card: Color(0xff171717), - ink: Color(0xffffffff), - body: Color(0xffb0b4ba), - muted: Color(0xff8a8f98), - hairline: Color(0xff2a2a2a), - hairlineStrong: Color(0xff3a3a3a), - primaryAction: Color(0xffffffff), - onPrimaryAction: Color(0xff000000), - terminalBackground: Color(0xff0b0b0b), - terminalForeground: Color(0xfff5f5f7), - success: Color(0xff22c55e), - warning: Color(0xfff59e0b), - ); - - @override - EasyNodeTokens copyWith({ - Color? canvas, - Color? canvasSoft, - Color? card, - Color? ink, - Color? body, - Color? muted, - Color? hairline, - Color? hairlineStrong, - Color? primaryAction, - Color? onPrimaryAction, - Color? terminalBackground, - Color? terminalForeground, - Color? success, - Color? warning, - }) { - return EasyNodeTokens( - canvas: canvas ?? this.canvas, - canvasSoft: canvasSoft ?? this.canvasSoft, - card: card ?? this.card, - ink: ink ?? this.ink, - body: body ?? this.body, - muted: muted ?? this.muted, - hairline: hairline ?? this.hairline, - hairlineStrong: hairlineStrong ?? this.hairlineStrong, - primaryAction: primaryAction ?? this.primaryAction, - onPrimaryAction: onPrimaryAction ?? this.onPrimaryAction, - terminalBackground: terminalBackground ?? this.terminalBackground, - terminalForeground: terminalForeground ?? this.terminalForeground, - success: success ?? this.success, - warning: warning ?? this.warning, - ); - } - - @override - EasyNodeTokens lerp(ThemeExtension? other, double t) { - if (other is! EasyNodeTokens) return this; - return EasyNodeTokens( - canvas: Color.lerp(canvas, other.canvas, t)!, - canvasSoft: Color.lerp(canvasSoft, other.canvasSoft, t)!, - card: Color.lerp(card, other.card, t)!, - ink: Color.lerp(ink, other.ink, t)!, - body: Color.lerp(body, other.body, t)!, - muted: Color.lerp(muted, other.muted, t)!, - hairline: Color.lerp(hairline, other.hairline, t)!, - hairlineStrong: Color.lerp(hairlineStrong, other.hairlineStrong, t)!, - primaryAction: Color.lerp(primaryAction, other.primaryAction, t)!, - onPrimaryAction: Color.lerp(onPrimaryAction, other.onPrimaryAction, t)!, - terminalBackground: Color.lerp(terminalBackground, other.terminalBackground, t)!, - terminalForeground: Color.lerp(terminalForeground, other.terminalForeground, t)!, - success: Color.lerp(success, other.success, t)!, - warning: Color.lerp(warning, other.warning, t)!, - ); - } -} - -extension EasyNodeTokensContext on BuildContext { - EasyNodeTokens get tokens => Theme.of(this).extension()!; -} -``` - -- [ ] **Step 4: Implement theme** - -Create `mobile/lib/core/ui/app_theme.dart`: - -```dart -import 'package:flutter/material.dart'; - -import 'app_tokens.dart'; - -class EasyNodeTheme { - const EasyNodeTheme._(); - - static ThemeData light() => _build(Brightness.light, EasyNodeTokens.light); - static ThemeData dark() => _build(Brightness.dark, EasyNodeTokens.dark); - - static ThemeData _build(Brightness brightness, EasyNodeTokens tokens) { - final colorScheme = ColorScheme.fromSeed( - seedColor: tokens.primaryAction, - brightness: brightness, - primary: tokens.primaryAction, - onPrimary: tokens.onPrimaryAction, - surface: tokens.canvas, - onSurface: tokens.ink, - error: const Color(0xffeb8e90), - ); - - return ThemeData( - useMaterial3: true, - brightness: brightness, - colorScheme: colorScheme, - scaffoldBackgroundColor: tokens.canvas, - extensions: [tokens], - dividerColor: tokens.hairlineStrong, - appBarTheme: AppBarTheme( - centerTitle: true, - elevation: 0, - scrolledUnderElevation: 0, - backgroundColor: tokens.canvas, - foregroundColor: tokens.ink, - titleTextStyle: TextStyle( - color: tokens.ink, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - cardTheme: CardThemeData( - color: tokens.card, - elevation: 0, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: tokens.hairlineStrong), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: tokens.primaryAction, - foregroundColor: tokens.onPrimaryAction, - minimumSize: const Size.fromHeight(40), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: tokens.hairlineStrong), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: tokens.ink, width: 1.4), - ), - ), - navigationBarTheme: NavigationBarThemeData( - height: 58, - backgroundColor: tokens.canvas, - indicatorColor: Colors.transparent, - labelTextStyle: WidgetStateProperty.resolveWith((states) { - final selected = states.contains(WidgetState.selected); - return TextStyle( - color: selected ? tokens.ink : tokens.muted, - fontSize: 11, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - ); - }), - iconTheme: WidgetStateProperty.resolveWith((states) { - final selected = states.contains(WidgetState.selected); - return IconThemeData(color: selected ? tokens.ink : tokens.muted); - }), - ), - ); - } -} -``` - -- [ ] **Step 5: Implement shared UI components** - -Create: - -`mobile/lib/core/ui/notice_box.dart` -```dart -import 'package:flutter/material.dart'; - -class NoticeBox extends StatelessWidget { - const NoticeBox({ - super.key, - required this.icon, - required this.title, - required this.body, - required this.color, - this.action, - }); - - final IconData icon; - final String title; - final String body; - final Color color; - final Widget? action; - - @override - Widget build(BuildContext context) { - final background = color.withValues(alpha: 0.10); - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withValues(alpha: 0.28)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: TextStyle(color: color, fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text(body), - if (action != null) Align(alignment: Alignment.centerRight, child: action), - ], - ), - ), - ], - ), - ); - } -} -``` - -`mobile/lib/core/ui/empty_state.dart` -```dart -import 'package:flutter/material.dart'; - -class EmptyState extends StatelessWidget { - const EmptyState({ - super.key, - required this.icon, - required this.title, - required this.body, - this.action, - }); - - final IconData icon; - final String title; - final String body; - final Widget? action; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(28), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 42, color: colors.onSurfaceVariant), - const SizedBox(height: 14), - Text(title, textAlign: TextAlign.center, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text(body, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant)), - if (action != null) ...[const SizedBox(height: 16), action!], - ], - ), - ), - ); - } -} -``` - -`mobile/lib/core/ui/language_switcher.dart` -```dart -import 'package:flutter/material.dart'; - -import '../i18n/app_locale.dart'; -import '../i18n/app_localizations.dart'; - -class LanguageSwitcher extends StatelessWidget { - const LanguageSwitcher({ - super.key, - required this.value, - required this.onChanged, - }); - - final AppLocale value; - final ValueChanged onChanged; - - Future _showPicker(BuildContext context) async { - final strings = context.strings; - final selected = await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text(strings.languageEnglish), - trailing: value == AppLocale.english ? const Icon(Icons.check) : null, - onTap: () => Navigator.of(context).pop(AppLocale.english), - ), - ListTile( - title: Text(strings.languageSimplifiedChinese), - trailing: value == AppLocale.simplifiedChinese ? const Icon(Icons.check) : null, - onTap: () => Navigator.of(context).pop(AppLocale.simplifiedChinese), - ), - ], - ), - ), - ); - if (selected != null && selected != value) onChanged(selected); - } - - @override - Widget build(BuildContext context) { - return OutlinedButton.icon( - key: const Key('language-switcher'), - onPressed: () => _showPicker(context), - icon: const Icon(Icons.language, size: 18), - label: Text(value == AppLocale.english ? 'EN' : '中文'), - ); - } -} -``` - -- [ ] **Step 6: Run Task 3 tests** - -Run: - -```bash -cd mobile -flutter test test/core/ui/app_theme_test.dart -``` - -Expected: PASS. - -- [ ] **Step 7: Commit Task 3** - -Run: - -```bash -git add mobile/lib/core/ui mobile/test/core/ui/app_theme_test.dart -git commit -m "feat(mobile): add redesign theme tokens" -``` - -## Task 4: App Bootstrap Locale and Theme Wiring - -**Files:** -- Modify: `mobile/lib/app.dart` -- Test: `mobile/test/features/auth/login_page_test.dart` - -- [ ] **Step 1: Write failing app wiring expectation in login test** - -Update `mobile/test/features/auth/login_page_test.dart` helper to wrap Login with `AppLocalizations`, then add: - -```dart -testWidgets('renders Chinese labels when locale is Chinese', (tester) async { - final controller = LoginController.fake(); - await tester.pumpWidget( - wrapLocalized( - LoginPage( - controller: controller, - initialServerAddress: 'https://example.com', - initialUsername: 'root', - initialSavePassword: false, - currentLocale: AppLocale.simplifiedChinese, - onLocaleChanged: (_) {}, - onLoginSuccess: (_) {}, - ), - locale: AppLocale.simplifiedChinese, - ), - ); - - expect(find.text('连接到你的服务器。'), findsOneWidget); - expect(find.text('服务器地址'), findsOneWidget); -}); -``` - -Expected imports: - -```dart -import 'package:mobile/core/i18n/app_locale.dart'; -import 'package:mobile/core/i18n/app_localizations.dart'; -import 'package:mobile/core/i18n/app_strings.dart'; -import 'package:mobile/core/ui/app_theme.dart'; -``` - -Add helper: - -```dart -Widget wrapLocalized(Widget child, {AppLocale locale = AppLocale.english}) { - return AppLocalizations( - locale: locale, - strings: AppStrings.forLocale(locale), - child: MaterialApp(theme: EasyNodeTheme.light(), home: child), - ); -} -``` - -- [ ] **Step 2: Run updated login test and verify it fails** - -Run: - -```bash -cd mobile -flutter test test/features/auth/login_page_test.dart -``` - -Expected: FAIL because LoginPage has no locale parameters and app wiring is absent. - -- [ ] **Step 3: Modify AppRoot to own locale** - -In `mobile/lib/app.dart`: - -- Add imports: - -```dart -import 'core/i18n/app_locale.dart'; -import 'core/i18n/app_localizations.dart'; -import 'core/i18n/app_strings.dart'; -import 'core/ui/app_theme.dart'; -``` - -- Add `initialLocale` to `_Bootstrap`. -- In `bootstrap()`, compute: - -```dart -final initialLocale = AppLocale.resolve( - savedCode: appStorage.localeCode, - systemLocale: WidgetsBinding.instance.platformDispatcher.locale, -); -``` - -- Pass `initialLocale` into `_AppRoot`. -- In `_AppRootState`, add: - -```dart -late AppLocale _locale; - -@override -void initState() { - super.initState(); - _locale = widget.initialLocale; - _loginController = LoginController(apiClientFactory: _buildApiClient) - ..onLoginSuccess(_onLoginSuccess); -} - -Future _setLocale(AppLocale locale) async { - if (_locale == locale) return; - setState(() => _locale = locale); - await ref.read(appStorageProvider).setLocaleCode(locale.storageCode); -} -``` - -- Wrap `MaterialApp` with `AppLocalizations`: - -```dart -return AppLocalizations( - locale: _locale, - strings: AppStrings.forLocale(_locale), - child: MaterialApp( - title: 'EasyNode', - locale: _locale.flutterLocale, - supportedLocales: AppLocale.values.map((locale) => locale.flutterLocale), - themeMode: ThemeMode.system, - theme: EasyNodeTheme.light(), - darkTheme: EasyNodeTheme.dark(), - home: home, - ), -); -``` - -- Pass `currentLocale: _locale` and `onLocaleChanged: _setLocale` to Login and Main Shell. - -- [ ] **Step 4: Run login test and fix compile errors only** - -Run: - -```bash -cd mobile -flutter test test/features/auth/login_page_test.dart -``` - -Expected: Still FAIL in LoginPage until Task 5 implements the page, but app-level compile errors from `_AppRoot` should be resolved. - -- [ ] **Step 5: Commit Task 4** - -Run: - -```bash -git add mobile/lib/app.dart mobile/test/features/auth/login_page_test.dart -git commit -m "feat(mobile): wire locale and theme at app root" -``` - -## Task 5: Login Redesign and Localization - -**Files:** -- Modify: `mobile/lib/features/auth/login_page.dart` -- Test: `mobile/test/features/auth/login_page_test.dart` - -- [ ] **Step 1: Update LoginPage constructor** - -Add required parameters: - -```dart -final AppLocale currentLocale; -final ValueChanged onLocaleChanged; -``` - -Keep existing controller, initial values, and `onLoginSuccess`. - -- [ ] **Step 2: Replace hard-coded Login copy with strings** - -In `build`, add: - -```dart -final strings = context.strings; -final tokens = context.tokens; -``` - -Replace labels: - -- `EasyNode` -> `strings.appName` -- `Mobile terminal access` -> `strings.loginSubtitle` -- `Server address` -> `strings.serverAddress` -- `Username` -> `strings.username` -- `Password` -> `strings.password` -- `MFA code (optional)` -> `strings.mfaCodeOptional` -- `Session duration` -> `strings.sessionDuration` -- `Save password securely` -> `strings.savePassword` -- `Log in` -> `strings.loginButton` - -- [ ] **Step 3: Redesign Login layout** - -Change Scaffold to no AppBar and use: - -```dart -return Scaffold( - body: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - _LoginHero( - locale: widget.currentLocale, - onLocaleChanged: widget.onLocaleChanged, - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 24), - child: Column( - children: [ - // existing fields and controls - ], - ), - ), - ], - ), - ), -); -``` - -Add `_LoginHero` that uses `LanguageSwitcher`, `strings.loginTitle`, and `strings.loginSubtitle`, with a subtle sky-blue vertical gradient. - -- [ ] **Step 4: Replace HTTP warning and error boxes with NoticeBox** - -Change `_HttpRiskBanner` to use localized strings: - -```dart -NoticeBox( - icon: Icons.warning_amber, - title: context.strings.httpWarningTitle, - body: context.strings.httpWarningBody, - color: context.tokens.warning, - action: TextButton(onPressed: onConfirm, child: Text(context.strings.continueButton)), -) -``` - -Change `_ErrorBox` to: - -```dart -NoticeBox( - key: const Key('login-error'), - icon: Icons.error_outline, - title: message, - body: '', - color: Theme.of(context).colorScheme.error, -) -``` - -If an empty `body` creates extra spacing, make `NoticeBox` hide the body `Text` when the body is empty. - -- [ ] **Step 5: Run Login tests** - -Run: - -```bash -cd mobile -flutter test test/features/auth/login_page_test.dart -``` - -Expected: PASS after updating tests to expect localized labels and preserving existing keys. - -- [ ] **Step 6: Commit Task 5** - -Run: - -```bash -git add mobile/lib/features/auth/login_page.dart mobile/test/features/auth/login_page_test.dart mobile/lib/core/ui/notice_box.dart -git commit -m "feat(mobile): redesign localized login page" -``` - -## Task 6: Shell, Settings, and Empty Screens - -**Files:** -- Modify: `mobile/lib/features/shell/main_shell_page.dart` -- Modify: `mobile/lib/features/shell/settings_tab.dart` -- Modify: `mobile/lib/features/shell/sftp_tab.dart` -- Modify: `mobile/lib/features/shell/scripts_tab.dart` -- Test: `mobile/test/features/shell/settings_tab_test.dart` -- Test: `mobile/test/features/shell/placeholder_tabs_test.dart` - -- [ ] **Step 1: Update MainShellPage API** - -Add: - -```dart -const MainShellPage({ - super.key, - required this.currentLocale, - required this.onLocaleChanged, -}); - -final AppLocale currentLocale; -final ValueChanged onLocaleChanged; -``` - -Build `_tabs` as an instance getter so Settings can receive the callback: - -```dart -List get _tabs => [ - const ServersTab(), - const SftpTab(), - const ScriptsTab(), - SettingsTab(currentLocale: widget.currentLocale, onLocaleChanged: widget.onLocaleChanged), -]; -``` - -Use `context.strings` for navigation labels. - -- [ ] **Step 2: Write failing Settings language test** - -Create `mobile/test/features/shell/settings_tab_test.dart` with a ProviderScope and AppLocalizations wrapper. Assert it renders `Language`, current account, and `Log out`; tap the language row and expect `English` and `Chinese` options. - -- [ ] **Step 3: Redesign Settings** - -In `settings_tab.dart`: - -- Add required locale parameters. -- Replace hard-coded copy with `context.strings`. -- Render account/server in a `Card`. -- Add a `ListTile` with `Icons.language`, `strings.language`, and current locale label. -- Reuse the same picker behavior from `LanguageSwitcher`, or embed `LanguageSwitcher` as trailing. - -- [ ] **Step 4: Write failing empty-screen tests** - -Create `mobile/test/features/shell/placeholder_tabs_test.dart`: - -```dart -testWidgets('SFTP tab renders localized empty state', (tester) async { - await tester.pumpWidget(wrapLocalized(const SftpTab())); - expect(find.text('SFTP is not available yet'), findsOneWidget); -}); - -testWidgets('Scripts tab renders localized empty state', (tester) async { - await tester.pumpWidget(wrapLocalized(const ScriptsTab())); - expect(find.text('Scripts are not available yet'), findsOneWidget); -}); -``` - -- [ ] **Step 5: Replace SFTP and Scripts garbled text** - -Use `EmptyState`: - -```dart -EmptyState( - icon: Icons.folder_outlined, - title: context.strings.sftpEmptyTitle, - body: context.strings.sftpEmptyBody, -) -``` - -and: - -```dart -EmptyState( - icon: Icons.library_books_outlined, - title: context.strings.scriptsEmptyTitle, - body: context.strings.scriptsEmptyBody, -) -``` - -- [ ] **Step 6: Run Task 6 tests** - -Run: - -```bash -cd mobile -flutter test test/features/shell/settings_tab_test.dart test/features/shell/placeholder_tabs_test.dart -``` - -Expected: PASS. - -- [ ] **Step 7: Commit Task 6** - -Run: - -```bash -git add mobile/lib/features/shell mobile/test/features/shell -git commit -m "feat(mobile): localize shell settings and empty states" -``` - -## Task 7: Servers Tab Redesign - -**Files:** -- Modify: `mobile/lib/features/servers/servers_tab.dart` -- Test: `mobile/test/features/servers/servers_tab_test.dart` - -- [ ] **Step 1: Update Servers tests to use localization wrapper** - -Modify `_wrap` in `servers_tab_test.dart` to include `AppLocalizations` and `EasyNodeTheme.light()`. Keep all existing repository overrides. - -- [ ] **Step 2: Add failing localized search/action assertions** - -Add expectations: - -```dart -expect(find.text('Servers'), findsOneWidget); -expect(find.text('Connect'), findsOneWidget); -expect(find.text('Not configured'), findsOneWidget); -``` - -Add a Chinese wrapper variant and assert: - -```dart -expect(find.text('服务器'), findsOneWidget); -expect(find.text('连接'), findsOneWidget); -``` - -- [ ] **Step 3: Localize Servers copy** - -Replace hard-coded: - -- `Servers` -> `strings.serversTitle` -- `Search by name, host, user, tag, or group` -> `strings.searchHosts` -- `Connect` -> `strings.connect` -- `Not configured` -> `strings.notConfigured` -- `Retry` -> `strings.retry` -- active terminal text -> `strings.activeTerminal` or `strings.activeTerminals.replaceFirst('{count}', '$count')` -- close-all tooltip -> `strings.closeAllTerminals` - -- [ ] **Step 4: Redesign cards and banner** - -Keep `_ServerCard` private, but change layout from `ListTile` to a custom `Card` with: - -```dart -Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [Expanded(child: Text(server.displayName)), action]), - Text(server.connectionLabel, style: const TextStyle(fontFamily: 'monospace')), - Wrap(children: chips), - ], - ), - ), -) -``` - -Use token border/card defaults from theme. Keep keys like `Key('server-${server.id}')`. - -- [ ] **Step 5: Run Servers tests** - -Run: - -```bash -cd mobile -flutter test test/features/servers/servers_tab_test.dart -``` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 7** - -Run: - -```bash -git add mobile/lib/features/servers/servers_tab.dart mobile/test/features/servers/servers_tab_test.dart -git commit -m "feat(mobile): redesign localized server list" -``` - -## Task 8: Terminal Shell and Toolbar Styling - -**Files:** -- Modify: `mobile/lib/features/terminal/terminal_shell_page.dart` -- Modify: `mobile/lib/features/terminal/terminal_toolbar.dart` -- Test: `mobile/test/features/terminal/terminal_toolbar_test.dart` - -- [ ] **Step 1: Update terminal toolbar test wrapper** - -Wrap `TerminalToolbar` with `AppLocalizations` and `EasyNodeTheme.light()` so theme tokens are present. - -- [ ] **Step 2: Run toolbar test before edits** - -Run: - -```bash -cd mobile -flutter test test/features/terminal/terminal_toolbar_test.dart -``` - -Expected: PASS before visual edits. - -- [ ] **Step 3: Make terminal toolbar token-driven** - -In `terminal_toolbar.dart`: - -- Import `app_tokens.dart`. -- Use `context.tokens.canvasSoft`, `context.tokens.hairlineStrong`, `context.tokens.card`, and `context.tokens.ink`. -- Keep button keys unchanged (`toolbar-Esc`, etc.). -- Keep emitted escape sequences unchanged. - -- [ ] **Step 4: Make terminal top bar token-driven** - -In `terminal_shell_page.dart`: - -- Import `app_tokens.dart`. -- Replace direct surface/divider colors in `_TerminalTopBar` with `context.tokens.canvas` and `context.tokens.hairlineStrong`. -- Replace terminal background `Colors.black` with `context.tokens.terminalBackground`. -- Keep `TerminalView` and `IndexedStack` behavior unchanged. -- Keep `_statusText` values in English for now unless AppStrings is already available in this subtree; do not change session behavior. - -- [ ] **Step 5: Run terminal test** - -Run: - -```bash -cd mobile -flutter test test/features/terminal/terminal_toolbar_test.dart -``` - -Expected: PASS with the same input sequence list. - -- [ ] **Step 6: Commit Task 8** - -Run: - -```bash -git add mobile/lib/features/terminal/terminal_shell_page.dart mobile/lib/features/terminal/terminal_toolbar.dart mobile/test/features/terminal/terminal_toolbar_test.dart -git commit -m "feat(mobile): align terminal chrome with redesign tokens" -``` - -## Task 9: Full Verification and Polish - -**Files:** -- Inspect: `mobile/lib` -- Inspect: `mobile/test` - -- [ ] **Step 1: Scan for garbled text and old seed color usage** - -Run: - -```bash -rg -n "鍗|绾|Colors\\.indigo|colorSchemeSeed|Mobile terminal access|即将|脚本库" mobile/lib mobile/test -``` - -Expected: No garbled text, no `Colors.indigo` or `colorSchemeSeed` in active mobile app code, no old Login subtitle. - -- [ ] **Step 2: Run all mobile tests** - -Run: - -```bash -cd mobile -flutter test -``` - -Expected: PASS. - -- [ ] **Step 3: Run Flutter analyzer** - -Run: - -```bash -cd mobile -flutter analyze -``` - -Expected: No errors. Existing warnings must be reviewed; new warnings from this work must be fixed. - -- [ ] **Step 4: Manual smoke check** - -Run an emulator/device build if available: - -```bash -cd mobile -flutter run -``` - -Expected: - -- Login starts in system language when no preference exists. -- Login language switch changes labels immediately. -- Login still validates and submits through existing controller. -- Servers tab still loads host cards. -- Terminal connect path still opens TerminalShellPage. -- Settings language switch changes labels immediately. -- SFTP/Scripts show readable localized empty states. - -- [ ] **Step 5: Commit final polish if needed** - -If Step 1-4 required fixes: - -```bash -git add mobile/lib mobile/test -git commit -m "fix(mobile): polish redesign verification issues" -``` - -If no fixes were needed, do not create an empty commit. - -## Self-Review - -- Spec coverage: Tasks cover theme/token architecture, all existing mobile screens, English/Chinese i18n, Login and Settings language switching, locale persistence, system-locale defaulting, dark-mode compatibility, tests, and verification. -- Placeholder scan: No deferred or empty steps remain. "Placeholder tabs" refers to existing SFTP/Scripts empty screens and includes concrete implementation steps. -- Type consistency: `AppLocale`, `AppStrings`, `AppLocalizations`, `EasyNodeTokens`, `EasyNodeTheme`, `LanguageSwitcher`, `NoticeBox`, and `EmptyState` names are introduced before use and remain consistent across tasks. diff --git a/docs/superpowers/plans/2026-05-23-mobile-native-proxy-jump-host-implementation.md b/docs/superpowers/plans/2026-05-23-mobile-native-proxy-jump-host-implementation.md deleted file mode 100644 index bcec583..0000000 --- a/docs/superpowers/plans/2026-05-23-mobile-native-proxy-jump-host-implementation.md +++ /dev/null @@ -1,1519 +0,0 @@ -# Mobile Native Proxy and Jump Host Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add mobile-local SOCKS5 proxy and SSH jump-host support for native terminal connections without routing mobile sessions through the server terminal websocket. - -**Architecture:** The server expands the encrypted `/mobile/ssh-connection` payload with proxy and jump-host topology. Mobile parses that payload into focused config models, builds the final `SSHSocket` through a transport factory, then creates the existing `SSHClient` over that socket. Direct connections stay behaviorally unchanged. - -**Tech Stack:** Node.js server controllers/tests, Flutter/Dart mobile app, `dartssh2 2.14.0`, `flutter_test`, Node `assert`. - ---- - -## File Structure - -- Modify: `server/app/controller/mobile.js` - - Keep `toMobileSshPayload` as the pure payload builder used by tests. - - Add topology arguments for `proxyType`, `proxy`, and `jumpHosts`. - - Add async helpers used by `getMobileSshConnection` to resolve proxy and jump-host connection details. -- Modify: `server/test/test-mobile-ssh-payload.js` - - Extend existing pure payload tests for direct, SOCKS5, jump-host, invalid auth, invalid proxy type, and missing topology. -- Modify: `mobile/lib/features/terminal/ssh_connection_config.dart` - - Add target auth helper shape plus `SshProxyConfig` and `SshJumpHostConfig`. - - Preserve empty-passphrase-to-null behavior for target and jump-host private keys. -- Modify: `mobile/test/features/terminal/ssh_connection_config_test.dart` - - Cover direct, SOCKS5, jump-host parsing, invalid port fallback, and passphrase normalization. -- Create: `mobile/lib/features/terminal/ssh_transport.dart` - - Define `SshTransportHandle`, `SshTransportFactory`, `SshClientFactory`, and `SshTransportException`. - - Implement direct transport first, then SOCKS5 and jump-host branches. -- Create: `mobile/lib/features/terminal/socks5_connector.dart` - - Implement the SOCKS5 TCP negotiation over a native `Socket`. - - Return an `SSHSocket` adapter for `dartssh2`. -- Create: `mobile/test/features/terminal/socks5_connector_test.dart` - - Use a local `ServerSocket` to assert SOCKS5 no-auth and username/password handshakes. -- Modify: `mobile/lib/features/terminal/ssh_terminal_controller.dart` - - Replace direct `SSHSocket.connect` with `SshTransportFactory.open`. - - Close the transport handle when disconnecting. -- Create: `mobile/test/features/terminal/ssh_transport_test.dart` - - Verify factory selection, unsupported proxy errors, direct socket creation, and jump-host lifecycle through fakes. - -## Task 1: Server Mobile Payload Topology - -**Files:** -- Modify: `server/app/controller/mobile.js` -- Modify: `server/test/test-mobile-ssh-payload.js` - -- [ ] **Step 1: Write failing server payload tests** - -Replace `server/test/test-mobile-ssh-payload.js` with: - -```js -const assert = require('assert') -const { toMobileSshPayload } = require('../app/controller/mobile') - -function testPasswordPayload() { - const payload = toMobileSshPayload('h1', 'prod', { - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'p@ss' - }) - - assert.deepStrictEqual(payload, { - hostId: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'p@ss', - privateKey: '', - passphrase: '', - proxyType: '', - proxy: null, - jumpHosts: [] - }) -} - -function testPrivateKeyPayload() { - const payload = toMobileSshPayload('h2', 'keyhost', { - host: '10.0.0.3', - port: 2222, - username: 'ubuntu', - authType: 'privateKey', - privateKey: 'KEY', - passphrase: 'phrase' - }) - - assert.strictEqual(payload.authType, 'privateKey') - assert.strictEqual(payload.privateKey, 'KEY') - assert.strictEqual(payload.password, '') - assert.strictEqual(payload.passphrase, 'phrase') - assert.strictEqual(payload.proxyType, '') - assert.strictEqual(payload.proxy, null) - assert.deepStrictEqual(payload.jumpHosts, []) -} - -function testSocks5ProxyPayload() { - const payload = toMobileSshPayload('h3', 'proxied', { - host: '10.0.0.4', - port: 22, - username: 'root', - authType: 'password', - password: 'secret' - }, { - proxyType: 'proxyServer', - proxy: { - id: 'p1', - name: 'office', - type: 'socks5', - host: '127.0.0.1', - port: 1080, - username: 'u', - password: 'p' - } - }) - - assert.strictEqual(payload.proxyType, 'proxyServer') - assert.deepStrictEqual(payload.proxy, { - id: 'p1', - name: 'office', - type: 'socks5', - host: '127.0.0.1', - port: 1080, - username: 'u', - password: 'p' - }) - assert.deepStrictEqual(payload.jumpHosts, []) -} - -function testJumpHostPayload() { - const payload = toMobileSshPayload('h4', 'target', { - host: '10.0.0.20', - port: 22, - username: 'root', - authType: 'privateKey', - privateKey: 'TARGET_KEY' - }, { - proxyType: 'jumpHosts', - jumpHosts: [{ - hostId: 'j1', - name: 'jump-1', - host: '203.0.113.10', - port: 22, - username: 'root', - authType: 'password', - password: 'jump-secret' - }] - }) - - assert.strictEqual(payload.proxyType, 'jumpHosts') - assert.strictEqual(payload.proxy, null) - assert.deepStrictEqual(payload.jumpHosts, [{ - hostId: 'j1', - name: 'jump-1', - host: '203.0.113.10', - port: 22, - username: 'root', - authType: 'password', - password: 'jump-secret', - privateKey: '', - passphrase: '' - }]) -} - -function testRejectsUnsupportedAuth() { - assert.throws(() => toMobileSshPayload('h5', 'unsupported', { - host: '10.0.0.4', - port: 22, - username: 'root', - authType: 'keyboard' - }), /unsupported mobile ssh auth type/) -} - -function testRejectsUnsupportedProxyType() { - assert.throws(() => toMobileSshPayload('h6', 'bad-proxy', { - host: '10.0.0.4', - port: 22, - username: 'root', - authType: 'password', - password: 'secret' - }, { - proxyType: 'proxyServer', - proxy: { - id: 'p1', - name: 'http-only', - type: 'http', - host: '127.0.0.1', - port: 8080 - } - }), /unsupported mobile proxy type: http/) -} - -function testRejectsMissingJumpHosts() { - assert.throws(() => toMobileSshPayload('h7', 'missing-jump', { - host: '10.0.0.4', - port: 22, - username: 'root', - authType: 'password', - password: 'secret' - }, { - proxyType: 'jumpHosts', - jumpHosts: [] - }), /mobile jump host chain is empty/) -} - -testPasswordPayload() -testPrivateKeyPayload() -testSocks5ProxyPayload() -testJumpHostPayload() -testRejectsUnsupportedAuth() -testRejectsUnsupportedProxyType() -testRejectsMissingJumpHosts() -console.log('test-mobile-ssh-payload passed') -``` - -- [ ] **Step 2: Run the failing server test** - -Run: - -```bash -node server/test/test-mobile-ssh-payload.js -``` - -Expected: FAIL because `proxyType`, `proxy`, and `jumpHosts` are not included yet. - -- [ ] **Step 3: Implement pure payload builder** - -In `server/app/controller/mobile.js`, update `toMobileSshPayload` and add these helpers above it: - -```js -function normalizePort(port) { - const numericPort = Number(port) - return Number.isFinite(numericPort) && numericPort > 0 ? numericPort : 22 -} - -function normalizeMobileAuthPayload(hostId, name, authInfo) { - const { host, port, username, authType } = authInfo - if (!['password', 'privateKey'].includes(authType)) { - throw new Error(`unsupported mobile ssh auth type: ${ authType || 'empty' }`) - } - - return { - hostId, - name, - host, - port: normalizePort(port), - username, - authType, - password: authType === 'password' ? authInfo.password || '' : '', - privateKey: authType === 'privateKey' ? authInfo.privateKey || '' : '', - passphrase: authType === 'privateKey' ? authInfo.passphrase || '' : '' - } -} - -function normalizeMobileProxy(proxy) { - if (!proxy) return null - if (proxy.type !== 'socks5') { - throw new Error(`unsupported mobile proxy type: ${ proxy.type || 'empty' }`) - } - return { - id: proxy.id || proxy._id || '', - name: proxy.name || '', - type: proxy.type, - host: proxy.host, - port: normalizePort(proxy.port), - username: proxy.username || '', - password: proxy.password || '' - } -} -``` - -Then replace `toMobileSshPayload` with: - -```js -function toMobileSshPayload(hostId, name, authInfo, topology = {}) { - const proxyType = topology.proxyType || '' - const proxy = proxyType === 'proxyServer' ? normalizeMobileProxy(topology.proxy) : null - const jumpHosts = proxyType === 'jumpHosts' - ? (topology.jumpHosts || []).map((jumpHost) => normalizeMobileAuthPayload( - jumpHost.hostId || jumpHost.id || '', - jumpHost.name || '', - jumpHost - )) - : [] - - if (proxyType === 'proxyServer' && !proxy) { - throw new Error('mobile proxy config is missing') - } - if (proxyType === 'jumpHosts' && jumpHosts.length === 0) { - throw new Error('mobile jump host chain is empty') - } - if (proxyType && !['proxyServer', 'jumpHosts'].includes(proxyType)) { - throw new Error(`unsupported mobile proxy type: ${ proxyType }`) - } - - return { - ...normalizeMobileAuthPayload(hostId, name, authInfo), - proxyType, - proxy, - jumpHosts - } -} -``` - -- [ ] **Step 4: Add async topology resolution for real API** - -In `server/app/controller/mobile.js`, add below `toMobileSshPayload`: - -```js -async function getMobileConnectionTopology(hostInfo) { - const { proxyType, proxyServer, jumpHosts } = hostInfo - if (proxyType === 'proxyServer') { - const { getProxyConfig } = require('../socket/terminal') - const proxy = await getProxyConfig(proxyServer) - return { proxyType, proxy, jumpHosts: [] } - } - - if (proxyType === 'jumpHosts') { - if (!Array.isArray(jumpHosts) || jumpHosts.length === 0) { - throw new Error('mobile jump host chain is empty') - } - const { getConnectionOptions } = require('../socket/terminal') - const resolvedJumpHosts = [] - for (const jumpHostId of jumpHosts) { - const { authInfo, name } = await getConnectionOptions(jumpHostId) - resolvedJumpHosts.push({ - hostId: jumpHostId, - name, - ...authInfo - }) - } - return { proxyType, proxy: null, jumpHosts: resolvedJumpHosts } - } - - return { proxyType: '', proxy: null, jumpHosts: [] } -} -``` - -Then update `getMobileSshConnection` to read the host record and pass topology: - -```js -const { HostListDB } = require('../utils/db-class') -const hostListDB = new HostListDB().getInstance() -``` - -Inside the handler, after `getConnectionOptions(hostId)`: - -```js -const hostInfo = await hostListDB.findOneAsync({ _id: hostId }) -if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`) -const topology = await getMobileConnectionTopology(hostInfo) -const payload = toMobileSshPayload(hostId, name, authInfo, topology) -``` - -- [ ] **Step 5: Run server test and commit** - -Run: - -```bash -node server/test/test-mobile-ssh-payload.js -``` - -Expected: PASS with `test-mobile-ssh-payload passed`. - -Commit: - -```bash -git add server/app/controller/mobile.js server/test/test-mobile-ssh-payload.js -git commit -m "feat: include mobile ssh connection topology" -``` - -## Task 2: Mobile SSH Config Models - -**Files:** -- Modify: `mobile/lib/features/terminal/ssh_connection_config.dart` -- Modify: `mobile/test/features/terminal/ssh_connection_config_test.dart` - -- [ ] **Step 1: Replace config tests with model coverage** - -Replace `mobile/test/features/terminal/ssh_connection_config_test.dart` with: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/terminal/ssh_connection_config.dart'; - -void main() { - Map basePayload({String passphrase = ''}) => { - 'hostId': 'h1', - 'name': 'prod', - 'host': '10.0.0.2', - 'port': 22, - 'username': 'root', - 'authType': 'privateKey', - 'password': '', - 'privateKey': 'key', - 'passphrase': passphrase, - 'proxyType': '', - 'proxy': null, - 'jumpHosts': [], - }; - - test('parses direct payload and normalizes empty passphrase', () { - final config = SshConnectionConfig.fromJson(basePayload(passphrase: ' ')); - - expect(config.hostId, 'h1'); - expect(config.port, 22); - expect(config.proxyType, ''); - expect(config.proxy, isNull); - expect(config.jumpHosts, isEmpty); - expect(config.privateKeyPassphrase, isNull); - }); - - test('keeps non-empty private key passphrase', () { - final config = SshConnectionConfig.fromJson(basePayload(passphrase: ' secret ')); - - expect(config.privateKeyPassphrase, 'secret'); - }); - - test('parses socks5 proxy payload', () { - final config = SshConnectionConfig.fromJson({ - ...basePayload(), - 'proxyType': 'proxyServer', - 'proxy': { - 'id': 'p1', - 'name': 'office', - 'type': 'socks5', - 'host': '127.0.0.1', - 'port': '1080', - 'username': 'u', - 'password': 'p', - }, - }); - - expect(config.proxyType, 'proxyServer'); - expect(config.proxy!.id, 'p1'); - expect(config.proxy!.port, 1080); - expect(config.proxy!.username, 'u'); - }); - - test('parses jump host payload and normalizes jump passphrase', () { - final config = SshConnectionConfig.fromJson({ - ...basePayload(), - 'proxyType': 'jumpHosts', - 'jumpHosts': [ - { - 'hostId': 'j1', - 'name': 'jump', - 'host': '203.0.113.10', - 'port': 2200, - 'username': 'root', - 'authType': 'privateKey', - 'password': '', - 'privateKey': 'jump-key', - 'passphrase': '', - } - ], - }); - - expect(config.jumpHosts, hasLength(1)); - expect(config.jumpHosts.single.hostId, 'j1'); - expect(config.jumpHosts.single.port, 2200); - expect(config.jumpHosts.single.privateKeyPassphrase, isNull); - }); -} -``` - -- [ ] **Step 2: Run the failing mobile config test** - -Run: - -```bash -cd mobile -flutter test test/features/terminal/ssh_connection_config_test.dart -``` - -Expected: FAIL because `proxyType`, `proxy`, `jumpHosts`, `SshProxyConfig`, and `SshJumpHostConfig` do not exist yet. - -- [ ] **Step 3: Implement config models** - -Replace `mobile/lib/features/terminal/ssh_connection_config.dart` with: - -```dart -class SshAuthConfig { - const SshAuthConfig({ - required this.hostId, - required this.name, - required this.host, - required this.port, - required this.username, - required this.authType, - required this.password, - required this.privateKey, - required this.passphrase, - }); - - final String hostId; - final String name; - final String host; - final int port; - final String username; - final String authType; - final String password; - final String privateKey; - final String passphrase; - - String? get privateKeyPassphrase { - final trimmed = passphrase.trim(); - return trimmed.isEmpty ? null : trimmed; - } - - static SshAuthConfig fromJson(Map json) { - return SshAuthConfig( - hostId: (json['hostId'] ?? json['id'] ?? '').toString(), - name: (json['name'] ?? '').toString(), - host: (json['host'] ?? '').toString(), - port: _parsePort(json['port']), - username: (json['username'] ?? '').toString(), - authType: (json['authType'] ?? '').toString(), - password: (json['password'] ?? '').toString(), - privateKey: (json['privateKey'] ?? '').toString(), - passphrase: (json['passphrase'] ?? '').toString(), - ); - } -} - -class SshProxyConfig { - const SshProxyConfig({ - required this.id, - required this.name, - required this.type, - required this.host, - required this.port, - required this.username, - required this.password, - }); - - final String id; - final String name; - final String type; - final String host; - final int port; - final String username; - final String password; - - factory SshProxyConfig.fromJson(Map json) { - return SshProxyConfig( - id: (json['id'] ?? json['_id'] ?? '').toString(), - name: (json['name'] ?? '').toString(), - type: (json['type'] ?? '').toString(), - host: (json['host'] ?? '').toString(), - port: _parsePort(json['port']), - username: (json['username'] ?? '').toString(), - password: (json['password'] ?? '').toString(), - ); - } -} - -class SshJumpHostConfig extends SshAuthConfig { - const SshJumpHostConfig({ - required super.hostId, - required super.name, - required super.host, - required super.port, - required super.username, - required super.authType, - required super.password, - required super.privateKey, - required super.passphrase, - }); - - factory SshJumpHostConfig.fromJson(Map json) { - final auth = SshAuthConfig.fromJson(json); - return SshJumpHostConfig( - hostId: auth.hostId, - name: auth.name, - host: auth.host, - port: auth.port, - username: auth.username, - authType: auth.authType, - password: auth.password, - privateKey: auth.privateKey, - passphrase: auth.passphrase, - ); - } -} - -class SshConnectionConfig extends SshAuthConfig { - const SshConnectionConfig({ - required super.hostId, - required super.name, - required super.host, - required super.port, - required super.username, - required super.authType, - required super.password, - required super.privateKey, - required super.passphrase, - required this.proxyType, - required this.proxy, - required this.jumpHosts, - }); - - final String proxyType; - final SshProxyConfig? proxy; - final List jumpHosts; - - factory SshConnectionConfig.fromJson(Map json) { - final auth = SshAuthConfig.fromJson(json); - final proxyRaw = json['proxy']; - final jumpHostsRaw = json['jumpHosts']; - return SshConnectionConfig( - hostId: auth.hostId, - name: auth.name, - host: auth.host, - port: auth.port, - username: auth.username, - authType: auth.authType, - password: auth.password, - privateKey: auth.privateKey, - passphrase: auth.passphrase, - proxyType: (json['proxyType'] ?? '').toString(), - proxy: proxyRaw is Map - ? SshProxyConfig.fromJson(proxyRaw) - : null, - jumpHosts: jumpHostsRaw is List - ? jumpHostsRaw - .whereType>() - .map(SshJumpHostConfig.fromJson) - .toList(growable: false) - : const [], - ); - } -} - -int _parsePort(Object? value) { - if (value is int) return value; - if (value is num) return value.toInt(); - return int.tryParse(value?.toString() ?? '') ?? 22; -} -``` - -- [ ] **Step 4: Run mobile config test and commit** - -Run: - -```bash -cd mobile -dart format lib/features/terminal/ssh_connection_config.dart test/features/terminal/ssh_connection_config_test.dart -flutter test test/features/terminal/ssh_connection_config_test.dart -``` - -Expected: PASS. - -Commit: - -```bash -git add mobile/lib/features/terminal/ssh_connection_config.dart mobile/test/features/terminal/ssh_connection_config_test.dart -git commit -m "feat: parse mobile ssh topology config" -``` - -## Task 3: Direct Transport Abstraction - -**Files:** -- Create: `mobile/lib/features/terminal/ssh_transport.dart` -- Modify: `mobile/lib/features/terminal/ssh_terminal_controller.dart` -- Create: `mobile/test/features/terminal/ssh_transport_test.dart` - -- [ ] **Step 1: Write failing direct transport tests** - -Create `mobile/test/features/terminal/ssh_transport_test.dart`: - -```dart -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:dartssh2/dartssh2.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/terminal/ssh_connection_config.dart'; -import 'package:mobile/features/terminal/ssh_transport.dart'; - -class FakeSocket implements SSHSocket { - FakeSocket(this.label); - final String label; - bool closed = false; - - @override - Stream get stream => const Stream.empty(); - - @override - StreamSink> get sink => StreamController>().sink; - - @override - Future get done async {} - - @override - Future close() async { - closed = true; - } - - @override - void destroy() { - closed = true; - } -} - -SshConnectionConfig config({String proxyType = ''}) { - return SshConnectionConfig( - hostId: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'secret', - privateKey: '', - passphrase: '', - proxyType: proxyType, - proxy: null, - jumpHosts: const [], - ); -} - -void main() { - test('opens direct transport when proxyType is empty', () async { - final calls = []; - final socket = FakeSocket('target'); - final factory = SshTransportFactory( - connectSocket: (host, port) async { - calls.add('$host:$port'); - return socket; - }, - ); - - final handle = await factory.open(config()); - - expect(calls, ['10.0.0.2:22']); - expect(handle.socket, same(socket)); - await handle.close(); - expect(socket.closed, isTrue); - }); - - test('fails explicitly for unsupported proxyType', () async { - final factory = SshTransportFactory( - connectSocket: (host, port) async => FakeSocket('unused'), - ); - - expect( - () => factory.open(config(proxyType: 'unknown')), - throwsA(isA().having( - (error) => error.message, - 'message', - 'Unsupported mobile proxy type: unknown', - )), - ); - }); -} -``` - -- [ ] **Step 2: Run the failing direct transport test** - -Run: - -```bash -cd mobile -flutter test test/features/terminal/ssh_transport_test.dart -``` - -Expected: FAIL because `ssh_transport.dart` does not exist. - -- [ ] **Step 3: Implement direct transport abstraction** - -Create `mobile/lib/features/terminal/ssh_transport.dart`: - -```dart -import 'package:dartssh2/dartssh2.dart'; - -import 'ssh_connection_config.dart'; - -typedef SshSocketConnector = Future Function(String host, int port); - -class SshTransportException implements Exception { - const SshTransportException(this.message); - final String message; - - @override - String toString() => message; -} - -class SshTransportHandle { - SshTransportHandle({ - required this.socket, - required List intermediateClients, - }) : _intermediateClients = intermediateClients; - - final SSHSocket socket; - final List _intermediateClients; - bool _closed = false; - - Future close() async { - if (_closed) return; - _closed = true; - for (final client in _intermediateClients.reversed) { - client.close(); - } - await socket.close(); - } -} - -class SshTransportFactory { - SshTransportFactory({ - SshSocketConnector? connectSocket, - }) : _connectSocket = connectSocket ?? SSHSocket.connect; - - final SshSocketConnector _connectSocket; - - Future open(SshConnectionConfig config) async { - if (config.proxyType.isEmpty) { - final socket = await _connectSocket(config.host, config.port); - return SshTransportHandle(socket: socket, intermediateClients: const []); - } - - throw SshTransportException( - 'Unsupported mobile proxy type: ${config.proxyType}', - ); - } -} -``` - -- [ ] **Step 4: Migrate controller to transport factory** - -In `mobile/lib/features/terminal/ssh_terminal_controller.dart`: - -Add import: - -```dart -import 'ssh_transport.dart'; -``` - -Update constructor and fields: - -```dart -SshTerminalController({ - required this.config, - Terminal? terminal, - SshTransportFactory? transportFactory, -}) : terminal = terminal ?? Terminal(), - _transportFactory = transportFactory ?? SshTransportFactory(); - -final SshTransportFactory _transportFactory; -SshTransportHandle? _transport; -``` - -Replace the direct socket line in `connect()`: - -```dart -final transport = await _transportFactory.open(config); -_transport = transport; -``` - -Then pass `transport.socket` into `SSHClient`: - -```dart -_client = SSHClient( - transport.socket, - username: config.username, - onPasswordRequest: config.authType == 'password' ? () => config.password : null, - identities: identities, -); -``` - -In `disconnect()`, after `_client?.close();`, add: - -```dart -await _transport?.close(); -_transport = null; -``` - -- [ ] **Step 5: Run tests and commit** - -Run: - -```bash -cd mobile -dart format lib/features/terminal/ssh_transport.dart lib/features/terminal/ssh_terminal_controller.dart test/features/terminal/ssh_transport_test.dart -flutter test test/features/terminal -``` - -Expected: PASS. - -Commit: - -```bash -git add mobile/lib/features/terminal/ssh_transport.dart mobile/lib/features/terminal/ssh_terminal_controller.dart mobile/test/features/terminal/ssh_transport_test.dart -git commit -m "feat: add mobile ssh transport abstraction" -``` - -## Task 4: SOCKS5 Transport - -**Files:** -- Create: `mobile/lib/features/terminal/socks5_connector.dart` -- Modify: `mobile/lib/features/terminal/ssh_transport.dart` -- Create: `mobile/test/features/terminal/socks5_connector_test.dart` -- Modify: `mobile/test/features/terminal/ssh_transport_test.dart` - -- [ ] **Step 1: Write SOCKS5 connector tests** - -Create `mobile/test/features/terminal/socks5_connector_test.dart`: - -```dart -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/terminal/socks5_connector.dart'; - -Future> readExactly(Socket socket, int length) async { - final bytes = []; - final sub = socket.listen(bytes.addAll); - while (bytes.length < length) { - await Future.delayed(const Duration(milliseconds: 10)); - } - await sub.cancel(); - return bytes.take(length).toList(); -} - -void main() { - test('performs no-auth socks5 handshake', () async { - final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - final captured = >[]; - - unawaited(server.first.then((socket) async { - captured.add(await readExactly(socket, 3)); - socket.add([0x05, 0x00]); - captured.add(await readExactly(socket, 18)); - socket.add([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1F, 0x90]); - })); - - final connector = Socks5Connector(); - final socket = await connector.connect( - proxyHost: '127.0.0.1', - proxyPort: server.port, - targetHost: 'example.com', - targetPort: 22, - ); - - expect(captured.first, [0x05, 0x01, 0x00]); - expect(captured.last.take(5), [0x05, 0x01, 0x00, 0x03, 11]); - await socket.close(); - await server.close(); - }); - - test('performs username password socks5 handshake', () async { - final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - final captured = >[]; - - unawaited(server.first.then((socket) async { - captured.add(await readExactly(socket, 4)); - socket.add([0x05, 0x02]); - captured.add(await readExactly(socket, 5)); - socket.add([0x01, 0x00]); - captured.add(await readExactly(socket, 18)); - socket.add([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1F, 0x90]); - })); - - final connector = Socks5Connector(); - final socket = await connector.connect( - proxyHost: '127.0.0.1', - proxyPort: server.port, - targetHost: 'example.com', - targetPort: 22, - username: 'u', - password: 'p', - ); - - expect(captured.first, [0x05, 0x02, 0x00, 0x02]); - expect(captured[1], [0x01, 0x01, 117, 0x01, 112]); - await socket.close(); - await server.close(); - }); -} -``` - -- [ ] **Step 2: Run the failing SOCKS5 test** - -Run: - -```bash -cd mobile -flutter test test/features/terminal/socks5_connector_test.dart -``` - -Expected: FAIL because `socks5_connector.dart` does not exist. - -- [ ] **Step 3: Implement SOCKS5 connector** - -Create `mobile/lib/features/terminal/socks5_connector.dart`: - -```dart -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:dartssh2/dartssh2.dart'; - -import 'ssh_transport.dart'; - -class SocketSshSocket implements SSHSocket { - SocketSshSocket(this._socket); - - final Socket _socket; - - @override - Stream get stream => _socket.map(Uint8List.fromList); - - @override - StreamSink> get sink => _socket; - - @override - Future get done => _socket.done; - - @override - Future close() => _socket.close(); - - @override - void destroy() { - _socket.destroy(); - } -} - -class Socks5Connector { - Future connect({ - required String proxyHost, - required int proxyPort, - required String targetHost, - required int targetPort, - String username = '', - String password = '', - }) async { - final socket = await Socket.connect(proxyHost, proxyPort); - final iterator = StreamIterator>(socket); - try { - final wantsAuth = username.isNotEmpty || password.isNotEmpty; - socket.add(wantsAuth ? [0x05, 0x02, 0x00, 0x02] : [0x05, 0x01, 0x00]); - final method = await _readExactly(iterator, 2); - if (method[0] != 0x05) { - throw const SshTransportException('SOCKS5 proxy connection failed'); - } - if (method[1] == 0xFF) { - throw const SshTransportException('SOCKS5 authentication failed'); - } - if (method[1] == 0x02) { - await _authenticate(socket, iterator, username, password); - } - - socket.add(_connectRequest(targetHost, targetPort)); - final response = await _readExactly(iterator, 5); - if (response[1] != 0x00) { - throw const SshTransportException('SOCKS5 target connection failed'); - } - final addressLength = switch (response[3]) { - 0x01 => 4, - 0x03 => response[4], - 0x04 => 16, - _ => throw const SshTransportException('SOCKS5 target connection failed'), - }; - if (response[3] == 0x03) { - await _readExactly(iterator, addressLength + 2); - } else { - await _readExactly(iterator, addressLength - 1 + 2); - } - return SocketSshSocket(socket); - } catch (_) { - socket.destroy(); - rethrow; - } - } - - Future _authenticate( - Socket socket, - StreamIterator> iterator, - String username, - String password, - ) async { - final user = utf8.encode(username); - final pass = utf8.encode(password); - socket.add([0x01, user.length, ...user, pass.length, ...pass]); - final response = await _readExactly(iterator, 2); - if (response[1] != 0x00) { - throw const SshTransportException('SOCKS5 authentication failed'); - } - } - - List _connectRequest(String host, int port) { - final hostBytes = utf8.encode(host); - return [ - 0x05, - 0x01, - 0x00, - 0x03, - hostBytes.length, - ...hostBytes, - (port >> 8) & 0xFF, - port & 0xFF, - ]; - } - - Future> _readExactly( - StreamIterator> iterator, - int length, - ) async { - final bytes = []; - while (bytes.length < length && await iterator.moveNext()) { - bytes.addAll(iterator.current); - } - if (bytes.length < length) { - throw const SshTransportException('SOCKS5 proxy connection failed'); - } - return bytes.take(length).toList(); - } -} -``` - -- [ ] **Step 4: Wire SOCKS5 transport into factory** - -In `mobile/lib/features/terminal/ssh_transport.dart`, add import: - -```dart -import 'socks5_connector.dart'; -``` - -Update constructor: - -```dart -SshTransportFactory({ - SshSocketConnector? connectSocket, - Socks5Connector? socks5Connector, -}) : _connectSocket = connectSocket ?? SSHSocket.connect, - _socks5Connector = socks5Connector ?? Socks5Connector(); - -final Socks5Connector _socks5Connector; -``` - -Add branch before the unsupported error: - -```dart -if (config.proxyType == 'proxyServer') { - final proxy = config.proxy; - if (proxy == null) { - throw const SshTransportException('SOCKS5 proxy connection failed'); - } - if (proxy.type != 'socks5') { - throw SshTransportException('Unsupported mobile proxy type: ${proxy.type}'); - } - final socket = await _socks5Connector.connect( - proxyHost: proxy.host, - proxyPort: proxy.port, - targetHost: config.host, - targetPort: config.port, - username: proxy.username, - password: proxy.password, - ); - return SshTransportHandle(socket: socket, intermediateClients: const []); -} -``` - -- [ ] **Step 5: Run SOCKS5 tests and commit** - -Run: - -```bash -cd mobile -dart format lib/features/terminal/socks5_connector.dart lib/features/terminal/ssh_transport.dart test/features/terminal/socks5_connector_test.dart test/features/terminal/ssh_transport_test.dart -flutter test test/features/terminal -``` - -Expected: PASS. - -Commit: - -```bash -git add mobile/lib/features/terminal/socks5_connector.dart mobile/lib/features/terminal/ssh_transport.dart mobile/test/features/terminal/socks5_connector_test.dart mobile/test/features/terminal/ssh_transport_test.dart -git commit -m "feat: support mobile socks5 ssh transport" -``` - -## Task 5: Jump Host Transport - -**Files:** -- Modify: `mobile/lib/features/terminal/ssh_transport.dart` -- Modify: `mobile/test/features/terminal/ssh_transport_test.dart` - -- [ ] **Step 1: Add jump-host fake tests** - -Append to `mobile/test/features/terminal/ssh_transport_test.dart`: - -```dart -test('fails when jumpHosts proxyType has empty chain', () async { - final factory = SshTransportFactory( - connectSocket: (host, port) async => FakeSocket('unused'), - ); - final jumpConfig = SshConnectionConfig( - hostId: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'secret', - privateKey: '', - passphrase: '', - proxyType: 'jumpHosts', - proxy: null, - jumpHosts: const [], - ); - - expect( - () => factory.open(jumpConfig), - throwsA(isA().having( - (error) => error.message, - 'message', - 'Jump host connection failed: empty chain', - )), - ); -}); -``` - -Add a second test after a fake client factory is introduced in implementation: - -```dart -test('opens jump host chain and keeps intermediate clients', () async { - final opened = []; - final closed = []; - final factory = SshTransportFactory( - connectSocket: (host, port) async { - opened.add('tcp:$host:$port'); - return FakeSocket(host); - }, - createClient: (socket, auth) => FakeSshClient( - auth.host, - closed, - forwardSocket: FakeSocket('forward:${auth.host}'), - ), - ); - final jumpConfig = SshConnectionConfig( - hostId: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'secret', - privateKey: '', - passphrase: '', - proxyType: 'jumpHosts', - proxy: null, - jumpHosts: const [ - SshJumpHostConfig( - hostId: 'j1', - name: 'jump', - host: '203.0.113.10', - port: 22, - username: 'root', - authType: 'password', - password: 'jump-secret', - privateKey: '', - passphrase: '', - ), - ], - ); - - final handle = await factory.open(jumpConfig); - - expect(opened, ['tcp:203.0.113.10:22']); - expect(handle.socket, isA()); - await handle.close(); - expect(closed, ['203.0.113.10']); -}); -``` - -- [ ] **Step 2: Refactor `ssh_transport.dart` for injectable client creation** - -In `mobile/lib/features/terminal/ssh_transport.dart`, add: - -```dart -abstract class SshClientHandle { - Future get authenticated; - Future forwardLocal(String host, int port); - void close(); -} - -typedef SshClientCreator = SshClientHandle Function( - SSHSocket socket, - SshAuthConfig auth, -); - -class DartSshClientHandle implements SshClientHandle { - DartSshClientHandle(this.client); - final SSHClient client; - - @override - Future get authenticated => client.authenticated; - - @override - Future forwardLocal(String host, int port) { - return client.forwardLocal(host, port); - } - - @override - void close() { - client.close(); - } -} - -SshClientHandle createDartSshClient(SSHSocket socket, SshAuthConfig auth) { - final identities = auth.authType == 'privateKey' - ? SSHKeyPair.fromPem(auth.privateKey, auth.privateKeyPassphrase) - : null; - final client = SSHClient( - socket, - username: auth.username, - onPasswordRequest: auth.authType == 'password' ? () => auth.password : null, - identities: identities, - ); - return DartSshClientHandle(client); -} -``` - -Update `SshTransportHandle` to store `List` instead of `List`. - -Update `SshTransportFactory` constructor: - -```dart -SshTransportFactory({ - SshSocketConnector? connectSocket, - Socks5Connector? socks5Connector, - SshClientCreator? createClient, -}) : _connectSocket = connectSocket ?? SSHSocket.connect, - _socks5Connector = socks5Connector ?? Socks5Connector(), - _createClient = createClient ?? createDartSshClient; - -final SshClientCreator _createClient; -``` - -- [ ] **Step 3: Implement jump-host branch** - -In `SshTransportFactory.open`, add before the unsupported error: - -```dart -if (config.proxyType == 'jumpHosts') { - if (config.jumpHosts.isEmpty) { - throw const SshTransportException('Jump host connection failed: empty chain'); - } - - final clients = []; - SSHSocket socket = await _connectSocket( - config.jumpHosts.first.host, - config.jumpHosts.first.port, - ); - - try { - for (var i = 0; i < config.jumpHosts.length; i++) { - final jumpHost = config.jumpHosts[i]; - final client = _createClient(socket, jumpHost); - clients.add(client); - try { - await client.authenticated; - } catch (_) { - throw SshTransportException( - 'Jump host authentication failed: ${jumpHost.name.isEmpty ? jumpHost.host : jumpHost.name}', - ); - } - - final nextHost = i == config.jumpHosts.length - 1 - ? config.host - : config.jumpHosts[i + 1].host; - final nextPort = i == config.jumpHosts.length - 1 - ? config.port - : config.jumpHosts[i + 1].port; - try { - socket = await client.forwardLocal(nextHost, nextPort); - } catch (_) { - throw SshTransportException( - 'Jump host forwarding failed: ${jumpHost.host} -> $nextHost', - ); - } - } - - return SshTransportHandle(socket: socket, intermediateClients: clients); - } catch (_) { - for (final client in clients.reversed) { - client.close(); - } - await socket.close(); - rethrow; - } -} -``` - -- [ ] **Step 4: Add fake client used by tests** - -In `mobile/test/features/terminal/ssh_transport_test.dart`, add: - -```dart -class FakeSshClient implements SshClientHandle { - FakeSshClient(this.label, this.closed, {required this.forwardSocket}); - - final String label; - final List closed; - final SSHSocket forwardSocket; - - @override - Future get authenticated async {} - - @override - Future forwardLocal(String host, int port) async => forwardSocket; - - @override - void close() { - closed.add(label); - } -} -``` - -- [ ] **Step 5: Run jump-host tests and commit** - -Run: - -```bash -cd mobile -dart format lib/features/terminal/ssh_transport.dart test/features/terminal/ssh_transport_test.dart -flutter test test/features/terminal -``` - -Expected: PASS. - -Commit: - -```bash -git add mobile/lib/features/terminal/ssh_transport.dart mobile/test/features/terminal/ssh_transport_test.dart -git commit -m "feat: support mobile ssh jump hosts" -``` - -## Task 6: Terminal Error Output and Final Verification - -**Files:** -- Modify: `mobile/lib/features/terminal/ssh_terminal_controller.dart` -- Test: existing terminal tests plus server payload test - -- [ ] **Step 1: Preserve readable transport errors in terminal output** - -In `mobile/lib/features/terminal/ssh_terminal_controller.dart`, wrap the transport and SSH client creation section in `connect()`: - -```dart -try { - final transport = await _transportFactory.open(config); - _transport = transport; - final identities = config.authType == 'privateKey' - ? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase) - : null; - _client = SSHClient( - transport.socket, - username: config.username, - onPasswordRequest: config.authType == 'password' ? () => config.password : null, - identities: identities, - ); -} on SshTransportException catch (error) { - terminal.write('[Error] ${error.message}\r\n'); - rethrow; -} catch (error) { - terminal.write('[Error] $error\r\n'); - rethrow; -} -``` - -Keep the existing shell/session setup after this block. - -- [ ] **Step 2: Run focused verification** - -Run: - -```bash -node server/test/test-mobile-ssh-payload.js -cd mobile -flutter test test/features/terminal -``` - -Expected: - -```text -test-mobile-ssh-payload passed -All tests passed! -``` - -- [ ] **Step 3: Run broader mobile verification** - -Run: - -```bash -cd mobile -flutter test -``` - -Expected: PASS. If unrelated pre-existing tests fail, capture exact failing test names and output in the task review before proceeding. - -- [ ] **Step 4: Commit final controller error handling** - -Commit: - -```bash -git add mobile/lib/features/terminal/ssh_terminal_controller.dart -git commit -m "fix: show mobile ssh transport errors" -``` - -## Self-Review - -- Spec coverage: Server encrypted payload topology, mobile config parsing, direct behavior preservation, SOCKS5 support, jump-host support, unsupported HTTP error, lifecycle cleanup, and tests are all mapped to tasks above. -- Scope: HTTP CONNECT implementation is excluded from this plan and represented as an explicit unsupported proxy error, matching the accepted design's staged support. -- Type consistency: `SshAuthConfig`, `SshProxyConfig`, `SshJumpHostConfig`, `SshTransportHandle`, `SshTransportFactory`, `SshClientHandle`, `SshTransportException`, and `Socks5Connector` are introduced before later tasks reference them. diff --git a/docs/superpowers/plans/2026-05-23-mobile-sftp-text-editor-implementation.md b/docs/superpowers/plans/2026-05-23-mobile-sftp-text-editor-implementation.md deleted file mode 100644 index 3288548..0000000 --- a/docs/superpowers/plans/2026-05-23-mobile-sftp-text-editor-implementation.md +++ /dev/null @@ -1,1756 +0,0 @@ -# Mobile SFTP Text File Editor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let mobile SFTP users single-tap a text file to open a full-screen editor with syntax highlighting, line numbers, undo/redo, language-aware formatting (JSON/YAML/XML), and save-back-to-remote, with size and binary-file guards. - -**Architecture:** Pure client-side, no backend change. Add a `mobile/lib/features/shell/editor/` package with four focused units (sniffer, language detector, formatters, controller) plus a `TextEditorPage` that hosts the third-party `re_editor` widget. `SftpSessionManager` gains `readTextFile` / `writeTextFile` helpers that wrap existing `_readRemoteFile` / `_writeRemoteFile` with size-stat and NUL-byte gates, and two typed exceptions so the UI can branch on them. `_SftpFileRow.onTap` in `sftp_tab.dart` becomes the single entry point: tap a file → call `readTextFile` → on success `Navigator.push` the editor, on the two typed exceptions toast localized errors. - -**Tech Stack:** Flutter / Dart `^3.11.5`, `re_editor` + `re_highlight` (Reqable, MIT), `yaml ^3.1.2`, `xml ^6.5.0`, existing `dartssh2 ^2.12.0` + `flutter_riverpod`. Spec reference: `docs/superpowers/specs/2026-05-23-mobile-sftp-text-editor-design.md`. Per `CLAUDE.md` the mobile workflow runs `flutter analyze` only — tests are written but not auto-run. - ---- - -## File Structure - -- Modify: `mobile/pubspec.yaml` - - Add `re_editor`, `re_highlight`, `yaml ^3.1.2`, `xml ^6.5.0` under `dependencies`. -- Create: `mobile/lib/features/shell/editor/editor_text_sniffer.dart` - - `TextSniffResult` data class + `sniffAndDecode(Uint8List bytes)` returning binary flag, malformedUtf8 flag, and decoded text. -- Create: `mobile/test/features/shell/editor/editor_text_sniffer_test.dart` - - Cover NUL-sniff window, pure ASCII, valid UTF-8 multibyte, malformed UTF-8 fallback, empty input. -- Create: `mobile/lib/features/shell/editor/editor_language.dart` - - `EditorLanguage` data class + `detectFromFileName(String name)` returning id / `re_highlight` Mode / formatSupported / defaultIndent. -- Create: `mobile/test/features/shell/editor/editor_language_test.dart` - - Cover .json / .yaml / .xml / .yml / .ts / .sh / unknown extension / no extension. -- Create: `mobile/lib/features/shell/editor/editor_formatters.dart` - - `formatJson`, `formatYaml`, `formatXml`; throws `FormatException` on parse failure. -- Create: `mobile/test/features/shell/editor/editor_formatters_test.dart` - - Cover valid + malformed input for each formatter. -- Modify: `mobile/lib/features/shell/sftp_session_manager.dart` - - Add `SftpFileTooLargeException`, `SftpBinaryFileException`, `readTextFile`, `writeTextFile`. -- Create: `mobile/test/features/shell/sftp_session_manager_text_test.dart` - - Sanity-check that the two exception classes carry their fields and that `readTextFile`'s size limit constant matches the spec (lightweight, no real SSH). -- Create: `mobile/lib/features/shell/editor/text_editor_controller.dart` - - `ChangeNotifier` holding `CodeLineEditingController code`, `_originalText`, `_saving`, `isDirty`, `save()`, `format()`. -- Create: `mobile/test/features/shell/editor/text_editor_controller_test.dart` - - Fake `SftpSessionManager` covering: isDirty on edit, save updates baseline, save failure preserves isDirty, format rewrites text, format on unsupported language no-ops. -- Create: `mobile/lib/features/shell/editor/text_editor_page.dart` - - `StatefulWidget`: AppBar + MetaBar + `CodeEditor` + StatusBar + ActionBar; `PopScope` unsaved guard. -- Modify: `mobile/lib/l10n/strings_en.dart` - - Append `editor.*` keys before closing `};`. -- Modify: `mobile/lib/l10n/strings_zh.dart` - - Same keys with zh text. -- Modify: `mobile/lib/features/shell/sftp_tab.dart` - - Replace `_SftpFileRow.onTap` non-directory branch (currently no-op) with `_openInEditor(session, entry)`; add the handler near `_showFileActionSheet`. - ---- - -## Task 1: Add Dependencies and Skeleton Directory - -**Files:** -- Modify: `mobile/pubspec.yaml` -- Create: `mobile/lib/features/shell/editor/.gitkeep` (only if dir-as-empty; otherwise skip — Task 2 creates the first file) - -- [ ] **Step 1: Add packages via pub** - -Run from `mobile/`: - -``` -flutter pub add re_editor re_highlight yaml:^3.1.2 xml:^6.5.0 -``` - -Expected: `pubspec.yaml` gets four new entries and `pubspec.lock` updates. Note the caret ranges that pub picks for `re_editor` and `re_highlight` (they are 0.x). - -- [ ] **Step 2: Verify pubspec block** - -Open `mobile/pubspec.yaml`. The `dependencies:` block should now contain (in addition to existing lines): - -```yaml - re_editor: ^ - re_highlight: ^ - yaml: ^3.1.2 - xml: ^6.5.0 -``` - -If pub chose a `^0.x` caret for `re_editor` or `re_highlight`, leave it as-is — that matches the spec note. - -- [ ] **Step 3: Sanity build** - -Run from `mobile/`: - -``` -flutter pub get -flutter analyze -``` - -Expected: no new errors. Pre-existing analyzer output is untouched. - -- [ ] **Step 4: Commit** - -``` -git add mobile/pubspec.yaml mobile/pubspec.lock -git commit -m "feat(mobile): 新增 re_editor / yaml / xml 依赖,准备 SFTP 文本编辑器" -``` - ---- - -## Task 2: Text Sniffer Module - -**Files:** -- Create: `mobile/lib/features/shell/editor/editor_text_sniffer.dart` -- Test: `mobile/test/features/shell/editor/editor_text_sniffer_test.dart` - -- [ ] **Step 1: Write the failing test** - -Create `mobile/test/features/shell/editor/editor_text_sniffer_test.dart`: - -```dart -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/shell/editor/editor_text_sniffer.dart'; - -void main() { - group('sniffAndDecode', () { - test('returns plain text for ASCII bytes', () { - final result = sniffAndDecode(Uint8List.fromList(utf8.encode('hello\nworld'))); - expect(result.isBinary, isFalse); - expect(result.malformedUtf8, isFalse); - expect(result.text, 'hello\nworld'); - }); - - test('detects binary when NUL byte appears in first 8 KB', () { - final bytes = Uint8List.fromList([0x68, 0x69, 0x00, 0x21]); - final result = sniffAndDecode(bytes); - expect(result.isBinary, isTrue); - expect(result.text, ''); - }); - - test('only inspects first 8 KB for NUL', () { - final builder = BytesBuilder() - ..add(Uint8List(8192)) - ..addByte(0); // NUL just past the 8 KB window - final bytes = builder.toBytes(); - // Replace the first 8 KB with non-NUL ASCII spaces. - for (var i = 0; i < 8192; i++) { - bytes[i] = 0x20; - } - final result = sniffAndDecode(bytes); - expect(result.isBinary, isFalse); - }); - - test('handles valid multibyte UTF-8 without flagging malformed', () { - final result = sniffAndDecode(Uint8List.fromList(utf8.encode('你好 hello 🌐'))); - expect(result.isBinary, isFalse); - expect(result.malformedUtf8, isFalse); - expect(result.text, '你好 hello 🌐'); - }); - - test('flags malformedUtf8 but still decodes via allowMalformed', () { - final bytes = Uint8List.fromList([0x68, 0xC3, 0x28, 0x69]); // invalid UTF-8 - final result = sniffAndDecode(bytes); - expect(result.isBinary, isFalse); - expect(result.malformedUtf8, isTrue); - expect(result.text, isNotEmpty); - }); - - test('empty input is treated as plain text', () { - final result = sniffAndDecode(Uint8List(0)); - expect(result.isBinary, isFalse); - expect(result.malformedUtf8, isFalse); - expect(result.text, ''); - }); - }); -} -``` - -- [ ] **Step 2: Write the implementation** - -Create `mobile/lib/features/shell/editor/editor_text_sniffer.dart`: - -```dart -import 'dart:convert'; -import 'dart:math' as math; -import 'dart:typed_data'; - -class TextSniffResult { - const TextSniffResult({ - required this.isBinary, - required this.malformedUtf8, - required this.text, - }); - - final bool isBinary; - final bool malformedUtf8; - final String text; -} - -TextSniffResult sniffAndDecode(Uint8List bytes) { - final probeLen = math.min(8192, bytes.length); - for (var i = 0; i < probeLen; i++) { - if (bytes[i] == 0) { - return const TextSniffResult( - isBinary: true, - malformedUtf8: false, - text: '', - ); - } - } - - var malformed = false; - try { - utf8.decode(bytes); - } on FormatException { - malformed = true; - } - - final text = utf8.decode(bytes, allowMalformed: true); - return TextSniffResult( - isBinary: false, - malformedUtf8: malformed, - text: text, - ); -} -``` - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors in the new files. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/features/shell/editor/editor_text_sniffer.dart mobile/test/features/shell/editor/editor_text_sniffer_test.dart -git commit -m "feat(mobile): 新增 editor_text_sniffer 二进制与 UTF-8 嗅探" -``` - ---- - -## Task 3: Language Detection Module - -**Files:** -- Create: `mobile/lib/features/shell/editor/editor_language.dart` -- Test: `mobile/test/features/shell/editor/editor_language_test.dart` - -- [ ] **Step 1: Write the failing test** - -Create `mobile/test/features/shell/editor/editor_language_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/shell/editor/editor_language.dart'; - -void main() { - group('detectFromFileName', () { - test('maps .json to JSON with formatter and highlight', () { - final lang = detectFromFileName('config.json'); - expect(lang.id, 'JSON'); - expect(lang.formatSupported, isTrue); - expect(lang.highlightMode, isNotNull); - expect(lang.defaultIndent, 2); - }); - - test('maps .yaml and .yml to YAML', () { - expect(detectFromFileName('a.yaml').id, 'YAML'); - expect(detectFromFileName('a.yml').id, 'YAML'); - expect(detectFromFileName('a.yaml').formatSupported, isTrue); - }); - - test('maps .xml to XML', () { - final lang = detectFromFileName('pom.xml'); - expect(lang.id, 'XML'); - expect(lang.formatSupported, isTrue); - }); - - test('maps .ts to TypeScript without formatter support', () { - final lang = detectFromFileName('app.ts'); - expect(lang.id, 'TypeScript'); - expect(lang.formatSupported, isFalse); - expect(lang.highlightMode, isNotNull); - }); - - test('maps .sh to Bash without formatter support', () { - final lang = detectFromFileName('deploy.sh'); - expect(lang.id, 'Bash'); - expect(lang.formatSupported, isFalse); - }); - - test('unknown extension falls back to plaintext with no highlight', () { - final lang = detectFromFileName('notes.unknownext'); - expect(lang.id, 'Plain Text'); - expect(lang.formatSupported, isFalse); - expect(lang.highlightMode, isNull); - }); - - test('file without extension falls back to plaintext', () { - final lang = detectFromFileName('README'); - expect(lang.id, 'Plain Text'); - expect(lang.highlightMode, isNull); - }); - - test('is case-insensitive on extension', () { - expect(detectFromFileName('UPPER.JSON').id, 'JSON'); - }); - }); -} -``` - -- [ ] **Step 2: Write the implementation** - -Create `mobile/lib/features/shell/editor/editor_language.dart`: - -```dart -import 'package:re_highlight/languages/bash.dart'; -import 'package:re_highlight/languages/dart.dart'; -import 'package:re_highlight/languages/dockerfile.dart'; -import 'package:re_highlight/languages/go.dart'; -import 'package:re_highlight/languages/ini.dart'; -import 'package:re_highlight/languages/javascript.dart'; -import 'package:re_highlight/languages/json.dart'; -import 'package:re_highlight/languages/markdown.dart'; -import 'package:re_highlight/languages/nginx.dart'; -import 'package:re_highlight/languages/python.dart'; -import 'package:re_highlight/languages/sql.dart'; -import 'package:re_highlight/languages/typescript.dart'; -import 'package:re_highlight/languages/xml.dart'; -import 'package:re_highlight/languages/yaml.dart'; -import 'package:re_highlight/re_highlight.dart'; - -class EditorLanguage { - const EditorLanguage({ - required this.id, - required this.highlightMode, - required this.formatSupported, - required this.defaultIndent, - }); - - final String id; - final Mode? highlightMode; - final bool formatSupported; - final int defaultIndent; -} - -const _plainText = EditorLanguage( - id: 'Plain Text', - highlightMode: null, - formatSupported: false, - defaultIndent: 2, -); - -EditorLanguage detectFromFileName(String name) { - final dot = name.lastIndexOf('.'); - if (dot < 0 || dot == name.length - 1) { - return _plainText; - } - final ext = name.substring(dot + 1).toLowerCase(); - switch (ext) { - case 'json': - return EditorLanguage( - id: 'JSON', - highlightMode: langJson, - formatSupported: true, - defaultIndent: 2, - ); - case 'yaml': - case 'yml': - return EditorLanguage( - id: 'YAML', - highlightMode: langYaml, - formatSupported: true, - defaultIndent: 2, - ); - case 'xml': - case 'html': - case 'htm': - case 'svg': - return EditorLanguage( - id: 'XML', - highlightMode: langXml, - formatSupported: ext == 'xml', - defaultIndent: 2, - ); - case 'ts': - case 'tsx': - return EditorLanguage( - id: 'TypeScript', - highlightMode: langTypescript, - formatSupported: false, - defaultIndent: 2, - ); - case 'js': - case 'jsx': - case 'mjs': - case 'cjs': - return EditorLanguage( - id: 'JavaScript', - highlightMode: langJavascript, - formatSupported: false, - defaultIndent: 2, - ); - case 'sh': - case 'bash': - case 'zsh': - return EditorLanguage( - id: 'Bash', - highlightMode: langBash, - formatSupported: false, - defaultIndent: 2, - ); - case 'py': - return EditorLanguage( - id: 'Python', - highlightMode: langPython, - formatSupported: false, - defaultIndent: 4, - ); - case 'go': - return EditorLanguage( - id: 'Go', - highlightMode: langGo, - formatSupported: false, - defaultIndent: 2, - ); - case 'sql': - return EditorLanguage( - id: 'SQL', - highlightMode: langSql, - formatSupported: false, - defaultIndent: 2, - ); - case 'dart': - return EditorLanguage( - id: 'Dart', - highlightMode: langDart, - formatSupported: false, - defaultIndent: 2, - ); - case 'md': - case 'markdown': - return EditorLanguage( - id: 'Markdown', - highlightMode: langMarkdown, - formatSupported: false, - defaultIndent: 2, - ); - case 'ini': - case 'conf': - case 'cfg': - case 'toml': - return EditorLanguage( - id: 'INI', - highlightMode: langIni, - formatSupported: false, - defaultIndent: 2, - ); - case 'dockerfile': - return EditorLanguage( - id: 'Dockerfile', - highlightMode: langDockerfile, - formatSupported: false, - defaultIndent: 2, - ); - case 'nginx': - return EditorLanguage( - id: 'Nginx', - highlightMode: langNginx, - formatSupported: false, - defaultIndent: 2, - ); - default: - return _plainText; - } -} -``` - -Note: if any of the `re_highlight` language import paths fail to resolve, check the installed version's `lib/languages/` directory and use the actual path. The list mirrors web's `text-editor` mapping; we only need the highlight Modes that ship with the version pub picked. - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. If any imported language file is missing in the installed `re_highlight` version, remove that case branch and the import line — those languages fall back to `_plainText`. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/features/shell/editor/editor_language.dart mobile/test/features/shell/editor/editor_language_test.dart -git commit -m "feat(mobile): 新增 editor_language 按文件名识别语言与高亮" -``` - ---- - -## Task 4: Formatter Module - -**Files:** -- Create: `mobile/lib/features/shell/editor/editor_formatters.dart` -- Test: `mobile/test/features/shell/editor/editor_formatters_test.dart` - -- [ ] **Step 1: Write the failing test** - -Create `mobile/test/features/shell/editor/editor_formatters_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/shell/editor/editor_formatters.dart'; - -void main() { - group('formatJson', () { - test('pretty-prints with 2-space indent', () { - final out = formatJson('{"a":1,"b":[1,2,3]}'); - expect(out, contains('\n "a": 1')); - expect(out, contains(' 1')); - }); - - test('throws FormatException on invalid JSON', () { - expect(() => formatJson('{not json'), throwsA(isA())); - }); - }); - - group('formatYaml', () { - test('round-trips a simple map with 2-space indent', () { - final out = formatYaml('foo: 1\nbar:\n baz: hello'); - expect(out, contains('foo: 1')); - expect(out, contains('bar:')); - expect(out, contains(' baz: hello')); - }); - - test('throws FormatException on invalid YAML', () { - expect(() => formatYaml(': : : not yaml'), throwsA(isA())); - }); - }); - - group('formatXml', () { - test('pretty-prints valid xml with 2-space indent', () { - final out = formatXml('x'); - expect(out, contains('')); - expect(out, contains(' x')); - }); - - test('throws FormatException on invalid XML', () { - expect(() => formatXml(''), throwsA(isA())); - }); - }); -} -``` - -- [ ] **Step 2: Write the implementation** - -Create `mobile/lib/features/shell/editor/editor_formatters.dart`: - -```dart -import 'dart:convert'; - -import 'package:xml/xml.dart'; -import 'package:yaml/yaml.dart'; - -String formatJson(String src) { - try { - final decoded = jsonDecode(src); - return const JsonEncoder.withIndent(' ').convert(decoded); - } on FormatException { - rethrow; - } -} - -String formatYaml(String src) { - try { - final decoded = loadYaml(src); - final buffer = StringBuffer(); - _dumpYaml(decoded, buffer, 0); - final output = buffer.toString(); - return output.endsWith('\n') ? output : '$output\n'; - } on YamlException catch (err) { - throw FormatException(err.message); - } -} - -String formatXml(String src) { - try { - final doc = XmlDocument.parse(src); - return doc.toXmlString(pretty: true, indent: ' '); - } on XmlException catch (err) { - throw FormatException(err.message); - } -} - -void _dumpYaml(dynamic node, StringBuffer buf, int indent) { - final pad = ' ' * indent; - if (node is YamlMap || node is Map) { - final map = node is YamlMap - ? node.nodes.map((k, v) => MapEntry(k.toString(), v.value)) - : (node as Map); - if (map.isEmpty) { - buf.write('{}\n'); - return; - } - var first = true; - for (final entry in map.entries) { - if (!first || indent > 0) buf.write(pad); - first = false; - buf.write('${_yamlKey(entry.key.toString())}:'); - final v = entry.value; - if (v is YamlMap || v is Map || v is YamlList || v is List) { - buf.write('\n'); - _dumpYaml(v, buf, indent + 1); - } else { - buf.write(' ${_yamlScalar(v)}\n'); - } - } - } else if (node is YamlList || node is List) { - final list = node is YamlList ? node.toList() : (node as List); - if (list.isEmpty) { - buf.write('$pad[]\n'); - return; - } - for (final item in list) { - buf.write('$pad- '); - if (item is YamlMap || item is Map || item is YamlList || item is List) { - buf.write('\n'); - _dumpYaml(item, buf, indent + 1); - } else { - buf.write('${_yamlScalar(item)}\n'); - } - } - } else { - buf.write('$pad${_yamlScalar(node)}\n'); - } -} - -String _yamlKey(String key) { - if (RegExp(r'^[A-Za-z_][\w\-]*$').hasMatch(key)) return key; - return _yamlQuote(key); -} - -String _yamlScalar(dynamic value) { - if (value == null) return 'null'; - if (value is bool) return value.toString(); - if (value is num) return value.toString(); - final s = value.toString(); - if (s.isEmpty) return '""'; - if (RegExp(r'^(true|false|null|~|\d|-)').hasMatch(s) || s.contains(': ') || s.contains('#')) { - return _yamlQuote(s); - } - return s; -} - -String _yamlQuote(String s) { - final escaped = s.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); - return '"$escaped"'; -} -``` - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/features/shell/editor/editor_formatters.dart mobile/test/features/shell/editor/editor_formatters_test.dart -git commit -m "feat(mobile): 新增 editor_formatters JSON/YAML/XML 格式化" -``` - ---- - -## Task 5: SftpSessionManager Read/Write Text Helpers - -**Files:** -- Modify: `mobile/lib/features/shell/sftp_session_manager.dart` -- Test: `mobile/test/features/shell/sftp_session_manager_text_test.dart` - -- [ ] **Step 1: Write the failing test** - -Create `mobile/test/features/shell/sftp_session_manager_text_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/shell/sftp_session_manager.dart'; - -void main() { - group('Sftp text exceptions', () { - test('SftpFileTooLargeException carries size info', () { - final err = SftpFileTooLargeException( - path: '/tmp/big.log', - size: 5 * 1024 * 1024, - limit: 2 * 1024 * 1024, - ); - expect(err.path, '/tmp/big.log'); - expect(err.size, 5 * 1024 * 1024); - expect(err.limit, 2 * 1024 * 1024); - expect(err.toString(), contains('big.log')); - }); - - test('SftpBinaryFileException carries path', () { - final err = SftpBinaryFileException(path: '/usr/bin/ls'); - expect(err.path, '/usr/bin/ls'); - expect(err.toString(), contains('ls')); - }); - }); -} -``` - -- [ ] **Step 2: Add exceptions and helpers** - -Open `mobile/lib/features/shell/sftp_session_manager.dart`. At the bottom of the file (after the existing `class _SftpConnection` block) append: - -```dart -class SftpFileTooLargeException implements Exception { - SftpFileTooLargeException({ - required this.path, - required this.size, - required this.limit, - }); - - final String path; - final int size; - final int limit; - - @override - String toString() => 'SftpFileTooLargeException($path, size=$size, limit=$limit)'; -} - -class SftpBinaryFileException implements Exception { - SftpBinaryFileException({required this.path}); - - final String path; - - @override - String toString() => 'SftpBinaryFileException($path)'; -} - -class SftpTextFileData { - const SftpTextFileData({ - required this.bytes, - required this.malformedUtf8, - }); - - final Uint8List bytes; - final bool malformedUtf8; -} -``` - -Then inside `class SftpSessionManager`, just below the existing `downloadFileBytes` method (around line 347), add: - -```dart - static const int defaultTextFileMaxBytes = 2 * 1024 * 1024; - - Future readTextFile( - String remotePath, { - int maxBytes = defaultTextFileMaxBytes, - }) async { - final state = activeSession; - if (state == null) { - throw StateError('No active SFTP session'); - } - final connection = _connections[state.server.id]; - if (connection == null) { - throw StateError('No active SFTP connection'); - } - final sftp = connection.sftp; - final stat = await sftp.stat(remotePath); - final size = stat.size ?? 0; - if (size > maxBytes) { - throw SftpFileTooLargeException( - path: remotePath, - size: size, - limit: maxBytes, - ); - } - final bytes = await _readRemoteFile(sftp, remotePath); - final probeLen = bytes.length < 8192 ? bytes.length : 8192; - for (var i = 0; i < probeLen; i++) { - if (bytes[i] == 0) { - throw SftpBinaryFileException(path: remotePath); - } - } - return bytes; - } - - Future writeTextFile(String remotePath, String content) async { - final state = activeSession; - if (state == null) { - throw StateError('No active SFTP session'); - } - final connection = _connections[state.server.id]; - if (connection == null) { - throw StateError('No active SFTP connection'); - } - await _writeRemoteFile( - connection.sftp, - remotePath, - Uint8List.fromList(utf8.encode(content)), - ); - } -``` - -If `dart:convert` is not yet imported at the top of the file, add it. (`utf8` lives there.) - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/features/shell/sftp_session_manager.dart mobile/test/features/shell/sftp_session_manager_text_test.dart -git commit -m "feat(mobile): SftpSessionManager 新增 readTextFile/writeTextFile 与异常类型" -``` - ---- - -## Task 6: TextEditorController - -**Files:** -- Create: `mobile/lib/features/shell/editor/text_editor_controller.dart` -- Test: `mobile/test/features/shell/editor/text_editor_controller_test.dart` - -- [ ] **Step 1: Write the failing test** - -Create `mobile/test/features/shell/editor/text_editor_controller_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/shell/editor/editor_language.dart'; -import 'package:mobile/features/shell/editor/text_editor_controller.dart'; - -class _FakeWriter implements TextEditorWriter { - _FakeWriter({this.shouldThrow = false}); - bool shouldThrow; - String? lastWritten; - int writes = 0; - - @override - Future writeTextFile(String remotePath, String content) async { - writes++; - if (shouldThrow) throw Exception('boom'); - lastWritten = content; - } -} - -void main() { - group('TextEditorController', () { - test('starts clean and becomes dirty on edit', () { - final controller = TextEditorController( - writer: _FakeWriter(), - remotePath: '/etc/app.json', - originalText: '{"a":1}', - language: detectFromFileName('app.json'), - totalBytes: 7, - ); - - expect(controller.isDirty, isFalse); - controller.code.text = '{"a":2}'; - expect(controller.isDirty, isTrue); - controller.dispose(); - }); - - test('save persists content and resets isDirty on success', () async { - final writer = _FakeWriter(); - final controller = TextEditorController( - writer: writer, - remotePath: '/etc/app.json', - originalText: '{"a":1}', - language: detectFromFileName('app.json'), - totalBytes: 7, - ); - controller.code.text = '{"a":2}'; - await controller.save(); - expect(writer.lastWritten, '{"a":2}'); - expect(controller.isDirty, isFalse); - controller.dispose(); - }); - - test('save failure keeps isDirty true and rethrows', () async { - final writer = _FakeWriter(shouldThrow: true); - final controller = TextEditorController( - writer: writer, - remotePath: '/etc/app.json', - originalText: '{"a":1}', - language: detectFromFileName('app.json'), - totalBytes: 7, - ); - controller.code.text = '{"a":2}'; - await expectLater(controller.save(), throwsException); - expect(controller.isDirty, isTrue); - controller.dispose(); - }); - - test('format applies JSON formatter when supported', () { - final controller = TextEditorController( - writer: _FakeWriter(), - remotePath: '/etc/app.json', - originalText: '{"a":1,"b":2}', - language: detectFromFileName('app.json'), - totalBytes: 14, - ); - controller.code.text = '{"a":1,"b":2}'; - controller.format(); - expect(controller.code.text, contains('\n "a": 1')); - controller.dispose(); - }); - - test('canFormat is false for plaintext', () { - final controller = TextEditorController( - writer: _FakeWriter(), - remotePath: '/etc/notes', - originalText: 'plain', - language: detectFromFileName('notes'), - totalBytes: 5, - ); - expect(controller.canFormat, isFalse); - controller.dispose(); - }); - }); -} -``` - -- [ ] **Step 2: Write the implementation** - -Create `mobile/lib/features/shell/editor/text_editor_controller.dart`: - -```dart -import 'package:flutter/foundation.dart'; -import 'package:re_editor/re_editor.dart'; - -import 'editor_formatters.dart'; -import 'editor_language.dart'; - -abstract class TextEditorWriter { - Future writeTextFile(String remotePath, String content); -} - -class TextEditorController extends ChangeNotifier { - TextEditorController({ - required TextEditorWriter writer, - required this.remotePath, - required String originalText, - required this.language, - required this.totalBytes, - }) : _writer = writer, - _originalText = originalText, - code = CodeLineEditingController.fromText(originalText) { - code.addListener(_onCodeChanged); - } - - final TextEditorWriter _writer; - final String remotePath; - final EditorLanguage language; - final int totalBytes; - final CodeLineEditingController code; - - String _originalText; - bool _saving = false; - String? _lastError; - - bool get isDirty => code.text != _originalText; - bool get saving => _saving; - bool get canFormat => language.formatSupported; - String? get lastError => _lastError; - - void _onCodeChanged() { - notifyListeners(); - } - - Future save() async { - if (_saving) return; - _saving = true; - _lastError = null; - notifyListeners(); - try { - final content = code.text; - await _writer.writeTextFile(remotePath, content); - _originalText = content; - } catch (error) { - _lastError = error.toString(); - rethrow; - } finally { - _saving = false; - notifyListeners(); - } - } - - void format() { - if (!language.formatSupported) return; - final src = code.text; - final String formatted; - switch (language.id) { - case 'JSON': - formatted = formatJson(src); - break; - case 'YAML': - formatted = formatYaml(src); - break; - case 'XML': - formatted = formatXml(src); - break; - default: - return; - } - if (formatted != src) { - code.text = formatted; - } - } - - @override - void dispose() { - code.removeListener(_onCodeChanged); - code.dispose(); - super.dispose(); - } -} -``` - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/features/shell/editor/text_editor_controller.dart mobile/test/features/shell/editor/text_editor_controller_test.dart -git commit -m "feat(mobile): 新增 TextEditorController 协调脏标记/保存/格式化" -``` - ---- - -## Task 7: i18n Keys - -**Files:** -- Modify: `mobile/lib/l10n/strings_en.dart` -- Modify: `mobile/lib/l10n/strings_zh.dart` - -- [ ] **Step 1: Append editor.* keys to English** - -In `mobile/lib/l10n/strings_en.dart`, replace the closing `};` (currently at line 205) with the block below — i.e., insert these key/value pairs after `'terminal.status.error': 'Error',` and before `};`: - -```dart - // Editor (mobile SFTP text file) - 'editor.tooLarge': 'File exceeds 2 MB. Download to edit.', - 'editor.binary': 'Binary file is not editable.', - 'editor.readFailed': 'Read failed: {0}', - 'editor.saveFailed': 'Save failed: {0}', - 'editor.saved': 'Saved', - 'editor.unsaved': 'Unsaved', - 'editor.format': 'Format', - 'editor.save': 'Save', - 'editor.discardTitle': 'Discard changes?', - 'editor.discardBody': 'Unsaved edits will be lost. Leave?', - 'editor.discardKeepEditing': 'Keep editing', - 'editor.discardLeave': 'Discard', - 'editor.discardSaveAndLeave': 'Save & leave', - 'editor.malformedUtf8': 'File contains non-UTF-8 bytes; saving may lose some characters.', - 'editor.formatUnsupported': 'Format not supported for this language.', - 'editor.formatFailed': 'Format failed: {0}', - 'editor.statusEncoding': 'UTF-8 · LF · {0}', - 'editor.statusPosition': 'Ln {0}, Col {1}', - 'editor.statusLineCount': '{0} / {1}', - 'editor.statusSpaces': 'Spaces: {0}', -}; -``` - -- [ ] **Step 2: Append the same keys to Chinese** - -In `mobile/lib/l10n/strings_zh.dart`, replace the closing `};` (currently at line 194) the same way: - -```dart - // Editor (mobile SFTP 文本文件) - 'editor.tooLarge': '文件超过 2 MB,请下载后再编辑', - 'editor.binary': '二进制文件不支持编辑', - 'editor.readFailed': '读取失败:{0}', - 'editor.saveFailed': '保存失败:{0}', - 'editor.saved': '已保存', - 'editor.unsaved': '未保存', - 'editor.format': '格式化', - 'editor.save': '保存', - 'editor.discardTitle': '放弃修改?', - 'editor.discardBody': '当前修改未保存,确定离开?', - 'editor.discardKeepEditing': '继续编辑', - 'editor.discardLeave': '放弃', - 'editor.discardSaveAndLeave': '保存并退出', - 'editor.malformedUtf8': '文件含非 UTF-8 字节,保存可能丢失部分字符', - 'editor.formatUnsupported': '当前语言不支持格式化', - 'editor.formatFailed': '格式化失败:{0}', - 'editor.statusEncoding': 'UTF-8 · LF · {0}', - 'editor.statusPosition': 'Ln {0}, Col {1}', - 'editor.statusLineCount': '{0} / {1}', - 'editor.statusSpaces': 'Spaces: {0}', -}; -``` - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/l10n/strings_en.dart mobile/lib/l10n/strings_zh.dart -git commit -m "feat(mobile): 新增 editor.* 国际化文案" -``` - ---- - -## Task 8: TextEditorPage Skeleton (AppBar + MetaBar + Editor + StatusBar) - -**Files:** -- Create: `mobile/lib/features/shell/editor/text_editor_page.dart` - -- [ ] **Step 1: Create page with AppBar, MetaBar, CodeEditor, StatusBar (no ActionBar yet)** - -Create `mobile/lib/features/shell/editor/text_editor_page.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:re_editor/re_editor.dart'; -import 'package:re_highlight/styles/atom-one-dark.dart'; - -import '../../../l10n/app_localizations.dart'; -import '../sftp_session_manager.dart'; -import 'editor_language.dart'; -import 'text_editor_controller.dart'; - -class _EditorPalette { - static const Color background = Color(0xFF0A0F14); - static const Color statusBg = Color(0xFF111827); - static const Color statusBorder = Color(0xFF1F2937); - static const Color statusText = Color(0xFF9CA3AF); - static const Color gutter = Color(0xFF4B5563); - static const Color gutterActive = Color(0xFF9CA3AF); - static const Color appBarBg = Color(0xFF111827); -} - -class _SftpManagerWriter implements TextEditorWriter { - _SftpManagerWriter(this.manager); - final SftpSessionManager manager; - - @override - Future writeTextFile(String remotePath, String content) => - manager.writeTextFile(remotePath, content); -} - -class TextEditorPage extends StatefulWidget { - const TextEditorPage({ - super.key, - required this.manager, - required this.remotePath, - required this.fileName, - required this.initialText, - required this.malformedUtf8, - required this.totalBytes, - }); - - final SftpSessionManager manager; - final String remotePath; - final String fileName; - final String initialText; - final bool malformedUtf8; - final int totalBytes; - - @override - State createState() => _TextEditorPageState(); -} - -class _TextEditorPageState extends State { - late final TextEditorController _controller; - late final EditorLanguage _language; - - @override - void initState() { - super.initState(); - _language = detectFromFileName(widget.fileName); - _controller = TextEditorController( - writer: _SftpManagerWriter(widget.manager), - remotePath: widget.remotePath, - originalText: widget.initialText, - language: _language, - totalBytes: widget.totalBytes, - ); - if (widget.malformedUtf8) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final l = AppLocalizations.of(context); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l.tr('editor.malformedUtf8')))); - }); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, _) => Scaffold( - backgroundColor: _EditorPalette.background, - appBar: _buildAppBar(context), - body: Column( - children: [ - _buildMetaBar(context), - Expanded(child: _buildEditor()), - _buildStatusBar(context), - ], - ), - ), - ); - } - - PreferredSizeWidget _buildAppBar(BuildContext context) { - return AppBar( - backgroundColor: _EditorPalette.appBarBg, - foregroundColor: Colors.white, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.fileName, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - widget.remotePath, - style: const TextStyle(fontSize: 11, color: _EditorPalette.statusText), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - actions: [ - IconButton( - tooltip: 'Undo', - icon: const Icon(Icons.undo), - onPressed: _controller.code.canUndo ? _controller.code.undo : null, - ), - IconButton( - tooltip: 'Redo', - icon: const Icon(Icons.redo), - onPressed: _controller.code.canRedo ? _controller.code.redo : null, - ), - ], - ); - } - - Widget _buildMetaBar(BuildContext context) { - final l = AppLocalizations.of(context); - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: _EditorPalette.appBarBg, - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: _EditorPalette.statusBorder, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _language.id, - style: const TextStyle(fontSize: 11, color: Colors.white), - ), - ), - const SizedBox(width: 12), - Text( - l.tr('editor.statusEncoding', [_formatBytes(widget.totalBytes)]), - style: const TextStyle(fontSize: 11, color: _EditorPalette.statusText), - ), - ], - ), - ); - } - - Widget _buildEditor() { - return CodeEditor( - controller: _controller.code, - style: CodeEditorStyle( - codeTheme: _language.highlightMode == null - ? null - : CodeHighlightTheme( - languages: {_language.id: CodeHighlightThemeMode(mode: _language.highlightMode!)}, - theme: atomOneDarkTheme, - ), - backgroundColor: _EditorPalette.background, - textColor: Colors.white, - fontSize: 13, - fontFamily: 'monospace', - ), - indicatorBuilder: (context, editingController, chunkController, notifier) { - return Row( - children: [ - DefaultCodeLineNumber( - controller: editingController, - notifier: notifier, - textStyle: const TextStyle(color: _EditorPalette.gutter, fontSize: 12), - focusedTextStyle: const TextStyle(color: _EditorPalette.gutterActive, fontSize: 12), - ), - DefaultCodeChunkIndicator( - width: 14, - controller: chunkController, - notifier: notifier, - ), - ], - ); - }, - chunkAnalyzer: const DefaultCodeChunkAnalyzer(), - ); - } - - Widget _buildStatusBar(BuildContext context) { - final l = AppLocalizations.of(context); - final sel = _controller.code.selection; - final lineIndex = sel.baseIndex; - final colIndex = sel.baseOffset; - final total = _controller.code.codeLines.length; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: const BoxDecoration( - color: _EditorPalette.statusBg, - border: Border(top: BorderSide(color: _EditorPalette.statusBorder)), - ), - child: Row( - children: [ - Text( - l.tr('editor.statusPosition', ['${lineIndex + 1}', '${colIndex + 1}']), - style: const TextStyle(fontSize: 11, color: _EditorPalette.statusText), - ), - const SizedBox(width: 16), - Text( - '${_language.id} · ${l.tr('editor.statusSpaces', ['${_language.defaultIndent}'])}', - style: const TextStyle(fontSize: 11, color: _EditorPalette.statusText), - ), - const Spacer(), - Text( - l.tr('editor.statusLineCount', ['${lineIndex + 1}', '$total']), - style: const TextStyle(fontSize: 11, color: _EditorPalette.statusText), - ), - ], - ), - ); - } - - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; - } -} -``` - -If `AppLocalizations` is imported from a different path, adjust the import. (See `mobile/lib/features/shell/sftp_tab.dart` for the existing import.) - -- [ ] **Step 2: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. If `DefaultCodeLineNumber` / `DefaultCodeChunkIndicator` / `DefaultCodeChunkAnalyzer` have a different name in the installed `re_editor`, check `re_editor`'s `lib/` exports and adjust. - -- [ ] **Step 3: Commit** - -``` -git add mobile/lib/features/shell/editor/text_editor_page.dart -git commit -m "feat(mobile): 新增 TextEditorPage 骨架与编辑区" -``` - ---- - -## Task 9: TextEditorPage Action Bar + Pop Guard - -**Files:** -- Modify: `mobile/lib/features/shell/editor/text_editor_page.dart` - -- [ ] **Step 1: Add the bottom action bar** - -In `text_editor_page.dart`, change the body `Column` children to include the action bar at the bottom, and wrap the `Scaffold` in a `PopScope`. Replace the `Widget build(...)` method body with: - -```dart - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - return PopScope( - canPop: !_controller.isDirty, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - final action = await _showDiscardDialog(context); - if (!mounted) return; - if (action == _DiscardAction.discard) { - Navigator.of(context).pop(); - } else if (action == _DiscardAction.saveAndLeave) { - try { - await _controller.save(); - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l.tr('editor.saved')))); - if (mounted) Navigator.of(context).pop(); - } catch (error) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('editor.saveFailed', [error.toString()]))), - ); - } - } - }, - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) => Scaffold( - backgroundColor: _EditorPalette.background, - appBar: _buildAppBar(context), - body: Column( - children: [ - _buildMetaBar(context), - Expanded(child: _buildEditor()), - _buildStatusBar(context), - SafeArea(top: false, child: _buildActionBar(context)), - ], - ), - ), - ), - ); - } -``` - -- [ ] **Step 2: Add `_DiscardAction` enum, dialog, action bar, save/format handlers** - -Append inside `_TextEditorPageState` (anywhere before the closing brace) — and add the enum at the bottom of the file: - -```dart - Widget _buildActionBar(BuildContext context) { - final l = AppLocalizations.of(context); - return Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: const BoxDecoration( - color: _EditorPalette.appBarBg, - border: Border(top: BorderSide(color: _EditorPalette.statusBorder)), - ), - child: Row( - children: [ - if (_controller.isDirty) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '• ${l.tr('editor.unsaved')}', - style: const TextStyle(fontSize: 11, color: Colors.amber), - ), - ), - const Spacer(), - TextButton.icon( - icon: const Icon(Icons.auto_fix_high, size: 16), - label: Text(l.tr('editor.format')), - onPressed: _controller.canFormat ? () => _onFormat(context) : null, - ), - const SizedBox(width: 8), - FilledButton.icon( - icon: _controller.saving - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.save, size: 16), - label: Text(l.tr('editor.save')), - onPressed: - (_controller.isDirty && !_controller.saving) ? () => _onSave(context) : null, - ), - ], - ), - ); - } - - Future _onSave(BuildContext context) async { - final l = AppLocalizations.of(context); - try { - await _controller.save(); - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l.tr('editor.saved')))); - } catch (error) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('editor.saveFailed', [error.toString()]))), - ); - } - } - - void _onFormat(BuildContext context) { - final l = AppLocalizations.of(context); - if (!_controller.canFormat) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l.tr('editor.formatUnsupported')))); - return; - } - try { - _controller.format(); - } on FormatException catch (error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('editor.formatFailed', [error.message]))), - ); - } catch (error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('editor.formatFailed', [error.toString()]))), - ); - } - } - - Future<_DiscardAction?> _showDiscardDialog(BuildContext context) { - final l = AppLocalizations.of(context); - return showDialog<_DiscardAction>( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l.tr('editor.discardTitle')), - content: Text(l.tr('editor.discardBody')), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(_DiscardAction.keepEditing), - child: Text(l.tr('editor.discardKeepEditing')), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(_DiscardAction.discard), - child: Text(l.tr('editor.discardLeave')), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(_DiscardAction.saveAndLeave), - child: Text(l.tr('editor.discardSaveAndLeave')), - ), - ], - ), - ); - } -} - -enum _DiscardAction { keepEditing, discard, saveAndLeave } -``` - -(Remove the original closing `}` of `_TextEditorPageState` if it ends up duplicated — the appended block already supplies one.) - -- [ ] **Step 3: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. If `Colors.amber.withValues` isn't available on the installed Flutter (older versions used `withOpacity`), switch to `Colors.amber.withOpacity(0.2)`. - -- [ ] **Step 4: Commit** - -``` -git add mobile/lib/features/shell/editor/text_editor_page.dart -git commit -m "feat(mobile): TextEditorPage 接入格式化/保存按钮与 PopScope 未保存拦截" -``` - ---- - -## Task 10: Wire Single-Tap Entry from sftp_tab.dart - -**Files:** -- Modify: `mobile/lib/features/shell/sftp_tab.dart` - -- [ ] **Step 1: Add imports** - -Open `mobile/lib/features/shell/sftp_tab.dart`. Near the existing `import` block (alongside the other shell imports), add: - -```dart -import 'editor/editor_text_sniffer.dart'; -import 'editor/text_editor_page.dart'; -``` - -- [ ] **Step 2: Replace the non-directory branch in `_SftpFileRow.onTap`** - -Locate the `onTap` callback that wraps `_SftpFileRow` near line 209. Replace: - -```dart - onTap: () { - if (session.hasSelection) { - session.toggleSelection(entry.name); - } else if (entry.isDirectory) { - manager.openPath( - manager.entryPath(session, entry), - ); - } - }, -``` - -with: - -```dart - onTap: () { - if (session.hasSelection) { - session.toggleSelection(entry.name); - } else if (entry.isDirectory) { - manager.openPath( - manager.entryPath(session, entry), - ); - } else { - _openInEditor(context, manager, session, entry); - } - }, -``` - -- [ ] **Step 3: Add `_openInEditor` handler** - -Inside the same `_SftpTabBodyState` (or whatever class owns `_showFileActionSheet`), add this method near `_showFileActionSheet`. If the method is on a stateful widget, you can use `mounted`; otherwise, replace `mounted` checks with `context.mounted`: - -```dart - Future _openInEditor( - BuildContext context, - SftpSessionManager manager, - SftpSessionState session, - SftpFileEntry entry, - ) async { - final l = AppLocalizations.of(context); - final remotePath = manager.entryPath(session, entry); - try { - final bytes = await manager.readTextFile(remotePath); - if (!context.mounted) return; - final sniff = sniffAndDecode(bytes); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TextEditorPage( - manager: manager, - remotePath: remotePath, - fileName: entry.name, - initialText: sniff.text, - malformedUtf8: sniff.malformedUtf8, - totalBytes: bytes.length, - ), - ), - ); - if (!context.mounted) return; - await manager.refreshActive(); - } on SftpFileTooLargeException { - if (!context.mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l.tr('editor.tooLarge')))); - } on SftpBinaryFileException { - if (!context.mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l.tr('editor.binary')))); - } catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.tr('editor.readFailed', [error.toString()]))), - ); - } - } -``` - -Make sure `sniffAndDecode` is the import from the new `editor_text_sniffer.dart`. If `SftpFileEntry`, `SftpSessionState`, `SftpSessionManager`, `SftpFileTooLargeException`, `SftpBinaryFileException` are not already in scope at the call site, ensure the existing `import 'sftp_session_manager.dart';` covers them (it does — Task 5 puts all three exception classes in the same file). - -- [ ] **Step 4: Run analyzer** - -Run from `mobile/`: - -``` -flutter analyze -``` - -Expected: no errors. If the analyzer complains about `mounted` on a non-State class, swap to `context.mounted` (already used above). - -- [ ] **Step 5: Commit** - -``` -git add mobile/lib/features/shell/sftp_tab.dart -git commit -m "feat(mobile): SFTP 单击文件进入文本编辑器,分支三类异常 toast" -``` - ---- - -## Task 11: Final analyze + manual smoke - -**Files:** (none — verification only) - -- [ ] **Step 1: Full analyze run** - -Run from `mobile/`: - -``` -flutter pub get -flutter analyze -``` - -Expected: no errors. - -- [ ] **Step 2: Manual smoke list (record results inline if any deviation)** - -Connect to a test server in SFTP tab and verify the golden path + edge cases manually: - -1. Tap a `.json` file (≤ 2 MB) → editor opens with JSON highlight, line numbers, language chip says `JSON`. -2. Edit one character → action bar shows `• 未保存 / Unsaved`, Save becomes active. -3. Tap `格式化 / Format` → JSON is pretty-printed; isDirty stays true (text changed) or flips to clean if formatted output equals previous saved baseline. -4. Tap `保存 / Save` → toast `已保存 / Saved`; Save button disables. -5. Open a `.yaml` file → YAML highlight; format still works (note: comments are dropped — known limitation). -6. Open a `.xml` file → XML highlight; format works. -7. Open an unknown extension (e.g. `.log`) → plain text, no highlight, format button disabled. -8. Tap a file > 2 MB (e.g. `/var/log/syslog`) → toast `文件超过 2 MB / File exceeds 2 MB`, page does not open. -9. Tap a binary (e.g. `/bin/ls`) → toast `二进制文件不支持编辑 / Binary file is not editable`, page does not open. -10. Edit a file then press the device back button → confirm dialog with three options behaves: keepEditing returns, discard pops without save, saveAndLeave saves then pops; on save error toast appears and page stays. -11. Open a file containing non-UTF-8 byte sequences (e.g., GB18030 text) → page opens with `editor.malformedUtf8` warning toast. - -- [ ] **Step 3: Final commit (only if any analyzer cleanup was needed)** - -If Steps 1–2 surfaced no follow-up fixes, no commit is needed for this task. Otherwise: - -``` -git add -git commit -m "chore(mobile): SFTP 编辑器 smoke 修复" -``` - ---- - -## Notes for the implementing engineer - -- Per `CLAUDE.md`, the mobile workflow runs `flutter analyze` only. The `*_test.dart` files in this plan are written for future regression — do NOT run `flutter test` unless the user explicitly asks for it. -- `.pen` files are encrypted; the design source-of-truth (Pencil node `sYMaF`) was already captured into the spec under `docs/superpowers/specs/2026-05-23-mobile-sftp-text-editor-design.md` §5. Do not try to read the `.pen` file directly — use the spec. -- The web equivalent (`web/src/components/text-editor/index.vue`) is a reference for ext → language mapping. Do not couple the two; mobile is intentionally a smaller surface. -- `re_editor` and `re_highlight` are still 0.x — if pub picks a different version than expected and the API differs, prefer adapting the code (Tasks 6, 8) over downgrading the library, since we want the latest mobile-perf improvements. -- The `_openInEditor` handler is placed inside the same widget that already owns `_showFileActionSheet` so we reuse the same `BuildContext` and `ScaffoldMessenger` pattern (`_showSnack` is a sibling helper if you'd rather use it). diff --git a/docs/superpowers/specs/2026-05-16-mobile-iteration-1-design.md b/docs/superpowers/specs/2026-05-16-mobile-iteration-1-design.md deleted file mode 100644 index 9656da9..0000000 --- a/docs/superpowers/specs/2026-05-16-mobile-iteration-1-design.md +++ /dev/null @@ -1,268 +0,0 @@ -# EasyNode Mobile Iteration 1 Design - -Date: 2026-05-16 - -## Goal - -在 `2026-05-16-mobile-native-terminal-design.md` 的初版基础上完善登录页、服务器列表页、终端页的体验与稳定性,重点处理两个已知 bug,引入应用级终端会话管理以支撑后续多终端 / 挂起 / 批量命令等扩展。 - -## Scope - -包含: - -- 修复"保存密码"开关无法回填密码的 bug -- 修复登录态无法持久化(下次进入仍走登录页) -- 应用级 `TerminalSessionManager`,会话所有权与页面解耦 -- 终端页重构为带顶部 Toolbar、底部 shortcut bar 的 shell 页面 -- 服务器列表页 UI/体验优化 -- 登录页 UI/体验优化 -- 跨页 401/403 自动跳回登录、暗色主题 - -不包含: - -- 服务器 CRUD、SFTP、RDP、跳板机 -- SH 会话挂起到磁盘(manager 内存级生命周期已为后续挂起预留接口) -- 字号 / 主题持久化(终端字号本轮固定) - -## Bug 根因与修复 - -### 保存密码不回填 - -`EasyNodeApp._hydrateInitialPassword()` 在 `initState` 之后异步读密码并 `setState`,但 `LoginPage._LoginPageState._pwdCtrl` 只在子 State 的 `initState` 里读了一次 `widget.initialPassword`。父 State 后来把新值传下来时,`TextEditingController` 不会自动更新。 - -修复:将密码加载提前到启动期,与其它持久化字段一起在 `EasyNodeApp.bootstrap()` 中读完,第一帧 `LoginPage` 即拿到 `initialPassword`。 - -### 登录态不持久 - -`app.dart` 没有任何启动时恢复 session 的逻辑。即使 token / sessionCookie / deviceId 已写入安全存储,每次进入仍创建空 `_session`,回到登录页。 - -修复:bootstrap 中尝试恢复 session: - -1. 读 serverAddress、username、token、sessionCookie、deviceId、savePassword、password -2. 如果 token 与 sessionCookie 齐全,构建 `ApiClient` 并调用 `/get-pub-pem` - - 成功:构造 `AuthSession`,跳过登录直接进入服务器列表 - - 401/403/网络失败:清 token + sessionCookie + deviceId,回登录页(保留 serverAddress / username / savePassword / password 偏好) -3. 启动期间显示轻量 splash(指示器 + 应用名),避免空白闪屏 - -## 应用级终端会话管理 - -### `TerminalSessionManager` - -- `extends ChangeNotifier`,根级注入,所有 widget 通过 `InheritedNotifier` / `Provider` 风格 `_TerminalSessionScope` 访问 -- 持有 `List`: - - `id` (uuid v4) - - `hostId` - - `displayName` - - `status`: `connecting` | `connected` | `disconnected` | `error` - - `lastError` - - `controller`: `SshTerminalController` -- 公开方法: - - `Future openSession(SshConnectionConfig)` - - `Future closeSession(String id)` - - `void setActive(String id)` - - `String? get activeId` - - `Iterable get sessions` - - `Future reconnect(String id)` -- session 生命周期与页面 routes 完全解耦;manager 是唯一所有者 -- session 状态变更通过 `notifyListeners` 推送 UI;同时把 `[Disconnected]` / `[Reconnecting]` 写入对应 xterm Terminal,保留 scrollback - -### 侧滑返回不断开 - -- 路由 pop 不调 `disconnect()`;manager 持有 controllers -- 重新 push 终端页时通过 manager 拿现有 session,xterm Terminal 实例复用,scrollback 完整保留 - -## 终端页 (`TerminalShellPage`) - -页面结构: - -``` -┌─────────────────────────────────┐ -│ [⚏³] api-1 ●已连接 [+] [✕] │ Toolbar (高 52) -├─────────────────────────────────┤ -│ │ -│ xterm view (active session) │ -│ │ -├─────────────────────────────────┤ -│ Esc Tab Ctrl-C ↵ ↑↓←→ Ctrl-D ... → │ Shortcut bar (高 44) -└─────────────────────────────────┘ -``` - -### Toolbar - -固定高度 52,水平排布。 - -**左:紫色堆叠图标 (`StackedSessionsIcon`)** - -- `CustomPaint` 绘制三层错位矩形,从深紫到浅紫渐变,2px 错位投影 -- 右上角徽标显示当前 session 数;只有 1 个时图标变单层、无徽标 -- 点击通过 `OverlayEntry` 在图标左下角弹出菜单: - - 宽度 240 - - 高度 `min(行数 × 48 + 16, screenHeight × 0.55)` - - 超出最大高度时启用 `Scrollbar` 常显的纵向滚动 - - 行结构:状态点 + session 名(超长省略),当前项右侧 ✓ 且浅紫高亮背景 - - 点击行 → `setActive(id)` 后关闭浮层 - - 点外部 / 返回键关闭 - -**中:当前 session 名 + 状态徽章** - -- session 名超长省略 -- 状态徽章:圆点 + 文字(连接中黄、已连接绿、已断开灰、错误红) - -**右:`+` 新建、`✕` 关闭** - -`+` 点击弹出"打开服务器"菜单(同样基于 OverlayEntry): - -- 宽度 280 -- 高度 `min(内容高, screenHeight × 0.55)`,超出滚动 -- 行结构:服务器名 + `username@host:port`;已连接的 host 右侧加绿色小点提示再点会再开一个 session -- 点击行 → 取 SSH 参数 → `manager.openSession()` → 自动 `setActive` 到新 session - -`✕` 点击关闭当前 session: - -- 已连接状态弹小气泡 confirm;已断开直接关 -- 关闭后查剩余: - - 还有 → `setActive(剩余 list.first.id)` 留在终端页 - - 没有 → `Navigator.pop` 回服务器列表 - -### 终端区 - -- `IndexedStack` 承载所有 session 的 `TerminalView`,切换不重建 -- 黑底浅灰前景(强制暗色,独立于 app 主题) -- `MediaQuery.viewInsets.bottom` 决定底部留白;`onResize` 推到 `SSHSession.resizeTerminal` - -### Shortcut bar - -- `resizeToAvoidBottomInset: true`,键盘弹起时整条上移到键盘上沿 -- 横向滚动 `ListView.scrollDirection: Axis.horizontal`,不换行 -- 按频率排序(首版顺序,后续可调): - ``` - Esc Tab Ctrl-C ↵ ↑ ↓ ← → Ctrl-D Ctrl-Z | ~ / - Ctrl-L Ctrl-A Ctrl-E PgUp PgDn - ``` -- 每键 minWidth 48,左右各 4 padding -- Ctrl 粘性键:按一下进入"Ctrl 待发"高亮态,下一个字母键发送 `Ctrl-X` 后自动复位;再按 Ctrl 取消 - -### 断线策略 - -- 断线 tab 不自动关闭,状态点灰,xterm 写入 `\r\n[Disconnected]\r\n` -- Toolbar `⚏` 菜单中断线项支持点击触发 reconnect;`SshTerminalController` 复用同一 xterm Terminal,保留 scrollback -- 第一版不主动发 SSH keep-alive - -## 服务器列表页 - -### 顶部活跃终端 banner - -`manager.sessions.isNotEmpty` 时显示:紫色堆叠小图标 + `N 个终端运行中`,整个 banner 点击 push `TerminalShellPage`。 - -### 列表 - -- ListTile → Card 卡片样式 -- 主标题:服务器名(无名时回退 host);左侧绿色小点表示该 host 已有 session -- 副标题:`username@host:port` -- chip 行:authType、group(仅非空时)、`expired`(红色) -- 行尾按钮: - - host 已有 session:`进入`,点击 `setActive` 到该 host 的 session 并 push 终端页 - - host 未连接:`连接`,点击取参数 + `openSession` + push 终端页 - - 不可连接(`!isConfig` 或 `expired`):禁用并显示 `未配置` / `已过期` - -### 分组 - -按 `group` 字段分组渲染(sticky header),未分组归"默认"组;分组之间组间距更明显。 - -### 顶部搜索框 - -实时过滤 name / host / username / tag / group。 - -### 其它 - -- 连接中:行内替换连接按钮为 `CircularProgressIndicator` -- 退出登录二次确认 `AlertDialog` -- 空 / 错误态使用统一组件 - -## 登录页 - -- 顶部 logo / 标题区 + 副标题 -- 密码字段加可见切换(suffix `IconButton`) -- 服务地址 / 用户名 / 密码 IME action 串联:next → next → done(submit) -- HTTP 警告改 inline banner,确认一次后记入状态,不再每次弹窗 -- 错误信息改为带图标的容器,不再裸文本 -- 字段间距统一为 12 - -## 跨页公共体验 - -### 主题 - -- `ThemeMode.system` -- `ColorScheme.fromSeed(seedColor: Colors.indigo)` 双套(light / dark) -- 暗色下文本 / 分隔线 / 卡片层级统一 - -### 401 / 403 自动登出 - -- `ApiClient` 把 401/403 的 `DioException` 抛 `UnauthorizedFailure extends ApiFailure` -- App 根注册 `onSessionExpired`:清 token + sessionCookie + deviceId(保留 serverAddress / username / savePassword / password 偏好),回登录页 -- 列表页与 SSH 凭据接口捕获 `UnauthorizedFailure` 后调用 `onSessionExpired` - -### 通用组件 - -`LoadingView`、`EmptyView`、`ErrorView`,居中布局,可选标题 + 副标题 + 行动按钮。 - -## Flutter Structure 调整 - -```text -mobile/lib/ - app.dart - main.dart - - core/ - api/... - crypto/... - storage/... - ui/ - loading_view.dart - empty_view.dart - error_view.dart - stacked_sessions_icon.dart - anchored_overlay_menu.dart - utils/... - - features/ - auth/... - servers/ - server_list_page.dart - server_card.dart - server_repository.dart - server_model.dart - terminal/ - terminal_session.dart - terminal_session_manager.dart - terminal_shell_page.dart - terminal_toolbar.dart - terminal_shortcut_bar.dart - ssh_connection_config.dart - ssh_terminal_controller.dart -``` - -`features/terminal/terminal_page.dart` 删除。 - -## Testing - -新增 / 修改: - -Dart 单测: - -- `TerminalSessionManager`:open / close / setActive / 状态流转 / 关闭最后一个 -- `SshTerminalController`:reconnect 时复用同一 Terminal、scrollback 累积(用 fake transport) -- `EasyNodeAppBootstrap`:登录态恢复(成功路径 / 401 路径 / 缺字段路径) - -Flutter widget 测: - -- `TerminalShellPage`:堆叠图标菜单展开 / `+` 菜单展开 / 关闭最后一个 session 自动 pop / 断线状态显示 -- `ServerListPage`:分组渲染 / 搜索过滤 / 已连接 host 显示绿点 / 顶部 banner 行为 -- `LoginPage`:密码可见切换 / 初始密码回填 / HTTP inline banner - -后端:未改后端,不新增 server 测试。 - -## Migration - -- 删除 `mobile/lib/features/terminal/terminal_page.dart` -- 路由从 `MaterialPageRoute(TerminalPage)` 切到 `TerminalShellPage` -- 从单 session push → 改为 manager.openSession + push shell 页 diff --git a/docs/superpowers/specs/2026-05-16-mobile-native-terminal-design.md b/docs/superpowers/specs/2026-05-16-mobile-native-terminal-design.md deleted file mode 100644 index d4c2962..0000000 --- a/docs/superpowers/specs/2026-05-16-mobile-native-terminal-design.md +++ /dev/null @@ -1,357 +0,0 @@ -# EasyNode Mobile Native Terminal Design - -Date: 2026-05-16 - -## Goal - -Build the first mobile EasyNode app with Flutter for Android and iOS. The first release focuses on login, server list, and native SSH terminal connection. Existing Web and server behavior must remain compatible. - -The app may learn from `C:\Users\chaos\Desktop\flutter_server_box` only at the level of general implementation ideas and dependency choices. That project is AGPL v3, so this implementation must not copy its source code, UI layout, component structure, assets, text, or visual design. - -## Scope - -Included: - -- Login to an existing EasyNode server. -- Persist server address and username by default. -- Save password only when the user explicitly enables it. -- Store password, token, session cookie, and the server-returned login `deviceId` in platform secure storage. -- The server-issued `deviceId` (returned by `/api/v1/login`) is preserved so the app can revoke its own session via the existing `DELETE /api/v1/revoke-login/:deviceId` endpoint later. -- Fetch server data from the existing `/api/v1/host-list` API. -- Show a mobile server list with a connect action. -- Request SSH connection parameters through one new mobile-only server API. -- Use native Flutter/Dart SSH for terminal connections. -- Support password authentication, private-key authentication, and credential-backed hosts that resolve to one of those two methods. -- Support Android and iOS from one Flutter codebase, with small platform configuration differences. - -Excluded from the first release: - -- Server add/edit/delete flows. -- SFTP. -- RDP. -- Jump hosts and proxy servers. -- Multi-tab terminals. -- Suspended terminal sessions. -- Script library, Docker, one-key commands, AI integrations, and other Web-only features. -- A separate mobile auth/session protocol. - -## Architecture - -The Flutter app uses one shared Dart codebase for Android and iOS. Platform-specific work is limited to network permissions, HTTP cleartext policy, ATS exceptions, and secure-storage plugin configuration. - -The app reuses existing EasyNode APIs where possible: - -- `GET /api/v1/get-pub-pem` -- `POST /api/v1/login` -- `GET /api/v1/host-list` -- optionally `DELETE /api/v1/revoke-login/:deviceId` - -Only one new server API is required: - -- `POST /api/v1/mobile/ssh-connection` - -The new endpoint exists because `/host-list` intentionally clears `password` and `privateKey`, while native SSH requires the app to receive decrypted connection parameters at connect time. - -## Login Flow - -The login page contains: - -- server address, for example `http://192.168.1.10:8082` -- username -- password -- optional MFA2 token -- save-password switch -- login expiry choice: temporary, current day, three days, seven days - -Server address and username are saved in ordinary app storage because other normal apps cannot read the app sandbox directly on Android or iOS. They are not treated as high-sensitivity secrets. - -Password is saved only when the user enables save-password. Saved passwords must use platform secure storage: - -- Android: Keystore-backed encrypted storage through `flutter_secure_storage` -- iOS: Keychain through `flutter_secure_storage` - -Token, session cookie, and the server-returned login `deviceId` also use secure storage. - -The first release does not implement public-key fingerprint binding or change-detection. The RSA public key returned from `/api/v1/get-pub-pem` is still required, because the login password and the per-request temporary AES key are both encrypted with it. - -On login: - -1. Validate and normalize the server address. -2. If the address uses HTTP, show a strong warning before any login request. -3. Fetch the server public key from `/api/v1/get-pub-pem`. -4. Encrypt the password with the server public key. -5. Call `/api/v1/login` with the existing Web-compatible payload (`loginName`, `ciphertext`, `jwtExpires`, `jwtExpireAt`, optional `mfa2Token`). -6. Store returned `token` and the response `deviceId`; the `session` cookie is written automatically by the server's `Set-Cookie` header. -7. Load the server list with `/api/v1/host-list`. - -The HTTP warning should be explicit: HTTP can expose the login token and session cookie, allowing an attacker to take over the app session. The encrypted SSH-parameter response does not replace HTTPS. The warning is shown only when the configured server address uses HTTP; it is not used for any other purpose. - -## Server List - -The server list uses the existing `/api/v1/host-list` response. The app consumes only the fields needed for a mobile list: - -- `id` -- `name` -- `host` -- `port` -- `username` -- `authType` -- `group` -- `tag` -- `expired` -- `isConfig` - -The initial UI is intentionally simple and distinct from the reference project: - -- title: server name -- subtitle: `username@host:port` -- small metadata chips or labels for auth type, group, and configured status -- primary action: connect - -The list supports pull-to-refresh. If the auth configuration is missing, the connect action is disabled or explains that the server has no SSH credentials configured. - -When a protected API returns 401 or 403, the app clears token and session and returns to the login page while preserving server address, username, and password-save preference. - -## SSH Credential API - -Endpoint: - -```http -POST /api/v1/mobile/ssh-connection -``` - -Request body: - -```json -{ - "hostId": "host id", - "encryptedKey": "RSA encrypted temporary key" -} -``` - -Rules: - -- The endpoint uses the existing Koa auth middleware. -- It requires the existing `token` header and `session` cookie. -- `hostId` must exist. -- The client generates a fresh 32-byte random key, base64-encodes it, then RSA-encrypts the base64 string with the server public key. The server RSA-decrypts to a utf8 string and base64-decodes that string back into the 32-byte key. This round-trip preserves binary key bytes through the existing RSA helper. -- The decrypted temporary key must be 32 bytes after decoding. -- The temporary key is used only for this response. -- On any failure, the response message must be a generic string; details only go to the server log. - -Server behavior: - -1. Verify the existing EasyNode login state. -2. Resolve the host record. -3. Resolve `authType=credential` into the underlying credential record. -4. Decrypt the stored password or private key using the existing EasyNode database encryption logic. -5. Build a minimal SSH connection payload. -6. Encrypt that payload with AES-256-GCM using the client temporary key. -7. Return only encrypted fields. - -Encrypted response shape: - -```json -{ - "status": 200, - "msg": "success", - "data": { - "alg": "AES-256-GCM", - "iv": "base64 iv", - "tag": "base64 auth tag", - "ciphertext": "base64 ciphertext" - } -} -``` - -Plaintext payload after mobile decryption: - -```json -{ - "hostId": "host id", - "name": "server name", - "host": "1.2.3.4", - "port": 22, - "username": "root", - "authType": "privateKey", - "password": "", - "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----...", - "passphrase": "" -} -``` - -For password authentication, `authType` is `password` and `password` is populated. For private-key authentication, `privateKey` is populated and `passphrase` may be populated. - -No response body outside the AES-GCM ciphertext may contain SSH passwords, private keys, or passphrases. - -## Encryption Design - -The first release uses a pragmatic encryption envelope for sensitive SSH parameters: - -- The app generates a fresh 32-byte random key for each SSH-parameter request. -- The app RSA-encrypts this key using the EasyNode public key from `/get-pub-pem`. -- The server decrypts the key with its private key. -- The server AES-256-GCM-encrypts the SSH parameters using that key. -- The app decrypts the response and immediately starts the SSH connection. -- The temporary key and SSH parameters stay in memory only. - -This protects the SSH credential response body from passive network capture. It does not fully protect HTTP users because the existing `token + session` login state can still be captured on HTTP. That is why the login flow must warn before HTTP use. - -The first release may use the current RSA mode for compatibility with the existing login flow. AES for the new response envelope should use Node's native `crypto` module rather than the existing CryptoJS passphrase mode. - -## Terminal - -The terminal page uses: - -- `dartssh2` for native SSH connections. -- the pub.dev `xterm` package for terminal rendering. The package is MIT licensed and may be used as a normal dependency. -- a project-owned `SshTerminalController` to bridge `dartssh2` shell streams to xterm input and output. - -The app must not copy terminal page code, local package code, UI layout, or component organization from the AGPL reference project. - -The first terminal page provides: - -- server name and connection status -- full-height terminal area -- minimal mobile toolbar with `Esc`, `Ctrl`, `Tab`, paste, disconnect, and navigation keys -- reconnect and return-to-list actions after disconnect - -Resize should be sent to the SSH shell when the terminal viewport changes. - -## Flutter Structure - -Proposed structure: - -```text -mobile/lib/ - main.dart - app.dart - - core/ - api/ - api_client.dart - api_result.dart - cookie_store.dart - crypto/ - rsa_crypto.dart - aes_gcm_crypto.dart - storage/ - app_storage.dart - secure_storage.dart - device_id.dart - ssh/ - ssh_connection_config.dart - ssh_terminal_controller.dart - utils/ - validators.dart - jwt_expiry.dart - - features/ - auth/ - login_page.dart - login_controller.dart - auth_session.dart - servers/ - server_list_page.dart - server_model.dart - server_repository.dart - terminal/ - terminal_page.dart - terminal_toolbar.dart -``` - -The app should avoid heavy generated architecture for the first release. A simple controller/store approach is enough. `ChangeNotifier` or another small state layer is preferred over a large framework until the app grows. - -Candidate dependencies: - -- `dio` -- `cookie_jar` -- `dio_cookie_manager` -- `flutter_secure_storage` -- `shared_preferences` -- `pointycastle` or another suitable crypto package -- `dartssh2` -- `xterm` - -Dependencies must be checked for permissive licenses before implementation. - -## Platform Policy - -Android: - -- Add `INTERNET` permission. -- Allow cleartext HTTP for user-provided self-hosted servers. -- Show in-app HTTP warning before login. - -iOS: - -- Add ATS exceptions required for user-provided HTTP servers. -- Keep in-app wording clear that HTTPS is recommended. -- For App Store review, explain that users connect to their own self-hosted EasyNode instance and HTTP is retained for LAN and legacy deployment compatibility. - -HTTPS with a valid certificate is the recommended path. Self-signed HTTPS can be supported later with certificate-fingerprint binding if needed, but the first implementation does not require a custom TLS trust manager. - -## Error Handling - -Login: - -- Invalid address: block locally. -- HTTP address: show strong warning before login. -- Public key fetch failure: show server/network error. -- Login failure: show the server message and keep address and username. -- 401/403: clear token/session and return to login. - -Server list: - -- Fetch failure: show retry. -- Empty list: show empty state. -- Missing SSH auth: disable connect or explain the reason. -- Credential-backed host: allow connect; the server resolves it. - -Terminal: - -- SSH-parameter API failure: show error and allow return. -- Decryption failure: stop before SSH and show error. -- SSH auth failure: show error and allow retry. -- Network disconnect: write disconnect status to the terminal and provide reconnect/return. -- App backgrounding: no explicit keepalive in first release. - -## Testing - -Dart unit tests: - -- login-expiry conversion -- server address normalization -- HTTP risk detection -- deviceId generation and persistence -- AES-GCM decrypt/encrypt helpers -- host-list JSON model mapping -- SSH credential encrypted response decoding - -Flutter widget tests: - -- login validation -- HTTP warning flow -- save-password switch behavior -- server list rendering -- disabled connect action for unconfigured hosts - -Node server tests: - -- missing token/session rejects `POST /api/v1/mobile/ssh-connection` -- missing or unknown `hostId` rejects -- unconfigured auth rejects -- password host returns encrypted response -- private-key host returns encrypted response -- credential-backed host returns encrypted response -- response body never includes raw password/private key/passphrase outside ciphertext - -Manual acceptance: - -- Android HTTP login shows warning. -- Android login succeeds against a local EasyNode server. -- Android server list loads from `/host-list`. -- Android password SSH connects. -- Android private-key SSH connects. -- Token expiration returns to login with address and username retained. -- iOS builds with compatible code and required network configuration. diff --git a/docs/superpowers/specs/2026-05-19-mobile-redesign-i18n-design.md b/docs/superpowers/specs/2026-05-19-mobile-redesign-i18n-design.md deleted file mode 100644 index 6ee7d36..0000000 --- a/docs/superpowers/specs/2026-05-19-mobile-redesign-i18n-design.md +++ /dev/null @@ -1,277 +0,0 @@ -# EasyNode Mobile Redesign and I18n Design - -Date: 2026-05-19 - -## Goal - -Redesign all existing Flutter mobile screens according to `DESIGN.md`, adapted for a compact operational server-management app rather than a marketing site. Add a lightweight two-language system for English and Simplified Chinese, with language switching available on the login page and settings page. - -The first implementation should land the light theme, while keeping the theme/token structure compatible with a later dark-mode pass. - -## Confirmed Scope - -Included: - -- Login page visual redesign. -- Main shell bottom navigation redesign. -- Servers tab redesign. -- Terminal shell page and terminal shortcut toolbar redesign. -- Settings tab redesign. -- SFTP and Scripts placeholder page redesign. -- English and Simplified Chinese app strings. -- Locale selection on Login and Settings. -- Locale persistence in local app storage. -- First-launch locale detection from the system locale. -- Tests for locale behavior and key redesigned UI surfaces. - -Excluded: - -- New server-side APIs. -- New SFTP, Scripts, or Settings features beyond the existing surfaces. -- Full dark-mode implementation and runtime dark-mode toggle. -- CRUD flows for servers. -- Pixel-perfect recreation of `DESIGN.md` marketing-page hero sections. - -## Design Direction - -Use the selected "Editorial ops app" direction: - -- Pure white app canvas for the primary light theme. -- Near-black ink for primary text. -- Cool gray for secondary text. -- Black primary actions with 8px radius. -- Compact 12px-radius cards with 1px hairline borders. -- Sparse sky-blue atmospheric wash only on the Login intro area. -- JetBrains Mono or platform monospace for terminal, SSH labels, and code-like connection strings. -- Terminal content remains an intentional dark working surface, independent of the app's light shell. -- No saturated purple/indigo seed-color look in user-facing mobile screens. - -This adapts `DESIGN.md` into a mobile operations UI: restrained, scannable, dense enough for repeated server work, and visually consistent without feeling like a landing page. - -## Theme Architecture - -The app should introduce a small theme layer instead of scattering inline colors across pages. - -Add a mobile design token module, for example: - -```text -mobile/lib/core/ui/ - app_theme.dart - app_tokens.dart -``` - -The theme should provide: - -- `ThemeData` light theme using Material 3. -- A dark-compatible token extension with semantic values for canvas, card, hairline, strong hairline, muted text, warning surface, success, and terminal surfaces. -- Button, input, card, app bar, navigation bar, chip, dialog, and snack bar defaults that match `DESIGN.md`. -- A future dark token set, even if `ThemeMode.system` remains and the first visual pass focuses on light mode. - -Pages should consume `Theme.of(context)`, `ColorScheme`, and token extension values. Avoid hard-coded black/white page backgrounds except for the terminal's fixed dark ANSI workspace and unavoidable text constants inside custom painters. - -## Typography - -Flutter should use the platform font stack by default unless bundled fonts are added later. Typography should match `DESIGN.md` proportions: - -- Page titles: 22-30px, weight 600. -- Component titles: 16-18px, weight 600. -- Body text: 14-16px, weight 400. -- Captions and metadata: 12-13px. -- Connection strings and terminal content: monospace. -- Letter spacing stays at zero in normal controls; only small uppercase labels may use modest positive tracking. - -## Page Designs - -### Login Page - -Remove the standard AppBar and use a full-page layout: - -- Top brand intro with `EasyNode`, language switch, headline, and short supporting copy. -- A single subtle sky-blue wash behind the intro area. -- Form area with server address, username, password, MFA code, session duration, and save-password control. -- Black primary login button. -- Inline HTTP warning notice. -- Inline error notice with icon and consistent padding. -- Language switch in the top-right of the intro area. - -The login page must remain usable on small screens with the keyboard open. Text fields keep at least 44px height. - -### Main Shell - -Keep the existing four tabs: - -- Servers -- SFTP -- Scripts -- Settings - -Update the bottom navigation to use: - -- White/surface background. -- Top hairline divider. -- Compact icons and labels. -- Black selected state. -- Muted gray unselected state. - -Keep the current `IndexedStack` behavior so tab state is preserved. - -### Servers Tab - -Keep the current provider and connection behavior. Redesign the UI: - -- Top title row with page title and icon actions. -- Search field shown as a compact bordered field. -- Active terminal banner styled as a light operational banner with stack icon, count, enter affordance, and close-all action. -- Grouped server sections with small uppercase group labels. -- Server cards with: - - server display name; - - connection string in monospace; - - status indicator when a host already has a session; - - auth/group/tag badges; - - black or bordered action button depending on state. -- Empty, error, and loading states use a shared visual language. - -The connection logic should not change. - -### Terminal Shell - -The terminal page keeps its existing structure and behavior: - -- 52px top toolbar. -- dark terminal viewport. -- bottom shortcut toolbar. -- session overlay menus. -- reconnect, close, and new-terminal actions. - -Visual changes: - -- Top toolbar becomes a light control surface with hairline border. -- Session title and status are compact and scannable. -- Stacked sessions icon should use theme-compatible colors or token constants that have light/dark counterparts. -- The terminal viewport stays dark with monospace text. -- Shortcut bar uses small bordered controls in light mode and token-driven surfaces for dark mode later. -- Shortcut labels should be localized where they are words, but terminal control labels such as `Esc`, `Tab`, `Ctrl`, `PgUp`, and `PgDn` remain conventional. - -### Settings Tab - -Settings should become the user's place to manage app-level preferences: - -- Account/server summary card. -- Language setting row with current language and picker/action sheet. -- Logout row with confirmation dialog. - -Settings must use the same shared strings system as Login. - -### SFTP and Scripts Placeholder Tabs - -Keep these as placeholders but make them consistent: - -- AppBar/title matching the shell. -- Empty-state component with icon, title, and description. -- Proper English and Chinese strings. -- Remove current mojibake/garbled copy. - -## I18n Architecture - -Use a lightweight local implementation rather than adding a large dependency. - -Suggested files: - -```text -mobile/lib/core/i18n/ - app_locale.dart - app_strings.dart - app_localizations.dart -``` - -Core behavior: - -- Support exactly two locales initially: - - English: `en` - - Simplified Chinese: `zh_Hans` -- If the user has not selected a language, resolve from the system locale. -- Any Chinese system locale resolves to Simplified Chinese for this phase. -- Other system locales resolve to English. -- Once the user changes language, persist it in `AppStorage`. -- Persisted user choice overrides future system-locale changes. -- Login and Settings both expose language switching. -- Switching language updates visible UI immediately. - -Integration shape: - -- Add `localeCode` to `AppStorage`. -- Bootstrap reads the saved locale before building `MaterialApp`. -- `_AppRoot` owns the current locale state and passes change callbacks to Login and Main Shell/Settings. -- `MaterialApp.locale` is set to the resolved locale. -- Widgets read copy via a small `context.strings` extension or `AppStrings.of(context)` helper. - -Do not localize values that are command syntax or terminal conventions. Do localize labels, hints, validation errors, notices, empty states, dialog titles, and button text. - -## State and Data Flow - -Startup: - -1. Read `SharedPreferences`. -2. Read saved server/user/save-password preferences. -3. Read saved locale code, if any. -4. Resolve effective locale from saved preference or platform locale. -5. Restore auth session as today. -6. Build `MaterialApp` with the effective locale and redesigned theme. - -Language switch: - -1. User opens switcher from Login or Settings. -2. App updates locale state. -3. App writes locale code to `AppStorage`. -4. `MaterialApp` rebuilds and visible strings update. - -Auth and terminal-session behavior remain unchanged. - -## Error Handling - -- If a saved locale is unknown, fall back to system resolution. -- If writing the locale preference fails, keep the in-memory language for the current session and surface no blocking error; the user can retry later. -- Existing auth expiration behavior remains unchanged. -- Login validation messages become localized. -- HTTP warning and login failure messages become localized when generated by the app. Server-returned messages may stay as returned. - -## Testing - -Add or update tests for: - -- `AppStorage` locale persistence. -- Locale resolution: - - no saved locale + Chinese system locale -> Chinese; - - no saved locale + English/other system locale -> English; - - saved locale overrides system locale. -- Login page renders English and Chinese labels. -- Login language switch updates visible copy. -- Settings language switch updates visible copy. -- SFTP/Scripts placeholders render non-garbled localized copy. -- Servers tab still renders existing cards and connect action. -- Terminal toolbar still emits expected escape sequences after visual changes. - -Run at minimum: - -```bash -flutter test -``` - -from the `mobile` directory. - -## Migration Notes - -- Existing stored users will default from system locale unless they choose a language. -- Existing saved server address, username, password preference, token, session cookie, and device ID storage remain unchanged. -- No server migration is needed. -- No database or API changes are needed. - -## Acceptance Criteria - -- All current mobile screens visually align with the confirmed Editorial ops app direction. -- Login and Settings can switch between English and Simplified Chinese. -- Language selection persists across app restarts. -- First launch follows system language when no user preference exists. -- Current SSH connection behavior, terminal sessions, and shortcut input behavior continue to work. -- SFTP and Scripts placeholder pages no longer contain garbled text. -- Theme code is structured so a later dark-mode pass can add complete dark colors without rewriting page layouts. diff --git a/docs/superpowers/specs/2026-05-23-mobile-native-proxy-jump-host-design.md b/docs/superpowers/specs/2026-05-23-mobile-native-proxy-jump-host-design.md deleted file mode 100644 index 746f45f..0000000 --- a/docs/superpowers/specs/2026-05-23-mobile-native-proxy-jump-host-design.md +++ /dev/null @@ -1,259 +0,0 @@ -# Mobile Native Proxy and Jump Host Support Design - -## Context - -The web terminal connects through `server/app/socket/terminal.js`. The server reads the target host, decrypts credentials, applies `proxyType`, and either opens a direct SSH connection, creates a proxy tunnel, or connects through jump hosts before handing the final socket to `ssh2`. - -The mobile terminal currently connects locally with `dartssh2`: - -```text -mobile -> target SSH -``` - -That preserves a native terminal experience, but it means server-side proxy and jump-host handling does not apply. Mobile must support the same connection topology locally: - -```text -mobile -> proxy/jump chain -> target SSH -``` - -## Goals - -- Keep mobile terminal connections local and native, including proxy and jump-host scenarios. -- Support the existing host fields: `proxyType`, `proxyServer`, and `jumpHosts`. -- Reuse the existing encrypted `/mobile/ssh-connection` response envelope for sensitive connection details. -- Preserve current direct SSH behavior for hosts without proxy or jump hosts. -- Provide clear errors for proxy, jump-host, and target-host failures. - -## Non-Goals - -- Do not route mobile terminal sessions through the server terminal websocket. -- Do not change web terminal behavior. -- Do not redesign server host, proxy, or credential storage. -- Do not implement mobile RDP proxying in this change. - -## Connection Modes - -### Direct - -When `proxyType` is empty, the mobile app connects as it does today: - -```text -SSHSocket.connect(target.host, target.port) -SSHClient(socket, target auth) -``` - -Unsupported mobile proxy modes should fail explicitly instead of silently falling back to direct connection. - -### SOCKS5 Proxy - -When `proxyType === 'proxyServer'` and the selected proxy has `type === 'socks5'`, the mobile app opens a TCP socket to the proxy, performs SOCKS5 negotiation, asks the proxy to connect to the target host and port, and passes the established tunnel to `dartssh2`. - -Supported SOCKS5 authentication: - -- No authentication -- Username/password authentication - -### HTTP Proxy - -HTTP CONNECT can be added after SOCKS5 and jump hosts. If the server returns an HTTP proxy before mobile support exists, mobile should return a clear unsupported error. - -### Jump Hosts - -When `proxyType === 'jumpHosts'`, the mobile app connects to each jump host in order. Each jump host opens a `direct-tcpip` style channel to the next hop. The final target `SSHClient` is created over the last forwarded channel. - -Single and multi-hop chains use the same algorithm: - -```text -connect jump1 -jump1 opens channel to jump2 or target -connect next SSHClient over that channel -repeat until target -connect final target SSHClient -``` - -All intermediate jump `SSHClient` instances must stay alive for the target session lifetime and must be closed when the terminal disconnects. - -## Server Payload Design - -`/mobile/ssh-connection` should continue to return an AES-GCM encrypted payload. The plaintext payload expands from target auth only to target auth plus connection topology. - -Direct example: - -```json -{ - "hostId": "target", - "name": "prod", - "host": "1.2.3.4", - "port": 22, - "username": "root", - "authType": "privateKey", - "password": "", - "privateKey": "...", - "passphrase": "", - "proxyType": "", - "proxy": null, - "jumpHosts": [] -} -``` - -SOCKS5 example: - -```json -{ - "hostId": "target", - "name": "prod", - "host": "1.2.3.4", - "port": 22, - "username": "root", - "authType": "password", - "password": "...", - "privateKey": "", - "passphrase": "", - "proxyType": "proxyServer", - "proxy": { - "id": "proxy1", - "name": "office socks", - "type": "socks5", - "host": "proxy.example.com", - "port": 1080, - "username": "", - "password": "" - }, - "jumpHosts": [] -} -``` - -Jump-host example: - -```json -{ - "hostId": "target", - "name": "prod", - "host": "10.0.0.20", - "port": 22, - "username": "root", - "authType": "privateKey", - "password": "", - "privateKey": "...", - "passphrase": "", - "proxyType": "jumpHosts", - "proxy": null, - "jumpHosts": [ - { - "hostId": "jump1", - "name": "jump-1", - "host": "203.0.113.10", - "port": 22, - "username": "root", - "authType": "password", - "password": "...", - "privateKey": "", - "passphrase": "" - } - ] -} -``` - -The server must resolve credentials for jump hosts the same way it resolves the target host. If a jump host uses `authType === 'credential'`, the payload should contain the resolved concrete auth type and decrypted secret. - -## Mobile Architecture - -Introduce a transport layer between `SshTerminalController` and `dartssh2`. - -```text -SshTerminalController - -> SshTransportFactory.open(config) - -> DirectSshTransport - -> Socks5SshTransport - -> JumpHostSshTransport - -> SSHClient(transport.socket, target auth) -``` - -### Models - -Extend `SshConnectionConfig` with: - -- `proxyType` -- `SshProxyConfig? proxy` -- `List jumpHosts` - -Add a shared auth shape for target and jump hosts: - -- `hostId` -- `name` -- `host` -- `port` -- `username` -- `authType` -- `password` -- `privateKey` -- `passphrase` -- `privateKeyPassphrase` - -`privateKeyPassphrase` should keep the existing behavior: empty or whitespace passphrases become `null`. - -### Transport Handle - -`SshTransportFactory.open` should return a handle containing: - -- The stream/socket used by the final target `SSHClient`. -- Any intermediate SSH clients that must remain alive. -- A `close()` method that shuts down intermediate clients and sockets in reverse order. - -This prevents `SshTerminalController` from knowing how a tunnel was built while still letting it clean up correctly. - -## Error Handling - -Errors should identify the failing layer: - -- `SOCKS5 proxy connection failed` -- `SOCKS5 authentication failed` -- `SOCKS5 target connection failed` -- `Jump host connection failed: ` -- `Jump host authentication failed: ` -- `Jump host forwarding failed: -> ` -- `Target SSH authentication failed` -- `Unsupported mobile proxy type: http` - -The terminal page can display the error in the existing terminal output style. - -## Security - -Mobile already receives decrypted target SSH credentials for local native SSH. This design expands that scope to proxy credentials and jump-host credentials only when the selected target host requires them. - -Mitigations: - -- Keep using the existing RSA temporary key plus AES-GCM encrypted response envelope. -- Do not persist decrypted proxy or jump-host credentials. -- Keep decrypted payload lifetime scoped to the connection attempt. -- Do not include proxy credentials or jump-host credentials in list APIs. -- Avoid logging decrypted secrets on server or mobile. - -## Testing - -### Server - -- `toMobileSshPayload` returns direct payload with empty proxy and jump-host fields. -- `toMobileSshPayload` returns SOCKS5 proxy details for `proxyType === 'proxyServer'`. -- `toMobileSshPayload` returns resolved jump-host auth details for `proxyType === 'jumpHosts'`. -- Missing proxy or jump host produces a clear error. -- Credential-based target and jump hosts resolve to concrete `password` or `privateKey` auth. - -### Mobile - -- `SshConnectionConfig.fromJson` parses direct, SOCKS5, and jump-host payloads. -- Empty passphrases are converted to `null` for target and jump-host private keys. -- `SshTransportFactory` selects direct, SOCKS5, or jump-host transport based on `proxyType`. -- SOCKS5 handshake supports no-auth and username/password modes. -- Jump-host transport keeps intermediate clients alive and closes them on disconnect. -- Unsupported proxy types fail explicitly. - -## Rollout - -1. Extend server mobile payload generation and tests. -2. Extend mobile config models and parser tests. -3. Add `SshTransportFactory` with direct transport only and migrate current controller to use it. -4. Add SOCKS5 transport and tests. -5. Add jump-host transport and tests. -6. Add user-facing error messages. -7. Add HTTP CONNECT proxy support later if needed. diff --git a/docs/superpowers/specs/2026-05-23-mobile-sftp-text-editor-design.md b/docs/superpowers/specs/2026-05-23-mobile-sftp-text-editor-design.md deleted file mode 100644 index 4367629..0000000 --- a/docs/superpowers/specs/2026-05-23-mobile-sftp-text-editor-design.md +++ /dev/null @@ -1,291 +0,0 @@ -# 移动端 SFTP 文本文件编辑器 — 设计稿 - -- 状态:已与用户对齐(2026-05-23) -- 范围:仅 `mobile/`(Flutter);后端无改动 -- 设计参考:`mobile/design/mobile.pen` 节点 `sYMaF`(代码编辑页) -- 不在范围:Web 端、`/sftp-v2` socket、`flutter test` 跑测试(按 CLAUDE.md 默认只跑 analyze) - -## 1. 目标 - -在移动端 SFTP 文件列表里,允许用户单击文本文件直接进入全屏编辑页:浏览(带语法高亮 + 行号 + 折叠)、编辑(undo/redo、按语言格式化)、保存回远端,并对大文件 / 二进制做拒绝保护。 - -## 2. 用户决策回顾 - -| 维度 | 选择 | -| --- | --- | -| 入口 | 单击文件直接进入编辑器 | -| 大文件保护 | ≤ 2 MB + 前 8 KB NUL 嗅探 | -| 「格式化」按钮 + 编码 | 保留格式化(JSON / YAML / XML),仅 UTF-8 | -| 页面承载 | `Navigator.push` 全屏路由 | - -## 3. 库选型 - -**采用 `re_editor` + `re_highlight`**(Reqable 团队,MIT): - -- 不基于 `TextField`,独立绘制;移动端大文本性能好(Reqable iOS/Android 同款) -- 自带 undo/redo、行号 (`indicatorBuilder`)、折叠 (`DefaultCodeChunkAnalyzer` 识别 `{}` `[]`)、find/replace 控制逻辑、近百种 highlight mode -- 软键盘、iOS 浮动光标、ime 输入有专门处理 - -替代方案: - -- `flutter_code_editor` (Akvelon):基于 TextField + highlight,移动端大文件易掉帧,2025 之后更新放缓。不选。 -- `code_text_field`:旧,功能少。 -- `code_forge`:依赖 dart:io,依赖 LSP/AI,过重。 - -## 4. 文件落地 - -### 4.1 新增依赖(`mobile/pubspec.yaml`) - -```yaml -re_editor: -re_highlight: -yaml: ^3.1.2 -xml: ^6.5.0 -``` - -`re_editor` 与 `re_highlight` 仍在 0.x,实施第一步先 `flutter pub add re_editor re_highlight yaml xml` 让 pub 决定 caret range,再把结果固化到 pubspec.yaml。 - -### 4.2 新增源文件 - -``` -mobile/lib/features/shell/editor/ - text_editor_page.dart # 全屏编辑页 widget - text_editor_controller.dart # ChangeNotifier:脏标记 / 保存 / 放弃 - editor_language.dart # 文件名 → (语言 id, highlight Mode) - editor_text_sniffer.dart # NUL 嗅探 + UTF-8 解码兜底 - editor_formatters.dart # JSON / YAML / XML formatter -``` - -### 4.3 修改的源文件 - -- `mobile/lib/features/shell/sftp_session_manager.dart` - - 新增 `readTextFile(remotePath, {maxBytes=2*1024*1024})` → 返回 `({Uint8List bytes, bool malformedUtf8})`,内部先 `sftp.stat` 拿大小(超限抛 `SftpFileTooLargeException`),再 `_readRemoteFile`,前 8 KB 嗅探 NUL(命中抛 `SftpBinaryFileException`)。 - - 新增 `writeTextFile(remotePath, content)` → `_writeRemoteFile(remotePath, utf8.encode(content))`。 - - 新增两个异常类型(同文件内 `class SftpFileTooLargeException`、`class SftpBinaryFileException` extends `Exception`),便于 UI 层精确分支。 - -- `mobile/lib/features/shell/sftp_tab.dart` - - `_SftpFileRow.onTap`:非选择态下,目录走 `manager.openPath`(已有行为),文件改为调用新增的 `_openInEditor(entry)`。 - - `_openInEditor`:调用 `manager.readTextFile`,捕获 size / binary / generic 三类异常,分别 toast。成功后 `Navigator.push(MaterialPageRoute(builder: (_) => TextEditorPage(...)))`。 - -- `mobile/lib/l10n/strings_en.dart` + `strings_zh.dart`:新增 editor.* key(见 §7)。 - -## 5. UI - -### 5.1 结构(对齐 sYMaF) - -``` -TextEditorPage (Scaffold) -├─ _EditorAppBar 返回 + 文件名 + 路径 + undo + redo -├─ _EditorMetaBar 语言徽章 + "UTF-8 · LF · 2.4 KB" -├─ Expanded -│ └─ CodeEditor re_editor 主体,dark theme,行号 + 折叠 -├─ _EditorStatusBar Ln/Col + 语言·Spaces + 总行/当前行 -└─ SafeArea bottom - └─ _EditorActionBar 未保存指示 + 格式化 + 保存 -``` - -### 5.2 配色 - -| 区域 | 颜色 | -| --- | --- | -| 编辑器背景 | `#0A0F14` | -| 行号 / 默认 | `#4B5563` | -| 当前行号 | `#9CA3AF` | -| 状态栏背景 | `#111827` | -| 状态栏 border-top | `#1F2937` | -| 状态栏字 | `#9CA3AF` | -| 其余(AppBar、Meta、Action、Dialog) | 复用 `_SftpPalette` | - -高亮主题:`re_highlight` 的 `atom-one-dark`,静态 const 引用。 - -### 5.3 交互 - -- AppBar undo / redo 按钮:直接代理 `CodeLineEditingController.undo()` / `redo()`,并按 `controller.canUndo` / `canRedo` 控制可点态。 -- 格式化按钮:按当前语言是否在 `editor_formatters.dart` 中支持(JSON / YAML / XML)来控制启用;点击 → 调用对应 formatter;失败 → 弹 toast。 -- 保存按钮:仅 `isDirty` 时主色高亮可点;保存中显示 loading(按钮内 spinner);成功 → toast `editor.saved`、`isDirty=false`;失败 → toast `editor.saveFailed`,保留 isDirty。 -- 返回(AppBar 返回 / 系统返回 / 手势返回):`PopScope` 拦截,`isDirty` 时弹 dialog(继续编辑 / 放弃 / 保存并退出),否则直接 pop。 -- 不实现:查找替换 / 编码切换 / 主题切换 / 字体大小 / 缩略图 / 自动换行开关 / 多文件 tab。 - -## 6. 数据流 - -``` -[SFTP 文件行点击] - │ - ▼ -manager.readTextFile(path) - │ ├─ sftp.stat → 大小 > 2 MB ─► SftpFileTooLargeException - │ ├─ _readRemoteFile - │ ├─ 前 8 KB 含 NUL ──────────► SftpBinaryFileException - │ └─ utf8.decode(allowMalformed:true) → malformedUtf8 标记 - ▼ -Navigator.push → TextEditorPage(path, bytes, malformedUtf8) - │ - ▼ -TextEditorController: - - originalText = decoded - - CodeLineEditingController.fromText(originalText) - - 监听 text → 计算 isDirty - │ - ▼ 用户编辑 … - ▼ -saveFile(): - - manager.writeTextFile(path, controller.text) (utf8.encode) - - 成功 → originalText = controller.text → isDirty=false → toast - - 失败 → toast,保持 isDirty -``` - -## 7. i18n key - -| key | zh | en | -| --- | --- | --- | -| `editor.tooLarge` | 文件超过 2 MB,请下载后再编辑 | File exceeds 2 MB. Download to edit. | -| `editor.binary` | 二进制文件不支持编辑 | Binary file is not editable. | -| `editor.readFailed` | 读取失败:{0} | Read failed: {0} | -| `editor.saveFailed` | 保存失败:{0} | Save failed: {0} | -| `editor.saved` | 已保存 | Saved | -| `editor.unsaved` | 未保存 | Unsaved | -| `editor.format` | 格式化 | Format | -| `editor.save` | 保存 | Save | -| `editor.discardTitle` | 放弃修改? | Discard changes? | -| `editor.discardBody` | 当前修改未保存,确定离开? | Unsaved edits will be lost. Leave? | -| `editor.discardKeepEditing` | 继续编辑 | Keep editing | -| `editor.discardLeave` | 放弃 | Discard | -| `editor.discardSaveAndLeave` | 保存并退出 | Save & leave | -| `editor.malformedUtf8` | 文件含非 UTF-8 字节,保存可能丢失部分字符 | File contains non-UTF-8 bytes; saving may lose some characters. | -| `editor.formatUnsupported` | 当前语言不支持格式化 | Format not supported for this language. | -| `editor.formatFailed` | 格式化失败:{0} | Format failed: {0} | -| `editor.statusEncoding` | UTF-8 · LF · {0} | UTF-8 · LF · {0} | -| `editor.statusPosition` | Ln {0}, Col {1} | Ln {0}, Col {1} | -| `editor.statusLineCount` | {0} / {1} | {0} / {1} | -| `editor.statusSpaces` | Spaces: {0} | Spaces: {0} | - -## 8. 错误处理矩阵 - -| 场景 | 行为 | -| --- | --- | -| 文件 > 2 MB | toast `editor.tooLarge`,不进入页面 | -| 二进制文件(前 8 KB 含 NUL) | toast `editor.binary`,不进入页面 | -| 读取失败(权限 / 网络) | toast `editor.readFailed: `,不进入页面 | -| 解码遇到无效字节 | `utf8.decode(allowMalformed:true)` 兜底进入;进入后 toast 一次 `editor.malformedUtf8` 警告 | -| 保存失败 | toast `editor.saveFailed: `,保留 isDirty | -| 格式化失败 | toast `editor.formatFailed: `(包含 JSON/YAML/XML parser 行列信息) | -| 当前语言不支持格式化 | toast `editor.formatUnsupported` | - -## 9. 模块职责 - -### 9.1 `editor_text_sniffer.dart` - -```dart -class TextSniffResult { - final bool isBinary; - final bool malformedUtf8; - final String text; // utf8.decode(allowMalformed:true) 结果 -} - -TextSniffResult sniffAndDecode(Uint8List bytes); -``` - -- 取前 `min(8192, bytes.length)` 个字节,遇到 0x00 → `isBinary=true`,跳过解码。 -- 非二进制 → `utf8.decode(bytes, allowMalformed:true)`;同时用 `utf8.decode(bytes)` 试探一次(不抛出捕获),失败则 `malformedUtf8=true`。 - -### 9.2 `editor_language.dart` - -```dart -class EditorLanguage { - final String id; // 用于 UI 显示,如 'YAML', 'JSON' - final Mode? highlightMode; // re_highlight Mode;plaintext 为 null - final bool formatSupported; - final int defaultIndent; // 2 -} - -EditorLanguage detectFromFileName(String name); -``` - -- 复用 `web/src/components/text-editor/index.vue` 的 ext → lang 映射;缺省 plaintext。 -- 仅 json/yaml/xml 的 `formatSupported = true`。 - -### 9.3 `editor_formatters.dart` - -```dart -String formatJson(String src); // throws FormatException -String formatYaml(String src); // throws FormatException -String formatXml(String src); // throws FormatException -``` - -- `formatJson`:`jsonDecode` → `JsonEncoder.withIndent(' ').convert(...)`。 -- `formatYaml`:`loadYaml` → 自写递归 dumper(YamlMap / YamlList / scalar / null / bool / num / string,2 空格缩进,字符串按需带引号)。注释会丢失,UI 上用 toast 警告。 -- `formatXml`:`XmlDocument.parse(src).toXmlString(pretty: true, indent: ' ')`。 - -### 9.4 `text_editor_controller.dart` - -```dart -class TextEditorController extends ChangeNotifier { - TextEditorController({ - required this.sessionManager, // SftpSessionManager - required this.remotePath, - required String originalText, - required this.language, - required this.totalBytes, - }); - - final CodeLineEditingController code; // re_editor controller - String _originalText; - bool _saving = false; - - bool get isDirty => code.text != _originalText; - bool get saving => _saving; - bool get canFormat => language.formatSupported; - - Future save(); // writeTextFile + 更新 originalText - Future saveAndLeave(NavigatorState nav); - void format(); // 按 language 选 formatter;写回 code.text - void dispose(); // dispose code -} -``` - -- `code` 监听文本变化时 `notifyListeners()`,驱动 footer 未保存指示器。 -- `save` 内置 `_saving` 互斥,防止双击。 - -### 9.5 `text_editor_page.dart` - -- `StatefulWidget`,`initState` 创建 `TextEditorController`,`dispose` 释放。 -- 用 `PopScope(canPop: !isDirty, onPopInvoked: ...)` 拦截返回。 - -## 10. 风险与缓解 - -| 风险 | 缓解 | -| --- | --- | -| Android 第三方 IME(搜狗 / 百度)联想抖动 | `re_editor` 已处理大多数 IME;遇到 QA 反馈可临时打开 wordWrap 减少水平滚动冲突 | -| 用户误点击大文件等待几秒才被拒 | 先 `sftp.stat` 拿大小,未读取数据就拦截;NUL 嗅探在内存里很快 | -| YAML 格式化丢注释 | toast 警告;本期不引入 `yaml_writer` / 自研 round-trip | -| 保存中网络中断 | toast 报错,保留 isDirty,用户可手动重试 | -| 文件不是 UTF-8(如 GB18030) | `allowMalformed:true` 不让进入页面崩溃;toast 告知用户保存可能损坏 | - -## 11. YAGNI 列表(本期不做) - -- 查找 / 替换 UI(re_editor 已有逻辑,UI 待后续 PR) -- 编码切换、行尾切换、主题切换、字体大小、缩略图、word wrap toggle -- 多文件 tab、最近编辑历史、本地草稿恢复 -- 长按菜单的「编辑」入口(如有需求再加,到时和单击同走 `_openInEditor`) -- 后端 socket 协作编辑 - -## 12. 测试 - -按 CLAUDE.md,默认只跑 `flutter analyze` + format;下列单测仅在用户明确要求时执行: - -- `mobile/test/features/shell/editor/editor_text_sniffer_test.dart` -- `mobile/test/features/shell/editor/editor_language_test.dart` -- `mobile/test/features/shell/editor/editor_formatters_test.dart` -- `mobile/test/features/shell/editor/text_editor_controller_test.dart`(用 fake `SftpSessionManager`) - -## 13. 实施步骤索引(实际拆分见 plan) - -1. 加依赖 + 创建 `editor/` 目录骨架。 -2. `editor_text_sniffer.dart` + `editor_language.dart` + 单测。 -3. `editor_formatters.dart` + 单测。 -4. `SftpSessionManager` 新增 `readTextFile` / `writeTextFile` + 异常类。 -5. `TextEditorController` + 单测。 -6. `TextEditorPage` UI 拼装(AppBar / Meta / Editor / StatusBar / ActionBar)。 -7. i18n 补 key。 -8. `sftp_tab.dart` 接入单击入口,三类异常 toast。 -9. `flutter analyze` + 手动 / 模拟器自查。