完成了终端应用

This commit is contained in:
梦凌汐 2025-07-09 15:13:42 +08:00
parent b561a7f612
commit 33d30aacc8
10 changed files with 255 additions and 50 deletions

View File

@ -1,4 +1,5 @@
# vissh # vissh
SSH-based visual Linux administration tool SSH-based visual Linux administration tool
Waiting for commit Waiting for commit

View File

@ -10,6 +10,7 @@
analyzer: analyzer:
errors: errors:
use_build_context_synchronously: ignore use_build_context_synchronously: ignore
library_private_types_in_public_api: ignore
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:

View File

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="vissh" android:label="vissh"
android:name="${applicationName}" android:name="${applicationName}"
@ -11,6 +13,7 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user

View File

@ -1,9 +1,12 @@
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'widgets/draggable_window.dart'; import 'package:vissh/pages/terminal_page.dart';
import 'models/window_data.dart'; import 'package:vissh/widgets/draggable_window.dart';
import 'widgets/taskbar.dart'; import 'package:vissh/models/window_data.dart';
import 'pages/login_page.dart'; import 'package:vissh/widgets/taskbar.dart';
import 'package:vissh/pages/login_page.dart';
import 'package:vissh/models/credentials.dart';
import 'package:flutter/services.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
@ -23,8 +26,13 @@ class MyApp extends StatelessWidget {
class WindowManager extends StatefulWidget { class WindowManager extends StatefulWidget {
final SSHClient sshClient; final SSHClient sshClient;
final String host; final SSHCredentials credentials;
const WindowManager({super.key, required this.sshClient, required this.host});
const WindowManager({
super.key,
required this.sshClient,
required this.credentials,
});
@override @override
State<WindowManager> createState() => _WindowManagerState(); State<WindowManager> createState() => _WindowManagerState();
@ -40,6 +48,7 @@ class _WindowManagerState extends State<WindowManager> {
@override @override
void initState() { void initState() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.bottom]);
super.initState(); super.initState();
_verifyConnection(); _verifyConnection();
} }
@ -65,7 +74,7 @@ class _WindowManagerState extends State<WindowManager> {
_addWindow( _addWindow(
'File Explorer', 'File Explorer',
const Offset(100, 100), const Offset(100, 100),
const Center( (isActive, onSessionEnd) => const Center(
child: Text('View your files...', style: TextStyle(color: Colors.white)), child: Text('View your files...', style: TextStyle(color: Colors.white)),
), ),
Icons.folder_open, Icons.folder_open,
@ -73,15 +82,17 @@ class _WindowManagerState extends State<WindowManager> {
_addWindow( _addWindow(
'Terminal', 'Terminal',
const Offset(150, 150), const Offset(150, 150),
Center( (isActive, onSessionEnd) => TerminalPage(
child: Text('SSH connected to: ${widget.host}', credentials: widget.credentials,
style: const TextStyle(color: Colors.white)), isActive: isActive,
onSessionEnd: onSessionEnd,
), ),
Icons.web, Icons.terminal,
); );
} }
void _addWindow(String title, Offset position, Widget child, IconData icon) { void _addWindow(
String title, Offset position, Widget Function(bool, VoidCallback) child, IconData icon) {
setState(() { setState(() {
final id = 'window_${_nextWindowId++}'; final id = 'window_${_nextWindowId++}';
_windows.add( _windows.add(
@ -89,7 +100,7 @@ class _WindowManagerState extends State<WindowManager> {
id: id, id: id,
title: title, title: title,
position: position, position: position,
size: const Size(400, 300), size: const Size(700, 500),
child: child, child: child,
icon: icon, icon: icon,
), ),
@ -105,7 +116,6 @@ class _WindowManagerState extends State<WindowManager> {
if (window.isMinimized) { if (window.isMinimized) {
window.isMinimized = false; window.isMinimized = false;
} }
if (windowIndex != _windows.length - 1) { if (windowIndex != _windows.length - 1) {
final window = _windows.removeAt(windowIndex); final window = _windows.removeAt(windowIndex);
_windows.add(window); _windows.add(window);
@ -180,13 +190,11 @@ class _WindowManagerState extends State<WindowManager> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_isVerified) { if (!_isVerified) {
// --- Win11 ---
bool hasError = _verificationFailedMessage.isNotEmpty; bool hasError = _verificationFailedMessage.isNotEmpty;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xff0078D4), // Win11 backgroundColor: const Color(0xff0078D4),
body: Container( body: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: NetworkImage('https://www.meowdream.cn/background.jpg'), image: NetworkImage('https://www.meowdream.cn/background.jpg'),
fit: BoxFit.cover, fit: BoxFit.cover,
@ -197,9 +205,8 @@ class _WindowManagerState extends State<WindowManager> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 120), const SizedBox(height: 120),
hasError hasError
? Icon(Icons.error_outline, color: Colors.redAccent, size: 48) ? const Icon(Icons.error_outline, color: Colors.redAccent, size: 48)
: SizedBox( : SizedBox(
width: 48, width: 48,
height: 48, height: 48,
@ -209,7 +216,6 @@ class _WindowManagerState extends State<WindowManager> {
), ),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
Text( Text(
_verificationMessage, _verificationMessage,
style: const TextStyle( style: const TextStyle(
@ -219,7 +225,6 @@ class _WindowManagerState extends State<WindowManager> {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
if (hasError) if (hasError)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0), padding: const EdgeInsets.symmetric(horizontal: 40.0),
@ -232,10 +237,7 @@ class _WindowManagerState extends State<WindowManager> {
), ),
), ),
), ),
if (hasError) const SizedBox(height: 16),
if (hasError)
SizedBox(height: 16),
if (hasError) if (hasError)
TextButton( TextButton(
onPressed: () { onPressed: () {
@ -249,7 +251,7 @@ class _WindowManagerState extends State<WindowManager> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
), ),
child: const Text( child: const Text(
'返回', 'Back',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 16, fontSize: 16,
@ -271,8 +273,7 @@ class _WindowManagerState extends State<WindowManager> {
}); });
final topMostIndex = _windows.lastIndexWhere((w) => !w.isMinimized); final topMostIndex = _windows.lastIndexWhere((w) => !w.isMinimized);
final activeWindowId = final activeWindowId = topMostIndex != -1 ? _windows[topMostIndex].id : null;
topMostIndex != -1 ? _windows[topMostIndex].id : null;
return Scaffold( return Scaffold(
backgroundColor: Colors.blueGrey[900], backgroundColor: Colors.blueGrey[900],
@ -322,20 +323,21 @@ class _WindowManagerState extends State<WindowManager> {
children: [ children: [
FloatingActionButton( FloatingActionButton(
onPressed: () => _addWindow( onPressed: () => _addWindow(
'New Window', 'Terminal',
const Offset(200, 200), const Offset(150, 150),
const Center( (isActive, onSessionEnd) => TerminalPage(
child: Text('This is the new window content area', credentials: widget.credentials,
style: TextStyle(color: Colors.white)) isActive: isActive,
onSessionEnd: onSessionEnd,
), ),
Icons.add_circle_outline, Icons.terminal,
), ),
tooltip: 'Add New Window', tooltip: 'Add New Window',
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
] ],
) ),
); );
} }
} }

View File

@ -0,0 +1,11 @@
class SSHCredentials {
final String host;
final String username;
final String password;
SSHCredentials({
required this.host,
required this.username,
required this.password,
});
}

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
class WindowData { class WindowData {
final String id; final String id;
final String title; final String title;
final Widget child; final Widget Function(bool isActive, VoidCallback onSessionEnd) child;
final IconData icon; final IconData icon;
Offset position; Offset position;
Size size; Size size;

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import '../main.dart'; import '../main.dart';
import '../models/credentials.dart';
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@ -27,28 +28,41 @@ class _LoginPageState extends State<LoginPage> {
onPasswordRequest: () => _passwordController.text, onPasswordRequest: () => _passwordController.text,
); );
// ignore: use_build_context_synchronously final credentials = SSHCredentials(
host: _hostController.text,
username: _usernameController.text,
password: _passwordController.text,
);
if (!mounted) return;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) => WindowManager(
WindowManager(sshClient: client, host: _hostController.text)), sshClient: client,
credentials: credentials,
),
),
); );
} catch (e) { } catch (e) {
// ignore: use_build_context_synchronously if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login Failed: $e')), SnackBar(content: Text('Login Failed: $e')),
); );
} finally { } finally {
setState(() { if (mounted) {
_isLoading = false; setState(() {
}); _isLoading = false;
});
}
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xff0078D4),
body: Container( body: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
image: DecorationImage( image: DecorationImage(
@ -58,10 +72,10 @@ class _LoginPageState extends State<LoginPage> {
), ),
child: Center( child: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.fromLTRB(40, 20, 40, 20),
width: 300, width: 300,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Column( child: Column(

View File

@ -0,0 +1,168 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xterm/xterm.dart';
import '../models/credentials.dart';
class TerminalPage extends StatefulWidget {
final SSHCredentials credentials;
final bool isActive;
final VoidCallback onSessionEnd;
const TerminalPage({
super.key,
required this.credentials,
required this.isActive,
required this.onSessionEnd,
});
@override
_TerminalPageState createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage> {
final Terminal terminal = Terminal(maxLines: 10000);
final FocusNode _focusNode = FocusNode();
SSHClient? client;
SSHSession? shell;
@override
void initState() {
super.initState();
_startSshShell();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isActive) {
_focusNode.requestFocus();
}
});
}
@override
void didUpdateWidget(covariant TerminalPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
_focusNode.requestFocus();
} else if (!widget.isActive && oldWidget.isActive) {
_focusNode.unfocus();
}
}
Future<void> _startSshShell() async {
try {
client = SSHClient(
await SSHSocket.connect(widget.credentials.host, 22),
username: widget.credentials.username,
onPasswordRequest: () => widget.credentials.password,
);
shell = await client!.shell(
pty: SSHPtyConfig(
width: terminal.viewWidth,
height: terminal.viewHeight,
),
);
terminal.onOutput = (data) {
shell?.write(utf8.encode(data));
};
terminal.onResize = (width, height, pixelWidth, pixelHeight) {
shell?.resizeTerminal(width, height);
};
shell?.stdout.listen((data) {
terminal.write(utf8.decode(data));
});
shell?.stderr.listen((data) {
terminal.write(utf8.decode(data));
});
await shell?.done;
if (mounted) {
widget.onSessionEnd();
}
} catch (e) {
if (mounted) {
terminal.write('Error: $e');
widget.onSessionEnd();
}
}
}
TerminalKey? _mapLogicalKey(LogicalKeyboardKey key) {
final keyMap = {
LogicalKeyboardKey.enter: TerminalKey.enter,
LogicalKeyboardKey.backspace: TerminalKey.backspace,
LogicalKeyboardKey.arrowUp: TerminalKey.arrowUp,
LogicalKeyboardKey.arrowDown: TerminalKey.arrowDown,
LogicalKeyboardKey.arrowLeft: TerminalKey.arrowLeft,
LogicalKeyboardKey.arrowRight: TerminalKey.arrowRight,
LogicalKeyboardKey.tab: TerminalKey.tab,
LogicalKeyboardKey.escape: TerminalKey.escape,
LogicalKeyboardKey.delete: TerminalKey.delete,
LogicalKeyboardKey.home: TerminalKey.home,
LogicalKeyboardKey.end: TerminalKey.end,
LogicalKeyboardKey.pageUp: TerminalKey.pageUp,
LogicalKeyboardKey.pageDown: TerminalKey.pageDown,
LogicalKeyboardKey.insert: TerminalKey.insert,
LogicalKeyboardKey.f1: TerminalKey.f1,
LogicalKeyboardKey.f2: TerminalKey.f2,
LogicalKeyboardKey.f3: TerminalKey.f3,
LogicalKeyboardKey.f4: TerminalKey.f4,
LogicalKeyboardKey.f5: TerminalKey.f5,
LogicalKeyboardKey.f6: TerminalKey.f6,
LogicalKeyboardKey.f7: TerminalKey.f7,
LogicalKeyboardKey.f8: TerminalKey.f8,
LogicalKeyboardKey.f9: TerminalKey.f9,
LogicalKeyboardKey.f10: TerminalKey.f10,
LogicalKeyboardKey.f11: TerminalKey.f11,
LogicalKeyboardKey.f12: TerminalKey.f12,
};
return keyMap[key];
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
final terminalKey = _mapLogicalKey(event.logicalKey);
if (terminalKey != null) {
terminal.keyInput(
terminalKey,
ctrl: HardwareKeyboard.instance.isControlPressed,
shift: HardwareKeyboard.instance.isShiftPressed,
alt: HardwareKeyboard.instance.isAltPressed,
);
return KeyEventResult.handled;
} else if (event.character != null && event.character!.isNotEmpty) {
terminal.textInput(event.character!);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: TerminalView(
terminal,
autofocus: false,
),
);
}
@override
void dispose() {
_focusNode.dispose();
shell?.close();
client?.close();
super.dispose();
}
}

View File

@ -11,7 +11,7 @@ class DraggableWindow extends StatefulWidget {
final Offset initialPosition; final Offset initialPosition;
final Size initialSize; final Size initialSize;
final String title; final String title;
final Widget child; final Widget Function(bool isActive, VoidCallback onSessionEnd) child;
final IconData icon; final IconData icon;
final bool isActive; final bool isActive;
final bool isMaximized; final bool isMaximized;
@ -152,7 +152,7 @@ class _DraggableWindowState extends State<DraggableWindow> {
_top = 0; _top = 0;
_left = 0; _left = 0;
_width = screenSize.width; _width = screenSize.width;
_height = screenSize.height - 40; _height = screenSize.height - 48;
_isMaximized = true; _isMaximized = true;
} }
}); });
@ -227,7 +227,11 @@ class _DraggableWindowState extends State<DraggableWindow> {
child: Column( child: Column(
children: [ children: [
_buildTitleBar(), _buildTitleBar(),
Expanded(child: widget.child), Expanded(
child: ClipRect(
child: widget.child(widget.isActive, () => widget.onClose(widget.id)),
)
),
], ],
), ),
), ),

View File

@ -12,6 +12,7 @@ dependencies:
sdk: flutter sdk: flutter
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
dartssh2: ^2.13.0 dartssh2: ^2.13.0
xterm: ^4.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: