完成了终端应用

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
SSH-based visual Linux administration tool
Waiting for commit

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:dartssh2/dartssh2.dart';
import '../main.dart';
import '../models/credentials.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@ -27,28 +28,41 @@ class _LoginPageState extends State<LoginPage> {
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(
context,
MaterialPageRoute(
builder: (context) =>
WindowManager(sshClient: client, host: _hostController.text)),
builder: (context) => WindowManager(
sshClient: client,
credentials: credentials,
),
),
);
} catch (e) {
// ignore: use_build_context_synchronously
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login Failed: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xff0078D4),
body: Container(
decoration: const BoxDecoration(
image: DecorationImage(
@ -58,10 +72,10 @@ class _LoginPageState extends State<LoginPage> {
),
child: Center(
child: Container(
padding: const EdgeInsets.all(20.0),
padding: const EdgeInsets.fromLTRB(40, 20, 40, 20),
width: 300,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
color: Colors.black.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
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 Size initialSize;
final String title;
final Widget child;
final Widget Function(bool isActive, VoidCallback onSessionEnd) child;
final IconData icon;
final bool isActive;
final bool isMaximized;
@ -152,7 +152,7 @@ class _DraggableWindowState extends State<DraggableWindow> {
_top = 0;
_left = 0;
_width = screenSize.width;
_height = screenSize.height - 40;
_height = screenSize.height - 48;
_isMaximized = true;
}
});
@ -227,7 +227,11 @@ class _DraggableWindowState extends State<DraggableWindow> {
child: Column(
children: [
_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
cupertino_icons: ^1.0.8
dartssh2: ^2.13.0
xterm: ^4.0.0
dev_dependencies:
flutter_test: