From 86d625cda1530004e9fbe56279798cde0e6152a2 Mon Sep 17 00:00:00 2001 From: hackeris Date: Wed, 22 Apr 2026 01:09:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ets/components/EditEmulatorContent.ets | 17 ++ .../main/ets/components/EmulatorListGrid.ets | 3 +- .../components/EmulatorManagementContent.ets | 3 +- entry/src/main/ets/components/WebTerminal.ets | 105 +++++++- .../main/ets/entryability/EntryAbility.ets | 14 ++ entry/src/main/ets/lib/SessionManager.ets | 228 ++++++++++++++++++ entry/src/main/ets/lib/UnixSocketSession.ets | 164 +++++++++++++ entry/src/main/ets/lib/startVm.ets | 18 +- entry/src/main/ets/model/Emulator.ets | 5 +- entry/src/main/ets/model/appOption.ets | 4 + entry/src/main/ets/pages/EditEmulator.ets | 6 +- entry/src/main/ets/pages/Index.ets | 168 ++++++++++++- .../main/resources/base/element/string.json | 16 ++ .../main/resources/zh_CN/element/string.json | 16 ++ 14 files changed, 745 insertions(+), 22 deletions(-) create mode 100644 entry/src/main/ets/lib/SessionManager.ets create mode 100644 entry/src/main/ets/lib/UnixSocketSession.ets diff --git a/entry/src/main/ets/components/EditEmulatorContent.ets b/entry/src/main/ets/components/EditEmulatorContent.ets index 73393ea..9316070 100644 --- a/entry/src/main/ets/components/EditEmulatorContent.ets +++ b/entry/src/main/ets/components/EditEmulatorContent.ets @@ -89,6 +89,16 @@ export default struct EditEmulatorContent { .onChange((value) => this.emulator.rootVda = value.trim()) } + @Builder + private maxSessionsSetting() { + Select([{ value: '1' }, { value: '2' }, { value: '3' }, { value: '4' }] as SelectOption[]) + .width(100) + .value((this.emulator.maxSessions ?? 1).toString()) + .onSelect((_i, value) => { + this.emulator.maxSessions = parseInt(value) + }) + } + @Builder buildEmulatorEdit() { Column() { @@ -189,6 +199,13 @@ export default struct EditEmulatorContent { this.vdaSetting() } }) + SettingItem({ + title: $r('app.string.label_max_sessions'), + subTitle: $r('app.string.setting_max_sessions_desc'), + content: () => { + this.maxSessionsSetting() + } + }) } .attributeModifier(this.blockStyle) } diff --git a/entry/src/main/ets/components/EmulatorListGrid.ets b/entry/src/main/ets/components/EmulatorListGrid.ets index 6b165ff..59361a1 100644 --- a/entry/src/main/ets/components/EmulatorListGrid.ets +++ b/entry/src/main/ets/components/EmulatorListGrid.ets @@ -348,7 +348,8 @@ export default struct EmulatorListGrid { portMapping: src.portMapping, rootVda: src.rootVda, rootFilesystem: target, - sharedFolderReadonly: src.sharedFolderReadonly + sharedFolderReadonly: src.sharedFolderReadonly, + maxSessions: src.maxSessions } this.emulators.push(emulator) diff --git a/entry/src/main/ets/components/EmulatorManagementContent.ets b/entry/src/main/ets/components/EmulatorManagementContent.ets index 3aa171d..394be96 100644 --- a/entry/src/main/ets/components/EmulatorManagementContent.ets +++ b/entry/src/main/ets/components/EmulatorManagementContent.ets @@ -69,7 +69,8 @@ export default struct EmulatorManagementContent { portMapping: item.portMapping, rootVda: item.rootVda, rootFilesystem: item.rootFilesystem, - sharedFolderReadonly: item.sharedFolderReadonly + sharedFolderReadonly: item.sharedFolderReadonly, + maxSessions: item.maxSessions }; const controller = new CustomDialogController({ builder: CustomContentDialog({ diff --git a/entry/src/main/ets/components/WebTerminal.ets b/entry/src/main/ets/components/WebTerminal.ets index e425bf4..e857f35 100644 --- a/entry/src/main/ets/components/WebTerminal.ets +++ b/entry/src/main/ets/components/WebTerminal.ets @@ -1,5 +1,4 @@ import util from '@ohos.util'; -import { buffer } from '@kit.ArkTS'; import { hilog } from '@kit.PerformanceAnalysisKit'; import napi from 'libentry.so'; import { webview } from '@kit.ArkWeb'; @@ -8,6 +7,7 @@ import appOption, { CursorShape, savePreference } from '../model/appOption'; import { common } from '@kit.AbilityKit'; import { SymbolGlyphModifier } from '@kit.ArkUI'; import stringToArrayBuffer from '../lib/stringToArrayBuffer'; +import sessionManager from '../lib/SessionManager'; import { applyVirtualKeyModifiers, FunctionKeySequences, @@ -28,6 +28,10 @@ const VKeyRadius = VKeyMetrics.radius; @Component export default struct WebTerminal { @Require virtKeyEnabled: boolean = true + /** Session index: 0 = primary (native serial), 1+ = extra (ArkTS unix socket) */ + @Prop sessionIndex: number = 0 + /** Whether this session is currently active (visible and receiving input) */ + @Prop @Watch('onActiveChanged') isActive: boolean = true @State ctrlPressed: boolean = false @State shiftPressed: boolean = false @State fnPressed: boolean = false @@ -346,7 +350,7 @@ export default struct WebTerminal { // ============== for javascript in ArkWeb ================ sendInput(data: string): void { - hilog.info(DOMAIN, 'WebTerminal', 'sendInput, data: %{public}s', data); + hilog.info(DOMAIN, 'WebTerminal', 'sendInput session=%{public}d, data: %{public}s', this.sessionIndex, data); const modifierResult = applyVirtualKeyModifiers(data, this.ctrlPressed, this.shiftPressed) data = modifierResult.data @@ -361,20 +365,69 @@ export default struct WebTerminal { hilog.info(DOMAIN, 'WebTerminal', 'sendInput with Shift, converted to: %{public}s', data); } - let buffer = stringToArrayBuffer(data, 'utf-8'); - napi.sendInput(buffer) + let buf = stringToArrayBuffer(data, 'utf-8'); + + if (this.sessionIndex === 0) { + // Primary session: use native napi.sendInput + napi.sendInput(buf) + } else { + // Non-primary session: use SessionManager (ArkTS unix socket) + sessionManager.sendInput(this.sessionIndex, buf) + } // Ctrl: 一次性模式,发送后自动重置 | Shift: 状态保持,需要用户手动点击取消 } async load(): Promise { - this.webviewController.runJavaScript('exports.setFocused(true)') - napi.onData((d: ArrayBuffer): void => this.onData(d)) - napi.onShutdown((): void => { - void this.onShutdown() - }) + hilog.info(DOMAIN, 'WebTerminal', 'load() called for session %{public}d, isActive=%{public}s', + this.sessionIndex, this.isActive) + + if (this.isActive) { + this.webviewController.runJavaScript('exports.setFocused(true)') + } + + if (this.sessionIndex === 0) { + // Primary session: use native napi callbacks + napi.onData((d: ArrayBuffer): void => this.onData(d)) + napi.onShutdown((): void => { + void this.onShutdown() + }) + } else { + // Non-primary session: use SessionManager (ArkTS LocalSocket) + const session = sessionManager.getSession(this.sessionIndex) + if (session && session.connected) { + sessionManager.setOutputCallback(this.sessionIndex, (d: ArrayBuffer): void => this.onData(d)) + // Show connected status + const statusMsg = `\r\n[Session ${this.sessionIndex + 1} connected to ${session.socketPath}]\r\n` + const statusText = util.TextEncoder.create('utf-8').encodeInto(statusMsg) + const statusBase64 = new util.Base64Helper().encodeToStringSync(statusText) + this.webviewController.runJavaScript(`exports.writeBase64("${statusBase64}", ${this.applicationMode})`) + } else { + // Session not connected yet — show waiting message and try to connect + const waitMsg = `\r\n[Session ${this.sessionIndex + 1}: Waiting for connection...]\r\n` + const waitText = util.TextEncoder.create('utf-8').encodeInto(waitMsg) + const waitBase64 = new util.Base64Helper().encodeToStringSync(waitText) + this.webviewController.runJavaScript(`exports.writeBase64("${waitBase64}", ${this.applicationMode})`) + + // Attempt to open the session + const opened = await sessionManager.openSession(this.sessionIndex) + if (opened) { + sessionManager.setOutputCallback(this.sessionIndex, (d: ArrayBuffer): void => this.onData(d)) + const okMsg = `\r\n[Session ${this.sessionIndex + 1} connected!]\r\n` + const okText = util.TextEncoder.create('utf-8').encodeInto(okMsg) + const okBase64 = new util.Base64Helper().encodeToStringSync(okText) + this.webviewController.runJavaScript(`exports.writeBase64("${okBase64}", ${this.applicationMode})`) + } else { + const failMsg = `\r\n[Session ${this.sessionIndex + 1}: Connection failed]\r\n` + const failText = util.TextEncoder.create('utf-8').encodeInto(failMsg) + const failBase64 = new util.Base64Helper().encodeToStringSync(failText) + this.webviewController.runJavaScript(`exports.writeBase64("${failBase64}", ${this.applicationMode})`) + } + } + } + const vmStatus = AppStorage.get(appOption.currentEmulatorStatus) as string | undefined - if (vmStatus === 'ABNORMAL') { + if (vmStatus === 'ABNORMAL' && this.sessionIndex === 0) { const message = this.getUIContext().getHostContext()?.resourceManager.getStringByNameSync('pm_status_qmp_connect_failed') ?? 'Failed to connect to QMP (please restart app)' const text = util.TextEncoder.create('utf-8').encodeInto('\r\n' + message + '\r\n') @@ -383,6 +436,38 @@ export default struct WebTerminal { } } + /** Called when isActive changes — update xterm.js focus */ + private onActiveChanged(): void { + hilog.info(DOMAIN, 'WebTerminal', 'Session %{public}d active: %{public}s (onActiveChanged)', this.sessionIndex, this.isActive) + this.applyFocus() + } + + /** Apply focus state to xterm.js. Called from onActiveChanged AND aboutToAppear. */ + private applyFocus(): void { + try { + this.webviewController.runJavaScript(`exports.setFocused(${this.isActive})`) + hilog.info(DOMAIN, 'WebTerminal', '[DIAG] applyFocus: session=%{public}d, isActive=%{public}s, OK', + this.sessionIndex, this.isActive) + } catch (e) { + hilog.warn(DOMAIN, 'WebTerminal', '[DIAG] applyFocus failed: session=%{public}d, err=%{public}s', + this.sessionIndex, JSON.stringify(e)) + } + } + + aboutToAppear(): void { + hilog.info(DOMAIN, 'WebTerminal', '[DIAG] aboutToAppear: session=%{public}d, isActive=%{public}s', + this.sessionIndex, this.isActive) + // @Watch only fires on value CHANGE, not on initial assignment. + // When a new WebTerminal is created with isActive=true (e.g. session 1 just opened), + // onActiveChanged never fires. We must explicitly apply focus here. + if (this.isActive) { + // WebView may not be fully ready yet in aboutToAppear; delay slightly + setTimeout(() => { + this.applyFocus() + }, 500) + } + } + async resize(width: number, height: number): Promise { hilog.info(DOMAIN, "WebTerminal", 'on resize: %{public}d, %{public}d', width, height) diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 767a118..a063d3d 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -12,6 +12,7 @@ import { defaultEmulator, defaultRootfs, Emulator, RootFilesystem } from '../mod import { util } from '@kit.ArkTS'; import { fileUri, picker } from '@kit.CoreFileKit'; import { backgroundRunningManager } from '../lib/backgroundRunningManager'; +import sessionManager from '../lib/SessionManager'; const DOMAIN = 0x0000; @@ -143,7 +144,15 @@ export default class EntryAbility extends UIAbility { const qmpUnixSocket = appTempDir + '/qmp_socket' const qgaUnixSocket = appTempDir + '/qga_socket' + // Generate additional serial socket paths based on maxSessions + const maxSessions = emulatorToStart.maxSessions ?? 1 + const serialUnixSockets: string[] = [] + for (let i = 1; i < maxSessions; i++) { + serialUnixSockets.push(appTempDir + `/serial_socket_${i}`) + } + AppStorage.setOrCreate(appOption.currentRunningEmulator, emulatorToStart.id) + AppStorage.setOrCreate(appOption.maxSessions, maxSessions) // 读取 VNC 启用配置 const vncEnabled = AppStorage.get(appOption.vncEnabled)!! @@ -160,6 +169,7 @@ export default class EntryAbility extends UIAbility { rootFilesystem, sharedFolder, serialUnixSocket, + serialUnixSockets, sharedFolderReadonly, init, qmpUnixSocket, @@ -171,6 +181,10 @@ export default class EntryAbility extends UIAbility { if (vmStarted) { AppStorage.setOrCreate(appOption.currentEmulatorStatus, 'RUNNING') + // Initialize session manager with serial socket paths + sessionManager.init(maxSessions, serialUnixSocket, serialUnixSockets) + // Open the primary session (session 0) immediately + sessionManager.openSession(0) } else { AppStorage.set(appOption.currentRunningEmulator, undefined) AppStorage.setOrCreate(appOption.currentEmulatorStatus, 'ABNORMAL') diff --git a/entry/src/main/ets/lib/SessionManager.ets b/entry/src/main/ets/lib/SessionManager.ets new file mode 100644 index 0000000..d32a054 --- /dev/null +++ b/entry/src/main/ets/lib/SessionManager.ets @@ -0,0 +1,228 @@ +/** + * SessionManager: Manages multiple terminal sessions. + * Session 0 is the primary serial port managed by the Native layer (napi.onData/sendInput). + * Sessions 1..N are managed by ArkTS via UnixSocketSession (backed by NAPI). + */ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { UnixSocketSession } from './UnixSocketSession'; + +const DOMAIN = 0x0000; +const TAG = 'SessionManager'; + +export interface TerminalSession { + /** Session index (0-based). 0 = primary serial port */ + index: number; + /** Display name for the tab */ + name: string; + /** Whether this session is currently active/connected */ + connected: boolean; + /** Whether this session is the primary (native) serial port */ + isPrimary: boolean; + /** For non-primary sessions, the UnixSocketSession instance */ + socketSession?: UnixSocketSession; + /** Unix socket path for this session */ + socketPath: string; + /** Callback to write data to the terminal UI */ + onOutput?: (data: ArrayBuffer) => void; + /** Pending data buffer (for data arriving before terminal is ready) */ + pendingData: ArrayBuffer[]; +} + +class SessionManagerClass { + private sessions: Map = new Map(); + private maxSessions: number = 1; + + init(maxSessions: number, primarySocketPath: string, extraSocketPaths: string[]): void { + this.maxSessions = maxSessions; + this.sessions.clear(); + + // Session 0: primary serial port (managed natively) + this.sessions.set(0, { + index: 0, + name: 'Session 1', + connected: true, + isPrimary: true, + socketPath: primarySocketPath, + pendingData: [] + }); + + // Sessions 1..N: extra serial ports (managed by ArkTS via NAPI) + for (let i = 0; i < extraSocketPaths.length; i++) { + this.sessions.set(i + 1, { + index: i + 1, + name: `Session ${i + 2}`, + connected: false, + isPrimary: false, + socketPath: extraSocketPaths[i], + pendingData: [] + }); + } + + hilog.info(DOMAIN, TAG, 'Initialized with %{public}d sessions (max: %{public}d)', this.sessions.size, maxSessions); + } + + getMaxSessions(): number { + return this.maxSessions; + } + + getSession(index: number): TerminalSession | undefined { + return this.sessions.get(index); + } + + getAllSessions(): TerminalSession[] { + const result: TerminalSession[] = []; + this.sessions.forEach((session: TerminalSession) => { + result.push(session); + }); + return result; + } + + /** Open a non-primary session (connect to its Unix socket) */ + async openSession(index: number): Promise { + const session = this.sessions.get(index); + if (!session) { + hilog.error(DOMAIN, TAG, 'Session %{public}d not found', index); + return false; + } + if (session.isPrimary) { + session.connected = true; + return true; + } + if (session.connected) { + hilog.warn(DOMAIN, TAG, 'Session %{public}d already connected', index); + return true; + } + + hilog.info(DOMAIN, TAG, 'Opening session %{public}d, socketPath=%{public}s', index, session.socketPath); + + const sockSession = new UnixSocketSession( + session.socketPath, + (data: ArrayBuffer) => { + hilog.info(DOMAIN, TAG, '[DIAG] onData from socket: session=%{public}d, bytes=%{public}d, onOutput=%{public}s', + index, data.byteLength, session.onOutput ? 'set' : 'NULL'); + if (session.onOutput) { + session.onOutput(data); + } else { + session.pendingData.push(data); + hilog.warn(DOMAIN, TAG, '[DIAG] onOutput is NULL, buffered data. session=%{public}d, pendingData=%{public}d', + index, session.pendingData.length); + } + }, + () => { + session.connected = false; + session.socketSession = undefined; + hilog.info(DOMAIN, TAG, 'Session %{public}d closed', index); + }, + (err: string) => { + hilog.error(DOMAIN, TAG, 'Session %{public}d error: %{public}s', index, err); + } + ); + + const connected = await sockSession.connect(); + hilog.info(DOMAIN, TAG, 'Session %{public}d connect result: %{public}s', index, connected ? 'success' : 'failed'); + if (connected) { + session.socketSession = sockSession; + session.connected = true; + + // Flush pending data + if (session.pendingData.length > 0 && session.onOutput) { + for (const data of session.pendingData) { + session.onOutput(data); + } + session.pendingData = []; + } + + hilog.info(DOMAIN, TAG, 'Session %{public}d opened (pendingData flushed, onOutput=%{public}s)', + index, session.onOutput ? 'set' : 'null'); + } + return connected; + } + + /** Close a non-primary session */ + async closeSession(index: number): Promise { + const session = this.sessions.get(index); + if (!session || session.isPrimary) { + hilog.warn(DOMAIN, TAG, 'Cannot close session %{public}d (not found or primary)', index); + return; + } + + if (session.socketSession) { + await session.socketSession.close(); + session.socketSession = undefined; + } + session.connected = false; + session.onOutput = undefined; + session.pendingData = []; + hilog.info(DOMAIN, TAG, 'Session %{public}d closed', index); + } + + /** Send input data to a specific session */ + async sendInput(index: number, data: ArrayBuffer): Promise { + const session = this.sessions.get(index); + if (!session || !session.connected) { + hilog.warn(DOMAIN, TAG, '[DIAG] sendInput FAIL: session=%{public}d (found=%{public}s, connected=%{public}s)', + index, session ? 'yes' : 'no', session?.connected ? 'yes' : 'no'); + return false; + } + + if (session.isPrimary) { + return false; // Caller should use napi.sendInput directly + } + + if (session.socketSession) { + const result = await session.socketSession.write(data); + hilog.info(DOMAIN, TAG, '[DIAG] sendInput: session=%{public}d, bytes=%{public}d, result=%{public}s', + index, data.byteLength, result ? 'OK' : 'FAIL'); + return result; + } + hilog.warn(DOMAIN, TAG, '[DIAG] sendInput FAIL: session=%{public}d has no socketSession', index); + return false; + } + + /** Set the output callback for a session */ + setOutputCallback(index: number, callback: (data: ArrayBuffer) => void): void { + const session = this.sessions.get(index); + if (!session) { + hilog.warn(DOMAIN, TAG, 'setOutputCallback: Session %{public}d not found', index); + return; + } + + session.onOutput = callback; + hilog.info(DOMAIN, TAG, 'setOutputCallback for session %{public}d, pendingData=%{public}d', + index, session.pendingData.length); + + // Flush any pending data + if (session.pendingData.length > 0) { + for (const data of session.pendingData) { + callback(data); + } + session.pendingData = []; + } + } + + /** Clear the output callback for a session */ + clearOutputCallback(index: number): void { + const session = this.sessions.get(index); + if (session) { + session.onOutput = undefined; + } + } + + /** Clean up all sessions */ + async closeAll(): Promise { + this.sessions.forEach((session: TerminalSession) => { + if (session.socketSession) { + session.socketSession.close(); + } + session.connected = false; + session.onOutput = undefined; + session.pendingData = []; + }); + this.sessions.clear(); + } +} + +/** Singleton session manager */ +const sessionManager = new SessionManagerClass(); + +export default sessionManager; diff --git a/entry/src/main/ets/lib/UnixSocketSession.ets b/entry/src/main/ets/lib/UnixSocketSession.ets new file mode 100644 index 0000000..22f8ac0 --- /dev/null +++ b/entry/src/main/ets/lib/UnixSocketSession.ets @@ -0,0 +1,164 @@ +/** + * UnixSocketSession: ArkTS-level serial port session manager for extra serial ports. + * Uses @kit.NetworkKit (LocalSocket) to connect to QEMU serial port Unix sockets. + * Only used for sessions beyond the first (primary) serial port. + * + * This is the same LocalSocket API used by QemuAgent.ets for QGA communication. + */ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { socket } from '@kit.NetworkKit'; +import { fileIo as fs } from '@kit.CoreFileKit'; + +const DOMAIN = 0x0000; +const TAG = 'UnixSocketSession'; + +export class UnixSocketSession { + private socketPath: string; + private onOutput: (data: ArrayBuffer) => void; + private onClose: () => void; + private onError: (err: string) => void; + private client: socket.LocalSocket | null = null; + private connected: boolean = false; + + constructor(socketPath: string, onOutput: (data: ArrayBuffer) => void, + onClose: () => void, onError: (err: string) => void) { + this.socketPath = socketPath; + this.onOutput = onOutput; + this.onClose = onClose; + this.onError = onError; + } + + async connect(): Promise { + // Wait for socket file to exist (max 10 seconds) + hilog.info(DOMAIN, TAG, 'Connecting to: %{public}s', this.socketPath); + const startTime = Date.now(); + const maxWait = 10000; + while (Date.now() - startTime < maxWait) { + try { + if (fs.accessSync(this.socketPath, fs.AccessModeType.EXIST)) { + hilog.info(DOMAIN, TAG, 'Socket file found after %{public}d ms', Date.now() - startTime); + break; + } + } catch (e) { + // accessSync may throw, ignore + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Final check + try { + if (!fs.accessSync(this.socketPath, fs.AccessModeType.EXIST)) { + const errMsg = `Socket file not found after ${maxWait}ms: ${this.socketPath}`; + hilog.error(DOMAIN, TAG, errMsg); + this.onError(errMsg); + return false; + } + } catch (e) { + hilog.error(DOMAIN, TAG, 'Failed to check socket file: %{public}s', JSON.stringify(e)); + } + + try { + this.client = socket.constructLocalSocketInstance(); + + // Subscribe to message events before connecting + this.client.on('message', (value: socket.LocalSocketMessageInfo) => { + hilog.info(DOMAIN, TAG, '[DIAG] message event on %{public}s, byteLength=%{public}d', + this.socketPath, value?.message?.byteLength ?? -1); + if (value && value.message) { + this.onOutput(value.message); + } else { + hilog.warn(DOMAIN, TAG, '[DIAG] message event but value/message is null on %{public}s', this.socketPath); + } + }); + + this.client.on('close', () => { + hilog.info(DOMAIN, TAG, 'Socket closed: %{public}s', this.socketPath); + if (this.connected) { + this.connected = false; + this.onClose(); + } + }); + + this.client.on('error', (err: Error) => { + hilog.error(DOMAIN, TAG, 'Socket error on %{public}s: %{public}s', this.socketPath, JSON.stringify(err)); + if (this.connected) { + this.connected = false; + this.onError(JSON.stringify(err)); + } + }); + + // Connect using LocalConnectOptions (same pattern as QemuAgent) + const address: socket.LocalAddress = { + address: this.socketPath + }; + const connectOptions: socket.LocalConnectOptions = { + address: address, + timeout: 6000 + }; + await this.client.connect(connectOptions); + + this.connected = true; + hilog.info(DOMAIN, TAG, 'Connected to: %{public}s', this.socketPath); + + // Send a newline to trigger getty to re-print the login prompt. + // With QEMU's "server,nowait" mode, any serial output before a client connects + // is discarded. Sending \n causes getty/agetty to re-display the prompt. + try { + const newline = new ArrayBuffer(1); + new Uint8Array(newline)[0] = 0x0A; // '\n' + await this.client.send({ data: newline }); + hilog.info(DOMAIN, TAG, 'Sent newline to trigger getty prompt on %{public}s', this.socketPath); + } catch (e) { + hilog.warn(DOMAIN, TAG, 'Failed to send newline: %{public}s', JSON.stringify(e)); + } + + return true; + } catch (e) { + hilog.error(DOMAIN, TAG, 'connect error for %{public}s: %{public}s', this.socketPath, JSON.stringify(e)); + this.onError(String(e)); + this.connected = false; + return false; + } + } + + async write(data: ArrayBuffer): Promise { + if (!this.connected || !this.client) { + hilog.warn(DOMAIN, TAG, '[DIAG] Write failed: not connected (connected=%{public}s, client=%{public}s, path=%{public}s)', + this.connected, this.client ? 'exists' : 'null', this.socketPath); + return false; + } + try { + const sendOptions: socket.LocalSendOptions = { + data: data + }; + await this.client.send(sendOptions); + hilog.info(DOMAIN, TAG, '[DIAG] Write OK: %{public}d bytes to %{public}s', data.byteLength, this.socketPath); + return true; + } catch (e) { + hilog.error(DOMAIN, TAG, '[DIAG] Write failed for %{public}s: %{public}s', this.socketPath, JSON.stringify(e)); + return false; + } + } + + isConnected(): boolean { + return this.connected; + } + + async close(): Promise { + if (this.connected && this.client) { + const client = this.client; + this.client = null; + this.connected = false; + + try { + client.off('message'); + client.off('error'); + client.off('close'); + await client.close(); + } catch (e) { + hilog.warn(DOMAIN, TAG, 'Close error for %{public}s: %{public}s', this.socketPath, JSON.stringify(e)); + } + this.onClose(); + } + } +} diff --git a/entry/src/main/ets/lib/startVm.ets b/entry/src/main/ets/lib/startVm.ets index a4e0eea..ecba2fe 100644 --- a/entry/src/main/ets/lib/startVm.ets +++ b/entry/src/main/ets/lib/startVm.ets @@ -13,6 +13,8 @@ interface VmOptions { rootFilesystem: string sharedFolder: string serialUnixSocket: string + /** Additional serial port Unix socket paths (for multi-session support) */ + serialUnixSockets: string[] qmpUnixSocket: string qgaUnixSocket: string sharedFolderReadonly: boolean @@ -34,6 +36,7 @@ function startVm(options: VmOptions): boolean { "-object", "rng-random,filename=/dev/urandom,id=rng0", "-device", "virtio-rng-pci-non-transitional,rng=rng0", '-rtc', 'base=utc,clock=host', '-L', options.baseDir] + // Primary serial port (ttyAMA0) - managed by Native layer const serial = ['-serial', "unix:" + options.serialUnixSocket + ",server,nowait"] const cpuMem = ['-smp', `cpus=${options.cpu},sockets=1,cores=${options.cpu},threads=1`, "-object", `memory-backend-ram,id=mem0,size=${options.memory}M,merge=off,prealloc=off`, '-m', options.memory + 'M'] @@ -79,6 +82,13 @@ function startVm(options: VmOptions): boolean { '-device', 'virtio-tablet-pci' ] : [] + // Additional serial ports (ttyAMA1..ttyAMAn) - managed by ArkTS layer + const extraSerials: string[] = [] + for (let i = 0; i < options.serialUnixSockets.length; i++) { + extraSerials.push('-chardev', `socket,path=${options.serialUnixSockets[i]},server=on,wait=off,id=chc${i}` , + '-device', `virtconsole,chardev=chc${i},name=console.${i}`) + } + const args = [ 'qemu-system-aarch64', ...basic, @@ -92,12 +102,18 @@ function startVm(options: VmOptions): boolean { ...monitor, ...qga, ...vnc, - ...sharedUserFolder + ...sharedUserFolder, + ...extraSerials ] if (fs.accessSync(options.serialUnixSocket, fs.AccessModeType.EXIST)) { fs.unlinkSync(options.serialUnixSocket) } + for (const sock of options.serialUnixSockets) { + if (fs.accessSync(sock, fs.AccessModeType.EXIST)) { + fs.unlinkSync(sock) + } + } if (fs.accessSync(options.qmpUnixSocket, fs.AccessModeType.EXIST)) { fs.unlinkSync(options.qmpUnixSocket) } diff --git a/entry/src/main/ets/model/Emulator.ets b/entry/src/main/ets/model/Emulator.ets index 7da2af8..4f17ed3 100644 --- a/entry/src/main/ets/model/Emulator.ets +++ b/entry/src/main/ets/model/Emulator.ets @@ -19,6 +19,8 @@ interface Emulator { rootVda: string, rootFilesystem: string sharedFolderReadonly: boolean + /** Maximum number of terminal sessions (serial ports). Default: 1, Max: 4 */ + maxSessions: number } const defaultRootfs: RootFilesystem = { @@ -36,7 +38,8 @@ const defaultEmulator: Emulator = { portMapping: [], rootVda: '/dev/sda', rootFilesystem: 'vm/rootfs_aarch64.qcow2', - sharedFolderReadonly: false + sharedFolderReadonly: false, + maxSessions: 1 } export { Emulator, PortMapping, RootFilesystem, defaultEmulator, defaultRootfs } diff --git a/entry/src/main/ets/model/appOption.ets b/entry/src/main/ets/model/appOption.ets index fba8a06..2c79f79 100644 --- a/entry/src/main/ets/model/appOption.ets +++ b/entry/src/main/ets/model/appOption.ets @@ -37,6 +37,10 @@ class appOption { static qemuImgTaskDescription = 'qemuImgTaskDescription'; // QGA 连接状态 (Global Truth) static isQgaConnected = 'isQgaConnected'; + // Multi-session support + static maxSessions = 'maxSessions'; + static activeSessionIndex = 'activeSessionIndex'; + static sessionList = 'sessionList'; } async function savePreference(key: string, value: object | string | boolean | number, context: UIContext) { diff --git a/entry/src/main/ets/pages/EditEmulator.ets b/entry/src/main/ets/pages/EditEmulator.ets index e8aad6e..4eb2ebf 100644 --- a/entry/src/main/ets/pages/EditEmulator.ets +++ b/entry/src/main/ets/pages/EditEmulator.ets @@ -24,7 +24,8 @@ struct EditEmulator { portMapping: [], rootVda: '/dev/sda', rootFilesystem: '', - sharedFolderReadonly: false + sharedFolderReadonly: false, + maxSessions: 1 } } else { this.emulator = { @@ -36,7 +37,8 @@ struct EditEmulator { portMapping: found.portMapping, rootVda: found.rootVda, rootFilesystem: found.rootFilesystem, - sharedFolderReadonly: found.sharedFolderReadonly + sharedFolderReadonly: found.sharedFolderReadonly, + maxSessions: found.maxSessions ?? 1 } } } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 99bfcc3..8290d4c 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -12,12 +12,104 @@ import RootfsManagementContent from "../components/RootfsManagementContent"; import UserGuide from "../components/UserGuide"; import { webview } from "@kit.ArkWeb"; import VmStatusBar from '../components/VmStatusBar'; +import sessionManager from '../lib/SessionManager'; + +/** Represents an active terminal session tab */ +@Observed +class SessionTab { + index: number = 0; + name: string = ''; + connected: boolean = false; + + constructor(index: number, name: string, connected: boolean) { + this.index = index; + this.name = name; + this.connected = connected; + } +} + +@Component +struct SessionTabBar { + @Link activeSessionIndex: number + @State tabs: SessionTab[] = [] + @StorageProp(appOption.maxSessions) maxSessions: number = 1 + /** Called when a new session is opened, with the session index */ + onSessionOpened?: (index: number) => void + /** Called when user clicks an existing tab to switch to it */ + onSessionSwitched?: (index: number) => void + + aboutToAppear(): void { + this.refreshTabs() + } + + private refreshTabs(): void { + const sessions = sessionManager.getAllSessions() + const newTabs: SessionTab[] = [] + for (const s of sessions) { + newTabs.push(new SessionTab(s.index, s.name, s.connected)) + } + this.tabs = newTabs + } + + build() { + Row() { + ForEach(this.tabs, (tab: SessionTab) => { + Button(tab.name, { buttonStyle: ButtonStyleMode.TEXTUAL }) + .height(28) + .fontSize(12) + .borderRadius(4) + .padding({ left: 8, right: 8 }) + .backgroundColor(this.activeSessionIndex === tab.index ? '#444' : '#222') + .fontColor(this.activeSessionIndex === tab.index ? '#fff' : '#999') + .margin({ right: 2 }) + .onClick(() => { + this.activeSessionIndex = tab.index + if (this.onSessionSwitched) { + this.onSessionSwitched(tab.index) + } + }) + }, (tab: SessionTab) => tab.index.toString()) + + if (this.tabs.length < this.maxSessions) { + Button('+', { buttonStyle: ButtonStyleMode.TEXTUAL }) + .height(28) + .width(28) + .fontSize(14) + .borderRadius(4) + .fontColor('#999') + .backgroundColor('#222') + .margin({ right: 2 }) + .onClick(async () => { + // Find the next unopened session + const allSessions = sessionManager.getAllSessions() + const unopened = allSessions.find(s => !s.connected) + if (unopened) { + await sessionManager.openSession(unopened.index) + this.refreshTabs() + this.activeSessionIndex = unopened.index + if (this.onSessionOpened) { + this.onSessionOpened(unopened.index) + } + } + }) + } + } + .width('100%') + .height(32) + .padding({ left: 4 }) + .backgroundColor('#1a1a1a') + } +} @Component struct PcIndex { @StorageProp(appOption.sharedHost) sharedHost?: string = undefined @StorageProp(appOption.sharedGuest) sharedGuest?: string = undefined @StorageProp(appOption.displayStatusBar) displayStatusBar: boolean = false + @StorageProp(appOption.maxSessions) maxSessions: number = 1 + @State activeSessionIndex: number = 0 + /** Track which session indices have been opened (need a WebTerminal) */ + @State openedSessionIndices: number[] = [0] emulatorManagementController: CustomDialogController = new CustomDialogController({ builder: CustomContentDialog({ primaryTitle: $r('app.string.emulator_management_title'), @@ -192,8 +284,37 @@ struct PcIndex { right: 150, }) - WebTerminal({ virtKeyEnabled: false }) - .layoutWeight(1) + if (this.maxSessions > 1) { + SessionTabBar({ + activeSessionIndex: $activeSessionIndex, + onSessionOpened: (idx: number) => { + this.openedSessionIndices = [...this.openedSessionIndices, idx] + }, + onSessionSwitched: (idx: number) => { + // Ensure the WebTerminal for this session exists + if (!this.openedSessionIndices.includes(idx)) { + this.openedSessionIndices = [...this.openedSessionIndices, idx] + } + } + }) + } + + // Stack multiple WebTerminal instances, show only the active one. + // Use offset to move inactive terminals off-screen instead of visibility:Hidden, + // because Hidden WebViews may not execute JS or respond to runJavaScript. + Stack() { + ForEach(this.openedSessionIndices, (idx: number) => { + WebTerminal({ + virtKeyEnabled: false, + sessionIndex: idx, + isActive: this.activeSessionIndex === idx + }) + .layoutWeight(1) + .offset({ y: this.activeSessionIndex === idx ? 0 : '-100%' }) + }, (idx: number) => idx.toString()) + } + .layoutWeight(1) + .clip(true) } .width("100%") .height('100%') @@ -221,7 +342,8 @@ struct PcIndex { portMapping: [], rootVda: '/dev/sda', rootFilesystem: '', - sharedFolderReadonly: false + sharedFolderReadonly: false, + maxSessions: 1 } this.openEditEmulatorDialog(emulatorToEdit, true) }) @@ -238,7 +360,8 @@ struct PcIndex { portMapping: item.portMapping, rootVda: item.rootVda, rootFilesystem: item.rootFilesystem, - sharedFolderReadonly: item.sharedFolderReadonly + sharedFolderReadonly: item.sharedFolderReadonly, + maxSessions: item.maxSessions ?? 1 }; const controller = new CustomDialogController({ builder: CustomContentDialog({ @@ -338,6 +461,10 @@ struct PcIndex { @Component struct PhoneOrTablet { @StorageProp(appOption.displayStatusBar) displayStatusBar: boolean = false + @StorageProp(appOption.maxSessions) maxSessions: number = 1 + @State activeSessionIndex: number = 0 + /** Track which session indices have been opened (need a WebTerminal) */ + @State openedSessionIndices: number[] = [0] build() { Column() { @@ -353,8 +480,37 @@ struct PhoneOrTablet { .backgroundColor($r('sys.color.ohos_id_color_sub_background')) } - WebTerminal({ virtKeyEnabled: true }) - .layoutWeight(1) + if (this.maxSessions > 1) { + SessionTabBar({ + activeSessionIndex: $activeSessionIndex, + onSessionOpened: (idx: number) => { + this.openedSessionIndices = [...this.openedSessionIndices, idx] + }, + onSessionSwitched: (idx: number) => { + // Ensure the WebTerminal for this session exists + if (!this.openedSessionIndices.includes(idx)) { + this.openedSessionIndices = [...this.openedSessionIndices, idx] + } + } + }) + } + + // Stack multiple WebTerminal instances, show only the active one. + // Use offset to move inactive terminals off-screen instead of visibility:Hidden, + // because Hidden WebViews may not execute JS or respond to runJavaScript. + Stack() { + ForEach(this.openedSessionIndices, (idx: number) => { + WebTerminal({ + virtKeyEnabled: true, + sessionIndex: idx, + isActive: this.activeSessionIndex === idx + }) + .layoutWeight(1) + .offset({ y: this.activeSessionIndex === idx ? 0 : '-100%' }) + }, (idx: number) => idx.toString()) + } + .layoutWeight(1) + .clip(true) } .width('100%') .height('100%') diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 49581b3..a2cc105 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -1647,6 +1647,22 @@ { "name": "agent_connecting_tips", "value": "Attempting to connect to qemu-guest-agent...\nIf it takes too long to connect, please check if the qemu-guest-agent inside the emulator is running properly" + }, + { + "name": "label_max_sessions", + "value": "Max Sessions" + }, + { + "name": "session_close_confirm", + "value": "Close Session" + }, + { + "name": "session_close_message", + "value": "Are you sure to close this session?" + }, + { + "name": "setting_max_sessions_desc", + "value": "Maximum number of terminal sessions (requires restart)" } ] } \ No newline at end of file diff --git a/entry/src/main/resources/zh_CN/element/string.json b/entry/src/main/resources/zh_CN/element/string.json index 0479401..f0ae5a4 100644 --- a/entry/src/main/resources/zh_CN/element/string.json +++ b/entry/src/main/resources/zh_CN/element/string.json @@ -1647,6 +1647,22 @@ { "name": "guest_status_bar_toast", "value": "开启“状态栏”需要确保模拟器中的 qemu-guest-agent 正常运行" + }, + { + "name": "label_max_sessions", + "value": "最大会话数" + }, + { + "name": "session_close_confirm", + "value": "关闭会话" + }, + { + "name": "session_close_message", + "value": "确定要关闭此会话吗?" + }, + { + "name": "setting_max_sessions_desc", + "value": "最大终端会话数量(需重启生效)" } ] } \ No newline at end of file