diff --git a/.gitignore b/.gitignore index 000126b0..963066bd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ build-profile.json5.* .claude .vscode -.temp \ No newline at end of file +.temp +.DS_Store diff --git a/deps/.gitignore b/deps/.gitignore index 0f7998d6..8f73c39c 100644 --- a/deps/.gitignore +++ b/deps/.gitignore @@ -4,4 +4,5 @@ download .stamp file.lst buildroot -output \ No newline at end of file +output +.DS_Store diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 index b449990d..916fa43c 100644 --- a/entry/build-profile.json5 +++ b/entry/build-profile.json5 @@ -17,7 +17,7 @@ "arkOptions": { "obfuscation": { "ruleOptions": { - "enable": false, + "enable": true, "files": [ "./obfuscation-rules.txt" ] diff --git a/entry/src/main/cpp/include/vnc_client.hpp b/entry/src/main/cpp/include/vnc_client.hpp index 7cdeef52..cf273650 100644 --- a/entry/src/main/cpp/include/vnc_client.hpp +++ b/entry/src/main/cpp/include/vnc_client.hpp @@ -24,6 +24,7 @@ struct VncFrameInfo { using VncResizeCallback = std::function; using VncFrameCallback = std::function; +using VncClipboardCallback = std::function; class VncClient { public: @@ -33,9 +34,11 @@ class VncClient { static void sendMouseEvent(int x, int y, int buttonMask); static void sendKeyEvent(uint32_t key, bool down); + static void sendCutText(const char* text, int len); static void setResizeCallback(VncResizeCallback cb); static void setFrameCallback(VncFrameCallback cb); + static void setClipboardCallback(VncClipboardCallback cb); static uint8_t* getFrameBuffer(); static int getFrameWidth(); @@ -63,12 +66,14 @@ class VncClient { static VncResizeCallback resizeCallback_; static VncFrameCallback frameCallback_; + static VncClipboardCallback cutTextCallback_; static std::mutex socketMutex_; // libvncclient callbacks static rfbBool onResize(rfbClient* cl); static void onUpdate(rfbClient* cl, int x, int y, int w, int h); static char* getPassword(rfbClient* cl); + static void onCutText(rfbClient* cl, const char* text, int textlen); static bool checkConnection(); }; diff --git a/entry/src/main/cpp/napi_init.cpp b/entry/src/main/cpp/napi_init.cpp index c4d88ad7..d567d6cd 100644 --- a/entry/src/main/cpp/napi_init.cpp +++ b/entry/src/main/cpp/napi_init.cpp @@ -1,4 +1,5 @@ #include "napi/native_api.h" +#include #include #include #include @@ -41,9 +42,9 @@ struct data_buffer { size_t size; }; -int serial_input_fd = -1; -napi_threadsafe_function on_data_callback = nullptr; -napi_threadsafe_function on_shutdown_callback = nullptr; +std::atomic serial_input_fd{-1}; +std::atomic on_data_callback{nullptr}; +std::atomic on_shutdown_callback{nullptr}; std::mutex buffer_mtx; std::string temp_buffer = ""; @@ -60,6 +61,7 @@ static QemuSystemEntry getQemuSystemEntry() { const char *libQemuPath = "libqemu-system-aarch64.so"; + // 故意不 dlclose:QEMU 符号在整个进程生命周期内必须有效 void *libQemuHandle = dlopen(libQemuPath, RTLD_LAZY); if (!libQemuHandle) { @@ -103,6 +105,7 @@ static QemuImgEntry getQemuImgEntry() { // QCOW2 文件头结构(简化版) // 参考: https://github.com/qemu/qemu/blob/master/docs/interop/qcow2.txt +#pragma pack(push, 1) struct Qcow2Header { uint32_t magic; // 0-3: Magic number 'QFI\xfb' @@ -125,6 +128,7 @@ struct Qcow2Header uint32_t refcount_order; // 96-99: refcount_bits = 1 << refcount_order uint32_t header_length; // 100-103 }; +#pragma pack(pop) // 大端转小端(网络字节序转主机字节序) static uint32_t be32toh_manual(uint32_t val) @@ -228,8 +232,8 @@ static std::string getQcow2Info(const std::string &imagePath) << "}}" << "}"; - OH_LOG_INFO(LOG_APP, "QCOW2 info: version=%{public}d, virtual_size=%{public}llu, cluster_size=%{public}d", - version, (unsigned long long)virtual_size, cluster_size); + OH_LOG_INFO(LOG_APP, "QCOW2 info: version=%{public}d, virtual_size=%{public}llu, cluster_size=%{public}llu", + version, (unsigned long long)virtual_size, (unsigned long long)cluster_size); return json.str(); } @@ -280,10 +284,6 @@ struct QcowSnapshotHeader }; #pragma pack(pop) -// 标记 qemu-img 是否已调用过(用于检测是否需要重启) -static bool qemu_img_called = false; -static bool qemu_img_failed = false; - // 读取 QCOW2 快照列表(直接解析文件,不调用 qemu-img) static std::string getSnapshotsFromFile(const std::string &imagePath) { @@ -474,7 +474,7 @@ static bool initQemuLibrary() } // 加载库 - // 使用 RTLD_GLOBAL 确保符号可见,RTLD_NOW 立即解析 + // 使用 RTLD_LOCAL 避免符号污染,RTLD_LAZY 延迟解析 g_qemu_lib_handle = dlopen(libPath.c_str(), RTLD_LAZY | RTLD_LOCAL); if (!g_qemu_lib_handle) { @@ -823,6 +823,54 @@ static void call_on_shutdown_callback(napi_env env, napi_value js_callback, void napi_call_function(env, global, js_callback, 0, nullptr, nullptr); } +// Strip echoed CSI resize sequences from raw serial output +// Kernel tty echo mode reflects \x1b[8;;t back to the console +// [修复] 维护跨 buffer 的 partial match 状态,防止 CSI 序列被 read 切断时产生乱码 +// 仅 serial_output_worker 单线程访问,不需互斥锁 +static uint8_t csi_partial_buf[32]; +static ssize_t csi_partial_len = 0; + +ssize_t stripEchoedCsi(uint8_t *buf, ssize_t len) { + // 如果上次有残留的 partial CSI,先拼接 + uint8_t combined[1024 + 32]; + ssize_t combined_len = 0; + uint8_t *src = buf; + + if (csi_partial_len > 0) { + memcpy(combined, csi_partial_buf, csi_partial_len); + memcpy(combined + csi_partial_len, buf, len); + combined_len = csi_partial_len + len; + src = combined; + csi_partial_len = 0; + } else { + combined_len = len; + } + + // raw pattern: 0x1B 0x5B 0x38 0x3B 0x3B 0x74 + ssize_t out = 0; + ssize_t i = 0; + for (; i < combined_len; ) { + if (src[i] == 0x1B && i + 3 < combined_len && + src[i + 1] == 0x5B && src[i + 2] == 0x38 && src[i + 3] == 0x3B) { + ssize_t j = i + 4; + while (j < combined_len && src[j] != 0x74) j++; + if (j < combined_len) { + i = j + 1; // skip entire CSI sequence + continue; + } + // Partial match at end of buffer: save for next call + ssize_t remaining = combined_len - i; + if (remaining <= sizeof(csi_partial_buf)) { + memcpy(csi_partial_buf, src + i, remaining); + csi_partial_len = remaining; + } + break; // Don't output partial CSI + } + buf[out++] = src[i++]; + } + return out; +} + std::string convert_to_hex(const uint8_t *buffer, int r) { std::string hex; for (int i = 0; i < r; i++) { @@ -850,8 +898,9 @@ void send_data_to_callback(const std::string &hex, napi_threadsafe_function call void on_serial_data_received(const std::string &hex) { if (hex.length() > 0) { std::lock_guard lk(buffer_mtx); - if (on_data_callback != nullptr) { - send_data_to_callback(hex, on_data_callback); + auto cb = on_data_callback.load(std::memory_order_acquire); + if (cb != nullptr) { + send_data_to_callback(hex, cb); } else { temp_buffer.append(hex); } @@ -898,9 +947,13 @@ void serial_output_worker(const char *unix_socket_path) { return; } - serial_input_fd = client_fd; + serial_input_fd.store(client_fd, std::memory_order_release); - OH_LOG_INFO(LOG_APP, "Connected to unix socket: %{public}d", serial_input_fd); + OH_LOG_INFO(LOG_APP, "Connected to unix socket: %{public}d", serial_input_fd.load(std::memory_order_acquire)); + + // [省电] 自适应 poll 间隔:有数据时 10ms,空闲时逐渐放宽到 500ms + int poll_interval = 10; + int idle_count = 0; while (true) { @@ -909,13 +962,34 @@ void serial_output_worker(const char *unix_socket_path) { struct pollfd fds[2]; fds[0].fd = client_fd; fds[0].events = POLLIN; - int res = poll(fds, 1, 100); + int res = poll(fds, 1, poll_interval); + + if (res < 0) { + // poll 错误(如 EINTR),记录并继续 + OH_LOG_WARN(LOG_APP, "poll error: %{public}d, errno=%{public}d", res, errno); + continue; + } + if (res == 0) { + // 超时,无数据可读,逐步放宽 poll 间隔 + if (idle_count < 10000) idle_count++; + if (idle_count > 10) poll_interval = 50; + if (idle_count > 50) poll_interval = 200; + if (idle_count > 100) poll_interval = 500; + continue; + } + + // 有数据,重置间隔 + poll_interval = 10; + idle_count = 0; uint8_t buffer[1024]; for (int i = 0; i < res; i += 1) { int fd = fds[i].fd; ssize_t r = read(fd, buffer, sizeof(buffer) - 1); if (r > 0) { + // 过滤 VM tty echo 回显的 CSI resize 序列 \x1b[8;H;Wt + r = stripEchoedCsi(buffer, r); + if (r <= 0) continue; // pretty print auto hex = convert_to_hex(buffer, r); // call callback registered by ArkTS @@ -939,12 +1013,20 @@ void serial_output_worker(const char *unix_socket_path) { // 清理 socket 资源,但保留回调以便新的 worker 线程使用 close(client_fd); - serial_input_fd = -1; + serial_input_fd.store(-1, std::memory_order_release); OH_LOG_INFO(LOG_APP, "Closed serial socket fd: %{public}d", client_fd); - if (on_data_callback != nullptr) { - napi_release_threadsafe_function(on_data_callback, napi_threadsafe_function_release_mode::napi_tsfn_release); - on_data_callback = nullptr; + if (on_data_callback.load(std::memory_order_acquire) != nullptr) { + napi_threadsafe_function cb = on_data_callback.exchange(nullptr, std::memory_order_acq_rel); + if (cb != nullptr) { + napi_release_threadsafe_function(cb, napi_threadsafe_function_release_mode::napi_tsfn_release); + } + } + if (on_shutdown_callback.load(std::memory_order_acquire) != nullptr) { + napi_threadsafe_function cb = on_shutdown_callback.exchange(nullptr, std::memory_order_acq_rel); + if (cb != nullptr) { + napi_release_threadsafe_function(cb, napi_tsfn_release); + } } OH_LOG_INFO(LOG_APP, "Serial unix socket broken: %{public}d", errno); @@ -1022,8 +1104,10 @@ static napi_value startVM(napi_env env, napi_callback_info info) { OH_LOG_INFO(LOG_APP, "qemuEntry exited with: %{public}d", status); - if (on_shutdown_callback != nullptr) { - napi_call_threadsafe_function(on_shutdown_callback, nullptr, napi_tsfn_nonblocking); + // [修复] 单次 load 消除 race window(两次 load 之间可能被 exchange 为 null) + auto cb = on_shutdown_callback.load(std::memory_order_acquire); + if (cb != nullptr) { + napi_call_threadsafe_function(cb, nullptr, napi_tsfn_nonblocking); } }); vm_loop.detach(); @@ -1038,7 +1122,8 @@ static napi_value startVM(napi_env env, napi_callback_info info) { static napi_value sendInput(napi_env env, napi_callback_info info) { - if (serial_input_fd < 0) { + int fd = serial_input_fd.load(std::memory_order_acquire); + if (fd < 0) { return nullptr; } @@ -1063,7 +1148,7 @@ static napi_value sendInput(napi_env env, napi_callback_info info) { while (written < (int)length) { // P0-02修复: 移除assert,使用显式错误处理 - int size = write(serial_input_fd, (uint8_t *)data + written, length - written); + int size = write(fd, (uint8_t *)data + written, length - written); if (size < 0) { OH_LOG_ERROR(LOG_APP, "Serial write failed: errno=%{public}d", errno); break; @@ -1089,11 +1174,15 @@ static napi_value onData(napi_env env, napi_callback_info info) { { std::lock_guard lk(buffer_mtx); + // 释放旧的 TSFN,防止重复注册导致资源泄漏 + napi_threadsafe_function old_cb = on_data_callback.exchange(data_callback, std::memory_order_acq_rel); + if (old_cb != nullptr) { + napi_release_threadsafe_function(old_cb, napi_tsfn_release); + } if (!temp_buffer.empty()) { send_data_to_callback(temp_buffer, data_callback); temp_buffer.clear(); } - on_data_callback = data_callback; } return nullptr; @@ -1105,10 +1194,18 @@ static napi_value onShutdown(napi_env env, napi_callback_info info) { napi_value args[1] = {nullptr}; napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + // 释放旧的 TSFN,防止 WebTerminal 和 EmulatorListGrid 重复注册导致资源泄漏 + napi_threadsafe_function old_cb = on_shutdown_callback.exchange(nullptr, std::memory_order_acq_rel); + if (old_cb != nullptr) { + napi_release_threadsafe_function(old_cb, napi_tsfn_release); + } + napi_value data_cb_name; napi_create_string_utf8(env, "shutdown_callback", NAPI_AUTO_LENGTH, &data_cb_name); + napi_threadsafe_function new_cb; napi_create_threadsafe_function(env, args[0], nullptr, data_cb_name, 0, 1, nullptr, nullptr, nullptr, - call_on_shutdown_callback, &on_shutdown_callback); + call_on_shutdown_callback, &new_cb); + on_shutdown_callback.store(new_cb, std::memory_order_release); return nullptr; } diff --git a/entry/src/main/cpp/napi_vnc.cpp b/entry/src/main/cpp/napi_vnc.cpp index a7654251..0c33276c 100644 --- a/entry/src/main/cpp/napi_vnc.cpp +++ b/entry/src/main/cpp/napi_vnc.cpp @@ -207,7 +207,8 @@ static napi_value vncInit(napi_env env, napi_callback_info info) { static napi_value vncClose(napi_env env, napi_callback_info info) { OH_LOG_INFO(LOG_APP, "vncClose"); - // 1. Stop poll thread + // 1. Stop poll thread — must join before releasing TSFN to prevent + // poll thread from queueing new callbacks while TSFN is being torn down. { std::lock_guard lock(g_pollMutex); g_pollRunning.store(false); @@ -216,9 +217,10 @@ static napi_value vncClose(napi_env env, napi_callback_info info) { } } - // 2. Release TSFN + // 2. Release TSFN — use abort to discard any pending callbacks + // and prevent use-after-free in tsfnCallJs after g_tsfn is nulled. if (g_tsfn != nullptr) { - napi_release_threadsafe_function(g_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(g_tsfn, napi_tsfn_abort); g_tsfn = nullptr; } @@ -283,7 +285,7 @@ static napi_value vncStartUpdateLoop(napi_env env, napi_callback_info info) { std::lock_guard lock(g_pollMutex); if (g_tsfn != nullptr) { - napi_release_threadsafe_function(g_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(g_tsfn, napi_tsfn_abort); g_tsfn = nullptr; } @@ -300,7 +302,7 @@ static napi_value vncStartUpdateLoop(napi_env env, napi_callback_info info) { g_pollRunning.store(true); g_pollThread = std::thread(vncPollThread); - // Send initial VNC dimensions to JS + // Send initial VNC dimensions to JS and wake renderer for initial frame upload rfbClient* cl = VncClient::getClient(); if (cl && g_tsfn) { auto* nd = allocNotifyData(); @@ -308,6 +310,11 @@ static napi_value vncStartUpdateLoop(napi_env env, napi_callback_info info) { nd->fbWidth = cl->width; nd->fbHeight = cl->height; notifyJs(nd); + + // Also wake the render thread to ensure it picks up the initial VNC framebuffer. + // Without this, if the render thread started before the first VNC frame update + // arrives, vncWidth_/vncHeight_ would remain 0 and frames would be skipped. + VncRenderer::resize(-1, -1); } OH_LOG_INFO(LOG_APP, "vncStartUpdateLoop: thread started"); @@ -320,6 +327,7 @@ static napi_value vncStartUpdateLoop(napi_env env, napi_callback_info info) { static napi_value vncStopUpdateLoop(napi_env env, napi_callback_info info) { OH_LOG_INFO(LOG_APP, "vncStopUpdateLoop"); + // Stop poll thread first, then abort TSFN to prevent use-after-free std::lock_guard lock(g_pollMutex); g_pollRunning.store(false); if (g_pollThread.joinable()) { @@ -327,7 +335,7 @@ static napi_value vncStopUpdateLoop(napi_env env, napi_callback_info info) { } if (g_tsfn != nullptr) { - napi_release_threadsafe_function(g_tsfn, napi_tsfn_release); + napi_release_threadsafe_function(g_tsfn, napi_tsfn_abort); g_tsfn = nullptr; } @@ -388,6 +396,29 @@ static napi_value vncDestroySurface(napi_env env, napi_callback_info info) { return ret; } +// ---- VNC Clipboard ---- +static napi_value vncSendCutText(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1] = {nullptr}; + napi_status status = napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (status != napi_ok || argc < 1) { + napi_value ret; + napi_create_int32(env, -1, &ret); + return ret; + } + + size_t len = 0; + napi_get_value_string_utf8(env, args[0], nullptr, 0, &len); + std::string text(len, '\0'); + napi_get_value_string_utf8(env, args[0], &text[0], len + 1, &len); + + VncClient::sendCutText(text.c_str(), static_cast(len)); + + napi_value ret; + napi_create_int32(env, 0, &ret); + return ret; +} + // ---- Register ---- void registerVncFunctions(napi_env env, napi_value exports) { napi_property_descriptor desc[] = { @@ -400,6 +431,7 @@ void registerVncFunctions(napi_env env, napi_value exports) { {"vncCreateSurface", nullptr, vncCreateSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, {"vncResizeSurface", nullptr, vncResizeSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, {"vncDestroySurface", nullptr, vncDestroySurface, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"vncSendCutText", nullptr, vncSendCutText, nullptr, nullptr, nullptr, napi_default, nullptr}, }; napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); } diff --git a/entry/src/main/cpp/types/libentry/Index.d.ts b/entry/src/main/cpp/types/libentry/Index.d.ts index 2a5fdc5b..2ff1a52c 100644 --- a/entry/src/main/cpp/types/libentry/Index.d.ts +++ b/entry/src/main/cpp/types/libentry/Index.d.ts @@ -137,3 +137,6 @@ export const vncResizeSurface: (surfaceId: bigint, width: number, height: number * @returns 0 表示成功 */ export const vncDestroySurface: () => number; + +/** Send clipboard text to VNC server */ +export const vncSendCutText: (text: string) => number; diff --git a/entry/src/main/cpp/utils.cpp b/entry/src/main/cpp/utils.cpp index 75ab9ca4..ca6b11a4 100644 --- a/entry/src/main/cpp/utils.cpp +++ b/entry/src/main/cpp/utils.cpp @@ -25,7 +25,7 @@ std::tuple createNewBuffer(napi_env env, size_t num) { } rfbKeySym ohKeyCode2RFBKeyCode(Input_KeyCode k, rfbBool down) { - static bool alt = false, ctrl = false, shift = false; + thread_local bool shift = false; switch (k) { case KEYCODE_UNKNOWN: case KEYCODE_FN: @@ -129,10 +129,8 @@ rfbKeySym ohKeyCode2RFBKeyCode(Input_KeyCode k, rfbBool down) { case KEYCODE_PERIOD: return shift ? XK_greater : XK_period; case KEYCODE_ALT_LEFT: - alt = down; return XK_Alt_L; case KEYCODE_ALT_RIGHT: - alt = down; return XK_Alt_R; case KEYCODE_SHIFT_LEFT: shift = down; @@ -187,10 +185,8 @@ rfbKeySym ohKeyCode2RFBKeyCode(Input_KeyCode k, rfbBool down) { case KEYCODE_FORWARD_DEL: return XK_Delete; case KEYCODE_CTRL_LEFT: - ctrl = down; return XK_Control_L; case KEYCODE_CTRL_RIGHT: - ctrl = down; return XK_Control_R; case KEYCODE_CAPS_LOCK: return XK_Caps_Lock; diff --git a/entry/src/main/cpp/vnc_client.cpp b/entry/src/main/cpp/vnc_client.cpp index f21774d0..8197f091 100644 --- a/entry/src/main/cpp/vnc_client.cpp +++ b/entry/src/main/cpp/vnc_client.cpp @@ -55,6 +55,7 @@ std::mutex VncClient::fbMutex_; VncResizeCallback VncClient::resizeCallback_ = nullptr; VncFrameCallback VncClient::frameCallback_ = nullptr; +VncClipboardCallback VncClient::cutTextCallback_ = nullptr; std::mutex VncClient::socketMutex_; rfbBool VncClient::onResize(rfbClient* cl) { @@ -161,6 +162,7 @@ bool VncClient::connect(const char* address, int port, const char* passwd) { client_->appData.useRemoteCursor = FALSE; client_->GetPassword = VncClient::getPassword; client_->GotFrameBufferUpdate = VncClient::onUpdate; + client_->GotXCutText = VncClient::onCutText; client_->connectTimeout = 5; // readTimeout=0 (infinite): non-zero values cause spurious ReadFromRFBServer // timeouts during ZRLE/zlib decode of large frames, killing the poll thread. @@ -185,6 +187,7 @@ void VncClient::disconnect() { // Clear callbacks first to prevent in-flight calls resizeCallback_ = nullptr; frameCallback_ = nullptr; + cutTextCallback_ = nullptr; if (client_) { connected_.store(false); @@ -223,6 +226,23 @@ void VncClient::sendKeyEvent(uint32_t key, bool down) { } } +void VncClient::sendCutText(const char* text, int len) { + std::lock_guard lock(socketMutex_); + if (checkConnection()) { + SendClientCutText(client_, const_cast(text), len); + } +} + +void VncClient::setClipboardCallback(VncClipboardCallback cb) { + cutTextCallback_ = cb; +} + +void VncClient::onCutText(rfbClient* cl, const char* text, int textlen) { + if (cutTextCallback_) { + cutTextCallback_(text, textlen); + } +} + void VncClient::setResizeCallback(VncResizeCallback cb) { resizeCallback_ = cb; } diff --git a/entry/src/main/ets/components/EditEmulatorContent.ets b/entry/src/main/ets/components/EditEmulatorContent.ets index 73393ea1..d858e3c5 100644 --- a/entry/src/main/ets/components/EditEmulatorContent.ets +++ b/entry/src/main/ets/components/EditEmulatorContent.ets @@ -292,8 +292,16 @@ export default struct EditEmulatorContent { progress.open() if (this.isCreate) { const roots = AppStorage.get(appOption.rootFilesystems) as RootFilesystem[] + const selected = roots.find(it => it.name === this.selectedRootfs) + if (!selected) { + // 所选镜像可能已被其他页面删除(如镜像管理页) + this.promptAction.showToast({ + message: $r('app.string.msg_rootfs_not_found'), + duration: 3000 + }) + return + } const vmBaseDir = AppStorage.get(appOption.vmBaseDir) as string - const selected = roots.find(it => it.name === this.selectedRootfs)!! const source = selected.path const target = vmBaseDir + '/' + this.emulator.id + selected.path.substring(selected.path.lastIndexOf('.')) await copyFile(source, target) diff --git a/entry/src/main/ets/components/EmulatorItem.ets b/entry/src/main/ets/components/EmulatorItem.ets index e14bfa3b..5fcf6dec 100644 --- a/entry/src/main/ets/components/EmulatorItem.ets +++ b/entry/src/main/ets/components/EmulatorItem.ets @@ -21,8 +21,9 @@ export default struct EmulatorItem { aboutToAppear(): void { const rootfsPath = this.emulator.rootFilesystem - const virtualSize = readQcow2VirtualSize(rootfsPath) - this.storageSize = virtualSize / (1024 * 1024 * 1024) + readQcow2VirtualSize(rootfsPath).then((virtualSize: number) => { + this.storageSize = virtualSize / (1024 * 1024 * 1024) + }) } build() { diff --git a/entry/src/main/ets/components/EmulatorListGrid.ets b/entry/src/main/ets/components/EmulatorListGrid.ets index 6b165ff4..4a6bab16 100644 --- a/entry/src/main/ets/components/EmulatorListGrid.ets +++ b/entry/src/main/ets/components/EmulatorListGrid.ets @@ -3,10 +3,12 @@ import { Emulator, RootFilesystem } from '../model/Emulator'; import { RepeatGrid } from './RepeatGrid'; import { util } from '@kit.ArkTS'; import appOption, { savePreference } from '../model/appOption'; +import { createAutoSnapshot } from '../lib/autoSnapshot'; import { CustomContentDialog, LoadingDialog } from '@kit.ArkUI'; import copyFile from '../lib/copyFile'; import napi from 'libentry.so'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { backgroundRunningManager } from '../lib/backgroundRunningManager'; import { Want, bundleManager } from '@kit.AbilityKit'; import EmulatorItem from './EmulatorItem'; import ImageManagementContent from './ImageManagementContent'; @@ -232,7 +234,7 @@ export default struct EmulatorListGrid { askForStartEmulator(item: Emulator) { this.getUIContext().showAlertDialog({ title: item.name, - message: '将重启HiSH并启动“' + item.name + "”,建议保存好当前模拟器中的数据再操作。是否现在启动?", + message: util.format(this.getUIContext().getHostContext()?.resourceManager.getStringByNameSync('restart_emulator_prompt'), item.name), autoCancel: true, alignment: DialogAlignment.Center, buttons: [ @@ -268,12 +270,28 @@ export default struct EmulatorListGrid { applicationContext.restartApp(want); } - async gracefulShutdownAndWait() :Promise { - return new Promise((resolve)=> { + async gracefulShutdownAndWait(): Promise { + // [修复] 带超时的优雅关机,并在 VM 意外退出时执行 auto-snapshot + const SHUTDOWN_TIMEOUT_MS = 30000 + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + hilog.warn(DOMAIN, 'EmulatorListGrid', 'Shutdown timeout after %{public}dms', SHUTDOWN_TIMEOUT_MS) + reject(new Error('Shutdown timeout')) + }, SHUTDOWN_TIMEOUT_MS) + napi.onShutdown(() => { + clearTimeout(timeout) AppStorage.set(appOption.currentRunningEmulator, undefined) + // 释放 wake lock + backgroundRunningManager.releaseWakeLockOnly() + + // 如果 auto-snapshot 开启,在关机回调中补做快照 + createAutoSnapshot() resolve() }) + const cmd = '\x1apoweroff\n' napi.sendInput(stringToArrayBuffer(cmd)) }) @@ -400,10 +418,6 @@ export default struct EmulatorListGrid { } onEmulatorsChange() { - hilog.info(1, "EmulatorManagement", "onEmulatorsChange: %{public}s", JSON.stringify(this.emulators)) - this.displayEmulators.splice(0, this.displayEmulators.length) - setTimeout(() => { - this.emulators.forEach(it => this.displayEmulators.push(it)) - }, 20) + this.displayEmulators = [...this.emulators] } } diff --git a/entry/src/main/ets/components/ImageManagementContent.ets b/entry/src/main/ets/components/ImageManagementContent.ets index 36bd3b4f..21d22d52 100644 --- a/entry/src/main/ets/components/ImageManagementContent.ets +++ b/entry/src/main/ets/components/ImageManagementContent.ets @@ -654,6 +654,7 @@ export default struct ImageManagementContent { const modeNames: Record = { 'sparse': $r('app.string.optimize_mode_sparse'), 'prealloc': $r('app.string.optimize_mode_prealloc'), + 'cleanup': $r('app.string.btn_force_cleanup'), 'optimize': $r('app.string.optimize_mode_optimize') } const tempPath = `${basicInfo.path}.tmp` @@ -1074,7 +1075,7 @@ export default struct ImageManagementContent { .fontSize(12) .fontColor($r('sys.color.ohos_id_color_text_secondary')) .width(80) - Text(`${((this.basicInfo.diskSize / this.basicInfo.virtualSize) * 100).toFixed(1)}%`) + Text(`${this.basicInfo.virtualSize > 0 ? ((this.basicInfo.diskSize / this.basicInfo.virtualSize) * 100).toFixed(1) : '0.0'}%`) .fontSize(12) .fontColor($r('sys.color.ohos_id_color_text_primary')) .layoutWeight(1) diff --git a/entry/src/main/ets/components/QemuMonitor.ets b/entry/src/main/ets/components/QemuMonitor.ets deleted file mode 100644 index 6cf46ed1..00000000 --- a/entry/src/main/ets/components/QemuMonitor.ets +++ /dev/null @@ -1,45 +0,0 @@ -import { socket } from "@kit.NetworkKit" -import { hilog } from "@kit.PerformanceAnalysisKit" -import { util } from "@kit.ArkTS" - -@Component -export default struct QemuMonitor { - tempDir = this.getUIContext().getHostContext()!!.getApplicationContext().tempDir - localSocket?: socket.LocalSocket = undefined - utf8Decoder = util.TextDecoder.create('utf-8', { ignoreBOM: true }) - - async aboutToAppear(): Promise { - const socketPath = this.tempDir + '/qmp_socket' - const local = socket.constructLocalSocketInstance() - try { - await local.connect({ address: { address: socketPath }, timeout: 2000 }) - this.localSocket = local - local.on('message', (info) => { - const trunk = this.utf8Decoder.decodeToString(new Uint8Array(info.message)) - hilog.info(1, "QemuMonitor", 'info from qmp socket, %{public}s', trunk) - }) - await local.send({ data: '{ "execute": "qmp_capabilities" }\n' }) - await local.send({ data: '{ "execute": "query-status" }\n' }) - await local.send({ data: '{ "execute": "query-commands" }\n' }) - setInterval(async () => { - await local.send({ data: '{ "execute": "query-cpus-fast" }\n' }) - await local.send({ data: '{ "execute": "query-memory-size-summary" }\n' }) - }, 2000) - } catch (e) { - hilog.error(1, "QemuMonitor", "qmp socket failed, %{public}s", e) - } - } - - async aboutToDisappear(): Promise { - try { - await this.localSocket?.close() - } catch (e) { - hilog.error(1, "QemuMonitor", "close qmp socket failed, %{public}s", e) - } - } - - build() { - Column() { - } - } -} \ No newline at end of file diff --git a/entry/src/main/ets/components/RootfsManagementContent.ets b/entry/src/main/ets/components/RootfsManagementContent.ets index fd8b2d80..1df3bc42 100644 --- a/entry/src/main/ets/components/RootfsManagementContent.ets +++ b/entry/src/main/ets/components/RootfsManagementContent.ets @@ -472,13 +472,13 @@ export default struct RootfsManagementContent { syncRootfsViewItems() { this.rootfsItems.splice(0, this.rootfsItems.length) - setTimeout(() => { + setTimeout(async () => { for (let i = 0; i < this.roots.length; i++) { const it = this.roots[i] const path = it.path const stat = fs.statSync(path) const sizeString = (stat.size / (1024 * 1024)).toFixed(1) + ' MB' - const virtualSize = readQcow2VirtualSize(path) + const virtualSize = await readQcow2VirtualSize(path) let virtualSizeString: string; if (virtualSize === undefined) { virtualSizeString = '-' diff --git a/entry/src/main/ets/components/SettingsContent.ets b/entry/src/main/ets/components/SettingsContent.ets index 7daaaeda..27abaa3e 100644 --- a/entry/src/main/ets/components/SettingsContent.ets +++ b/entry/src/main/ets/components/SettingsContent.ets @@ -31,6 +31,30 @@ const fontSizeOptions: SelectOption[] = [{ const cursorShapeOptions: SelectOption[] = [{ value: 'BLOCK' }, { value: 'BEAM' }, { value: 'UNDERLINE' }] +const themeOptions: SelectOption[] = [ + { value: 'Dark (Default)' }, + { value: 'Dracula' }, + { value: 'Solarized Dark' }, + { value: 'Solarized Light' }, + { value: 'Monokai' }, + { value: 'Nord' }, + { value: 'Gruvbox Dark' }, + { value: '自定义 (Custom)' } +] + +const fontOptions: SelectOption[] = [ + { value: 'CodeNewRoman NF Mono' }, + { value: 'Source Code Pro' }, + { value: 'Monospace' }, + { value: '自定义 (Custom)' } +] + +const profileOptions: SelectOption[] = [ + { value: '均衡 (Balanced)' }, + { value: '省电 (Power Saving)' }, + { value: '性能 (Performance)' } +] + const vncPortModeOptions: SelectOption[] = [ { value: $r('app.string.setting_vnc_port_mode_local') }, { value: $r('app.string.setting_vnc_port_mode_lan') } @@ -43,10 +67,16 @@ export default struct SettingsContent { @Watch('saveTermCursorShape') @StorageProp(appOption.terminalCursorShape) termCursorShape: CursorShape = 'BLOCK' @Watch('saveTermCursorBlink') @StorageProp(appOption.terminalCursorBlink) termCursorBlink: boolean = false @Watch('saveRunningInBackground') @StorageProp(appOption.runningInBackground) runningInBackground: boolean = false + @Watch('saveKeepAwake') @StorageProp(appOption.keepAwake) keepAwake: boolean = true @Watch('saveDisplayStatusBar') @StorageProp(appOption.displayStatusBar) displayStatusBar: boolean = false @Watch('saveVncEnabled') @StorageProp(appOption.vncEnabled) vncEnabled: boolean = true @Watch('saveVncPortMode') @StorageProp(appOption.vncPortMode) vncPortMode: string = 'local' @Watch('saveVncPort') @StorageProp(appOption.vncPort) vncPort: number = 5900 + @Watch('saveTerminalTheme') @StorageProp(appOption.terminalTheme) terminalTheme: string = 'dark' + @Watch('saveTerminalFont') @StorageProp(appOption.terminalFont) terminalFont: string = 'CodeNewRoman NF Mono' + @Watch('saveAutoSnapshot') @StorageProp(appOption.autoSnapshotOnExit) autoSnapshotOnExit: boolean = false + @Watch('savePerformanceProfile') @StorageProp(appOption.performanceProfile) performanceProfile: string = 'balanced' + @Watch('saveShowLauncher') @StorageProp(appOption.showLauncher) showLauncher: boolean = true @StorageProp('bundleInfo') bundleInfo?: bundleManager.BundleInfo = undefined @StorageProp('versionCode') versionCode: number = 0 openSourceController: CustomDialogController = new CustomDialogController({ @@ -82,6 +112,21 @@ export default struct SettingsContent { context = this.getUIContext().getHostContext() as common.UIAbilityContext preferences?: preference.Preferences blockStyle: SettingBlockStyle = new SettingBlockStyle() + /** 自定义主题编辑器状态 */ + @State customBg: string = '#1a1a2e' + @State customFg: string = '#ffffff' + @State customCursor: string = '#ffffff' + @State customBlack: string = '#1a1a2e' + @State customRed: string = '#e06c75' + @State customGreen: string = '#98c379' + @State customYellow: string = '#e5c07b' + @State customBlue: string = '#61afef' + @State customMagenta: string = '#c678dd' + @State customCyan: string = '#56b6c2' + @State customWhite: string = '#abb2bf' + @State customSelectionBg: string = '#ffffff44' + /** 自定义字体输入 */ + @State customFontFamily: string = '' async saveKeepScreenOn() { await savePreference(appOption.keepScreenOn, this.keepScreenOn, this.getUIContext()) @@ -127,6 +172,15 @@ export default struct SettingsContent { }) } + @Builder + private setKeepAwake() { + Toggle({ type: ToggleType.Switch, isOn: this.keepAwake }) + .selectedColor('#4FC3F7') + .onChange((isOn: boolean) => { + AppStorage.setOrCreate(appOption.keepAwake, isOn) + }) + } + @Builder private setDisplayStatusBar() { Checkbox() @@ -179,6 +233,296 @@ export default struct SettingsContent { .onSelect((_i, value) => AppStorage.setOrCreate(appOption.terminalCursorShape, value)) } + // Map display names to internal theme IDs + private themeIdMap: Record = { + 'Dark (Default)': 'dark', + 'Dracula': 'dracula', + 'Solarized Dark': 'solarized-dark', + 'Solarized Light': 'solarized-light', + 'Monokai': 'monokai', + 'Nord': 'nord', + 'Gruvbox Dark': 'gruvbox-dark', + '自定义 (Custom)': 'custom' + } + + /** 判断当前主题是否为自定义模式 */ + private isThemeCustom(): boolean { + return this.terminalTheme !== 'dark' && + this.terminalTheme !== 'dracula' && + this.terminalTheme !== 'solarized-dark' && + this.terminalTheme !== 'solarized-light' && + this.terminalTheme !== 'monokai' && + this.terminalTheme !== 'nord' && + this.terminalTheme !== 'gruvbox-dark' + } + + @Builder + private buildTerminalTheme() { + Select(themeOptions) + .width(160) + .value(this.isThemeCustom() ? '自定义 (Custom)' : + this.terminalTheme === 'dark' ? 'Dark (Default)' : + this.terminalTheme === 'dracula' ? 'Dracula' : + this.terminalTheme === 'solarized-dark' ? 'Solarized Dark' : + this.terminalTheme === 'solarized-light' ? 'Solarized Light' : + this.terminalTheme === 'monokai' ? 'Monokai' : + this.terminalTheme === 'nord' ? 'Nord' : + this.terminalTheme === 'gruvbox-dark' ? 'Gruvbox Dark' : 'Dark (Default)') + .onSelect((_i, value) => { + const themeId = this.themeIdMap[value] || 'dark' + if (themeId === 'custom') { + this.terminalTheme = 'custom' + // 尝试从现有 JSON 解析已保存的自定义配色 + this.loadCustomThemeFromStored() + } else { + AppStorage.setOrCreate(appOption.terminalTheme, themeId) + } + }) + } + + /** + * 尝试从当前 terminalTheme 值解析自定义主题 JSON + * 若 terminalTheme 是合法 JSON 则填充编辑器字段,否则用默认 dark 配色 + */ + private loadCustomThemeFromStored() { + try { + const stored = this.terminalTheme as string + if (stored.startsWith('{')) { + const parsed: Record = JSON.parse(stored) as Record + this.customBg = parsed['background'] || '#1a1a2e' + this.customFg = parsed['foreground'] || '#ffffff' + this.customCursor = parsed['cursor'] || '#ffffff' + this.customBlack = parsed['black'] || '#1a1a2e' + this.customRed = parsed['red'] || '#e06c75' + this.customGreen = parsed['green'] || '#98c379' + this.customYellow = parsed['yellow'] || '#e5c07b' + this.customBlue = parsed['blue'] || '#61afef' + this.customMagenta = parsed['magenta'] || '#c678dd' + this.customCyan = parsed['cyan'] || '#56b6c2' + this.customWhite = parsed['white'] || '#abb2bf' + this.customSelectionBg = parsed['selectionBackground'] || '#ffffff44' + return + } + } catch (_e) { + // JSON 解析失败,使用默认值 + } + // 回退:用 dark 主题默认值 + this.customBg = '#1a1a2e'; this.customFg = '#ffffff'; this.customCursor = '#ffffff' + this.customBlack = '#1a1a2e'; this.customRed = '#e06c75'; this.customGreen = '#98c379' + this.customYellow = '#e5c07b'; this.customBlue = '#61afef'; this.customMagenta = '#c678dd' + this.customCyan = '#56b6c2'; this.customWhite = '#abb2bf'; this.customSelectionBg = '#ffffff44' + } + + /** + * 自定义主题编辑器 —— 11 个配色字段 + 实时预览 + */ + @Builder + private buildCustomThemeEditor() { + Column() { + Text('自定义配色方案') + .fontSize(13).fontColor('#888888').padding({ bottom: 8 }) + + // 基础色:背景 / 前景 / 光标 / 选区 + Row() { this.colorFieldRow('背景', this.customBg, (v: string) => { this.customBg = v }) } + Row() { this.colorFieldRow('前景', this.customFg, (v: string) => { this.customFg = v }) } + Row() { this.colorFieldRow('光标', this.customCursor, (v: string) => { this.customCursor = v }) } + Row() { this.colorFieldRow('选区', this.customSelectionBg, (v: string) => { this.customSelectionBg = v }) } + + // ANSI 16 色(基础 8 色) + Divider().margin({ top: 8, bottom: 8 }) + Row() { this.colorFieldRow('黑', this.customBlack, (v: string) => { this.customBlack = v }) } + Row() { this.colorFieldRow('红', this.customRed, (v: string) => { this.customRed = v }) } + Row() { this.colorFieldRow('绿', this.customGreen, (v: string) => { this.customGreen = v }) } + Row() { this.colorFieldRow('黄', this.customYellow, (v: string) => { this.customYellow = v }) } + Row() { this.colorFieldRow('蓝', this.customBlue, (v: string) => { this.customBlue = v }) } + Row() { this.colorFieldRow('紫', this.customMagenta, (v: string) => { this.customMagenta = v }) } + Row() { this.colorFieldRow('青', this.customCyan, (v: string) => { this.customCyan = v }) } + Row() { this.colorFieldRow('白', this.customWhite, (v: string) => { this.customWhite = v }) } + + // 预览区 + Divider().margin({ top: 8, bottom: 8 }) + Text('预览') + .fontSize(12).fontColor('#888888').padding({ bottom: 6 }) + .alignSelf(ItemAlign.Start) + Column() { + Text('\\x1b[30mBlack \\x1b[31mRed \\x1b[32mGreen \\x1b[33mYellow') + .fontSize(12).fontFamily('monospace') + Text('\\x1b[34mBlue \\x1b[35mMagenta \\x1b[36mCyan \\x1b[37mWhite') + .fontSize(12).fontFamily('monospace').margin({ top: 4 }) + } + .width('100%').padding(10) + .backgroundColor('#0d0d1a').borderRadius(6) + + // 应用按钮 + Button('应用自定义主题') + .width('100%').margin({ top: 12 }) + .onClick(() => { + this.applyCustomTheme() + }) + + // 重置为预设 + Button('重置为 Dark 默认') + .width('100%').margin({ top: 6 }) + .backgroundColor('#333333') + .onClick(() => { + AppStorage.setOrCreate(appOption.terminalTheme, 'dark') + }) + } + .width('100%') + .padding(12) + .backgroundColor('#111122') + .borderRadius(8) + .margin({ top: 6 }) + } + + @Builder + private colorFieldRow(label: string, color: string, onChange: (v: string) => void) { + Text(label) + .fontSize(12).fontColor('#bbbbbb').width(30) + Circle() + .width(14).height(14) + .fill(color) + .stroke('#444444').strokeWidth(1) + .margin({ left: 4, right: 6 }) + TextInput({ placeholder: '#RRGGBB', text: color }) + .fontSize(11).fontFamily('monospace') + .width(90).height(28) + .backgroundColor('#222233') + .borderRadius(4) + .onChange((value: string) => { + onChange(value) + }) + } + + /** + * 将编辑器中的颜色值序列化为主题 JSON,写入 AppStorage + */ + private applyCustomTheme() { + const themeObj: Record = { + 'background': this.customBg, + 'foreground': this.customFg, + 'cursor': this.customCursor, + 'cursorAccent': '#1a1a2e', + 'selectionBackground': this.customSelectionBg, + 'black': this.customBlack, 'red': this.customRed, 'green': this.customGreen, + 'yellow': this.customYellow, 'blue': this.customBlue, 'magenta': this.customMagenta, + 'cyan': this.customCyan, 'white': this.customWhite, + 'brightBlack': '#5c6370', 'brightRed': this.customRed, 'brightGreen': this.customGreen, + 'brightYellow': this.customYellow, 'brightBlue': this.customBlue, + 'brightMagenta': this.customMagenta, 'brightCyan': this.customCyan, 'brightWhite': '#ffffff' + } + const json = JSON.stringify(themeObj) + AppStorage.setOrCreate(appOption.terminalTheme, json) + } + + /** 判断当前字体是否为预设之一 */ + private isFontCustom(): boolean { + const presets = ['CodeNewRoman NF Mono', 'Source Code Pro', 'Monospace'] + return presets.indexOf(this.terminalFont) < 0 + } + + @Builder + private buildTerminalFont() { + Select(fontOptions) + .width(160) + .value(this.isFontCustom() ? '自定义 (Custom)' : this.terminalFont) + .onSelect((_i, value) => { + if (value === '自定义 (Custom)') { + this.customFontFamily = this.isFontCustom() ? this.terminalFont : '' + } else { + AppStorage.setOrCreate(appOption.terminalFont, value) + } + }) + } + + @Builder + private buildCustomFontEditor() { + Column() { + Text('自定义字体') + .fontSize(13).fontColor('#888888').padding({ bottom: 8 }) + + Text('输入任意 CSS font-family 值,如 "Iosevka Term", monospace') + .fontSize(11).fontColor('#666666').padding({ bottom: 8 }) + + Row() { + TextInput({ placeholder: '例: "Iosevka", monospace', text: this.customFontFamily }) + .fontSize(12).fontFamily('monospace') + .layoutWeight(1).height(32) + .backgroundColor('#222233') + .borderRadius(4) + .onChange((value: string) => { + this.customFontFamily = value + }) + } + .width('100%') + + // 预览 + if (this.customFontFamily.length > 0) { + Column() { + Text('ABCDEFGHIJKLMNOPQRSTUVWXYZ') + .fontSize(14).fontFamily(this.customFontFamily).fontColor('#ffffff') + Text('abcdefghijklmnopqrstuvwxyz') + .fontSize(14).fontFamily(this.customFontFamily).fontColor('#aaaaaa').margin({ top: 4 }) + Text('0123456789 !@#$%^&*()') + .fontSize(14).fontFamily(this.customFontFamily).fontColor('#888888').margin({ top: 4 }) + } + .width('100%').padding(10) + .backgroundColor('#0d0d1a').borderRadius(6) + .margin({ top: 8, bottom: 8 }) + } + + Button('应用自定义字体') + .width('100%') + .onClick(() => { + if (this.customFontFamily.length > 0) { + AppStorage.setOrCreate(appOption.terminalFont, this.customFontFamily) + } + }) + + Button('重置为默认字体') + .width('100%').margin({ top: 6 }) + .backgroundColor('#333333') + .onClick(() => { + AppStorage.setOrCreate(appOption.terminalFont, 'CodeNewRoman NF Mono') + }) + } + .width('100%') + .padding(12) + .backgroundColor('#111122') + .borderRadius(8) + .margin({ top: 6 }) + } + + @Builder + private buildPerformanceProfile() { + Select(profileOptions) + .width(140) + .value(this.performanceProfile === 'balanced' ? '均衡 (Balanced)' : + this.performanceProfile === 'powersave' ? '省电 (Power Saving)' : '性能 (Performance)') + .onSelect((_i, _value) => { + const profile = _i === 0 ? 'balanced' : _i === 1 ? 'powersave' : 'performance' + AppStorage.setOrCreate(appOption.performanceProfile, profile) + }) + } + + @Builder + private buildAutoSnapshot() { + Checkbox() + .select(this.autoSnapshotOnExit) + .onChange((value: boolean) => { + AppStorage.setOrCreate(appOption.autoSnapshotOnExit, value) + }) + } + + @Builder + private buildShowLauncherToggle() { + Toggle({ type: ToggleType.Switch, isOn: this.showLauncher }) + .selectedColor('#4FC3F7') + .onChange((isOn: boolean) => { + AppStorage.setOrCreate(appOption.showLauncher, isOn) + }) + } + build() { Column() { Scroll() { @@ -225,6 +569,30 @@ export default struct SettingsContent { }).onClick(() => { AppStorage.setOrCreate(appOption.terminalCursorBlink, !this.termCursorBlink) }) + + SettingItem({ + title: '主题配色', + subTitle: '选择终端配色方案', + content: () => { + this.buildTerminalTheme() + } + }) + + if (this.isThemeCustom()) { + this.buildCustomThemeEditor() + } + + SettingItem({ + title: '字体', + subTitle: '选择终端字体(需重启生效)', + content: () => { + this.buildTerminalFont() + } + }) + + if (this.isFontCustom()) { + this.buildCustomFontEditor() + } } .attributeModifier(this.blockStyle) @@ -267,6 +635,15 @@ export default struct SettingsContent { }).onClick(() => { AppStorage.setOrCreate(appOption.runningInBackground, !this.runningInBackground) }) + SettingItem({ + title: $r('app.string.keep_awake'), + subTitle: $r('app.string.keep_awake_desc'), + content: () => { + this.setKeepAwake() + } + }).onClick(() => { + AppStorage.setOrCreate(appOption.keepAwake, !this.keepAwake) + }) SettingItem({ title: $r('app.string.display_status_bar'), subTitle: $r('app.string.guest_status_bar_desc'), @@ -278,6 +655,30 @@ export default struct SettingsContent { AppStorage.setOrCreate(appOption.displayStatusBar, value) this.showDisplayStatusBarToast(value) }) + + SettingItem({ + title: '性能预设', + subTitle: '均衡/省电/性能,重启 VM 生效', + content: () => { + this.buildPerformanceProfile() + } + }) + + SettingItem({ + title: '退出时自动快照', + subTitle: '退出 VM 前自动保存快照,下次恢复状态', + content: () => { + this.buildAutoSnapshot() + } + }) + + SettingItem({ + title: '启动时显示固件选择', + subTitle: '关闭后直接启动上次使用的模拟器', + content: () => { + this.buildShowLauncherToggle() + } + }) } .attributeModifier(this.blockStyle) @@ -408,7 +809,7 @@ export default struct SettingsContent { async saveRunningInBackground() { const context = this.getUIContext().getHostContext() as common.UIAbilityContext; if (this.runningInBackground) { - const success = await backgroundRunningManager.grantLocationPermission(context) + const success = await backgroundRunningManager.startBackground(context) if (!success) { this.getUIContext().getPromptAction().showToast({ message: $r('app.string.failed_to_force_background'), @@ -419,6 +820,10 @@ export default struct SettingsContent { await savePreference(appOption.runningInBackground, this.runningInBackground, this.getUIContext()) } + async saveKeepAwake() { + await savePreference(appOption.keepAwake, this.keepAwake, this.getUIContext()) + } + async saveDisplayStatusBar() { await savePreference(appOption.displayStatusBar, this.displayStatusBar, this.getUIContext()) } @@ -452,4 +857,24 @@ export default struct SettingsContent { await savePreference(appOption.vncPort, this.vncPort, this.getUIContext()) this.showVncRestartToast() } + + async saveTerminalTheme() { + await savePreference(appOption.terminalTheme, this.terminalTheme, this.getUIContext()) + } + + async saveTerminalFont() { + await savePreference(appOption.terminalFont, this.terminalFont, this.getUIContext()) + } + + async saveAutoSnapshot() { + await savePreference(appOption.autoSnapshotOnExit, this.autoSnapshotOnExit, this.getUIContext()) + } + + async savePerformanceProfile() { + await savePreference(appOption.performanceProfile, this.performanceProfile, this.getUIContext()) + } + + async saveShowLauncher() { + await savePreference(appOption.showLauncher, this.showLauncher, this.getUIContext()) + } } diff --git a/entry/src/main/ets/components/SharedFolderContent.ets b/entry/src/main/ets/components/SharedFolderContent.ets index 534f44a5..118c0c2e 100644 --- a/entry/src/main/ets/components/SharedFolderContent.ets +++ b/entry/src/main/ets/components/SharedFolderContent.ets @@ -288,7 +288,7 @@ export struct SharedFolderContent { await deleteDirectoryRecursive(hostPath) } } catch (e) { - hilog.info(1, "SharedFolderContent", "failed to delete %{public}s", hostPath) + hilog.info(DOMAIN, "SharedFolderContent", "failed to delete %{public}s", hostPath) } finally { this.deletingProgress.close() this.refreshFileList() @@ -576,7 +576,9 @@ export struct SharedFolderContent { } const srcUri = uris[0] - const destPath = [...this.current, decodeURI(srcUri.split('/').reverse()[0])].join('/') + // 使用 FileUri API 安全提取文件名,避免 query 参数等干扰 + const fileName = new fileUri.FileUri(srcUri).name + const destPath = [...this.current, decodeURI(fileName)].join('/') this.importProgress.open() try { diff --git a/entry/src/main/ets/components/SnippetsPanel.ets b/entry/src/main/ets/components/SnippetsPanel.ets new file mode 100644 index 00000000..a33edbff --- /dev/null +++ b/entry/src/main/ets/components/SnippetsPanel.ets @@ -0,0 +1,309 @@ +import { Snippet, DEFAULT_SNIPPETS } from '../model/Emulator' + +/** + * Snippets 命令面板 + * 半屏弹出面板,显示常用命令,点击发送到终端 + * 支持编辑模式:添加/编辑/删除命令 + */ +@Component +export struct SnippetsPanel { + @Prop snippets: Snippet[] = [] + @Prop showPanel: boolean = false + @State searchText: string = '' + @State isEditMode: boolean = false + @State editingSnippet: Snippet | null = null + @State showEditDialog: boolean = false + @State editLabel: string = '' + @State editCommand: string = '' + onSend: (command: string) => void = () => {} + onDismiss: () => void = () => {} + onToggleStar: (id: string) => void = () => {} + onAdd: (label: string, command: string) => void = () => {} + onEdit: (id: string, label: string, command: string) => void = () => {} + onDelete: (id: string) => void = () => {} + + getFilteredSnippets(): Snippet[] { + let list = this.snippets + .filter(s => !this.searchText || + s.label.toLowerCase().includes(this.searchText.toLowerCase()) || + s.command.toLowerCase().includes(this.searchText.toLowerCase())) + list.sort((a, b) => { + if (a.starred !== b.starred) return a.starred ? -1 : 1 + return a.sortOrder - b.sortOrder + }) + return list + } + + openAddDialog(): void { + this.editingSnippet = null + this.editLabel = '' + this.editCommand = '' + this.showEditDialog = true + } + + openEditDialog(snippet: Snippet): void { + this.editingSnippet = snippet + this.editLabel = snippet.label + this.editCommand = snippet.command.replace(/\n$/, '') + this.showEditDialog = true + } + + saveEdit(): void { + if (!this.editLabel.trim() || !this.editCommand.trim()) return + if (this.editingSnippet) { + this.onEdit(this.editingSnippet.id, this.editLabel.trim(), this.editCommand.trim() + '\n') + } else { + this.onAdd(this.editLabel.trim(), this.editCommand.trim() + '\n') + } + this.showEditDialog = false + } + + build() { + if (this.showPanel) { + Stack({ alignContent: Alignment.Center }) { + Column() { + // 下拉把手 + Row() { + Column() + .width(40) + .height(4) + .borderRadius(2) + .backgroundColor('#555555') + } + .width('100%') + .height(20) + .justifyContent(FlexAlign.Center) + .gesture( + PanGesture({ direction: PanDirection.Down }) + .onActionEnd(() => { + this.searchText = '' + this.isEditMode = false + this.onDismiss() + }) + ) + + // 标题栏 + Row() { + Text(this.isEditMode ? '📋 编辑命令' : '📋 命令面板') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor('#ffffff') + Blank() + // 编辑/完成按钮 + Button(this.isEditMode ? '完成' : '编辑') + .fontSize(13) + .fontColor(this.isEditMode ? '#4FC3F7' : '#aaaaaa') + .backgroundColor('#333333') + .height(30) + .padding({ left: 12, right: 12 }) + .borderRadius(15) + .onClick(() => { + this.isEditMode = !this.isEditMode + }) + // 关闭按钮 + Button('✕') + .fontSize(18) + .fontColor('#aaaaaa') + .backgroundColor('#333333') + .width(34) + .height(34) + .borderRadius(17) + .margin({ left: 8 }) + .onClick(() => { + this.searchText = '' + this.isEditMode = false + this.onDismiss() + }) + } + .width('100%') + .padding({ left: 16, right: 12, top: 4, bottom: 8 }) + + // 搜索框 + Row() { + Text('🔍') + .fontSize(14) + .margin({ right: 8 }) + TextInput({ placeholder: '搜索命令...' }) + .layoutWeight(1) + .fontSize(14) + .fontColor('#ffffff') + .placeholderColor('#666666') + .backgroundColor(Color.Transparent) + .onChange((value: string) => { + this.searchText = value + }) + } + .width('100%') + .padding({ left: 16, right: 16 }) + .backgroundColor('#1a1a2e') + .borderRadius(8) + .margin({ left: 16, right: 16, bottom: 8 }) + + // 命令列表 + List() { + ForEach(this.getFilteredSnippets(), (item: Snippet) => { + ListItem() { + Row() { + if (this.isEditMode) { + // 编辑模式:删除按钮 + Button('删除') + .fontSize(12) + .fontColor('#ff5555') + .backgroundColor('#331111') + .height(28) + .padding({ left: 10, right: 10 }) + .borderRadius(14) + .onClick(() => { + this.onDelete(item.id) + }) + .margin({ right: 8 }) + } else { + // 正常模式:收藏图标 + Text(item.starred ? '⭐' : '☆') + .fontSize(16) + .width(28) + .onClick(() => { + this.onToggleStar(item.id) + }) + } + + // 命令内容 + Column() { + Text(item.label) + .fontSize(14) + .fontColor('#ffffff') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(item.command.replace(/\n/g, '')) + .fontSize(11) + .fontColor('#888888') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 2 }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .margin({ left: 8 }) + + // 编辑模式:编辑按钮 + if (this.isEditMode) { + Button('编辑') + .fontSize(12) + .fontColor('#4FC3F7') + .backgroundColor('#112233') + .height(28) + .padding({ left: 10, right: 10 }) + .borderRadius(14) + .onClick(() => { + this.openEditDialog(item) + }) + } + } + .width('100%') + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .onClick(() => { + if (this.isEditMode) { + this.openEditDialog(item) + } else { + this.onSend(item.command) + this.searchText = '' + this.onDismiss() + } + }) + } + }, (item: Snippet) => item.id) + } + .width('100%') + .layoutWeight(1) + .divider({ strokeWidth: 0.5, color: '#333333', startMargin: 48, endMargin: 12 }) + + // 底部添加按钮 + Button('+ 添加命令') + .fontSize(14) + .fontColor('#4FC3F7') + .backgroundColor('#112233') + .width('90%') + .height(38) + .borderRadius(19) + .margin({ top: 8, bottom: 12 }) + .onClick(() => { + this.openAddDialog() + }) + } + .width('100%') + .height('50%') + .backgroundColor('#0d0d1a') + .borderRadius({ topLeft: 16, topRight: 16 }) + .shadow({ radius: 16, color: '#00000088', offsetY: -4 }) + .transition(TransitionEffect.translate({ y: '100%' }).combine(TransitionEffect.OPACITY)) + .animation({ duration: 250, curve: Curve.EaseOut }) + + // 编辑对话框 + if (this.showEditDialog) { + Column() { + Text(this.editingSnippet ? '编辑命令' : '添加命令') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor('#ffffff') + .margin({ bottom: 16 }) + + TextInput({ placeholder: '命令名称', text: this.editLabel }) + .fontSize(14) + .fontColor('#ffffff') + .placeholderColor('#666666') + .backgroundColor('#1a1a2e') + .borderRadius(8) + .height(40) + .margin({ bottom: 12 }) + .onChange((value: string) => { + this.editLabel = value + }) + + TextArea({ placeholder: '命令内容(如 apt update)', text: this.editCommand }) + .fontSize(13) + .fontColor('#ffffff') + .placeholderColor('#666666') + .backgroundColor('#1a1a2e') + .borderRadius(8) + .height(80) + .fontFamily('monospace') + .margin({ bottom: 16 }) + .onChange((value: string) => { + this.editCommand = value + }) + + Row() { + Button('取消') + .fontSize(14) + .fontColor('#aaaaaa') + .backgroundColor('#333333') + .height(36) + .layoutWeight(1) + .margin({ right: 8 }) + .onClick(() => { + this.showEditDialog = false + }) + Button('保存') + .fontSize(14) + .fontColor('#ffffff') + .backgroundColor('#4FC3F7') + .height(36) + .layoutWeight(1) + .margin({ left: 8 }) + .enabled(this.editLabel.trim().length > 0 && this.editCommand.trim().length > 0) + .onClick(() => { + this.saveEdit() + }) + } + .width('100%') + } + .width('80%') + .padding(20) + .backgroundColor('#1a1a2e') + .borderRadius(16) + .shadow({ radius: 20, color: '#000000aa', offsetY: 4 }) + } + } + } + } +} diff --git a/entry/src/main/ets/components/VmStatusBar.ets b/entry/src/main/ets/components/VmStatusBar.ets index d9663178..0e2ea944 100644 --- a/entry/src/main/ets/components/VmStatusBar.ets +++ b/entry/src/main/ets/components/VmStatusBar.ets @@ -1,9 +1,11 @@ import { hilog } from '@kit.PerformanceAnalysisKit' import { CustomContentDialog } from '@kit.ArkUI' -import QemuAgent, { CpuStats } from '../lib/QemuAgent' +import QemuAgent, { CpuStats, SystemInfo } from '../lib/QemuAgent' import { QemuAgentManager, AgentPriority } from '../lib/QemuAgentManager' import appOption from '../model/appOption' +import { Emulator } from '../model/Emulator' import { util } from '@kit.ArkTS' +import { pasteboard } from '@kit.BasicServicesKit' const DOMAIN = 0x0000 @@ -132,23 +134,20 @@ export struct VmStatusBar { @State isRefreshing: boolean = false @State isForceReconnecting: boolean = false @Prop reactive?: boolean = undefined - private qgaAgent: QemuAgent | null = null private refreshTimer: number = -1 private isComponentActive: boolean = true private lastCpuStats: CpuStats | null = null aboutToAppear() { this.isComponentActive = true - this.settings.masterSwitch = true this.initAgent() } aboutToDisappear() { this.isComponentActive = false this.stopRefreshTimer() - if (this.qgaAgent) { - this.qgaAgent.disconnect() - } + // [修复] 通过 Manager 释放连接,否则 currentUser 残留会导致其他组件无法 acquire + QemuAgentManager.release('VmStatusBar') } onSettingsChange() { @@ -164,6 +163,31 @@ export struct VmStatusBar { } } + private showSshInfo() { + try { + const emulatorsJson = AppStorage.get(appOption.emulators) + const emulators = JSON.parse(emulatorsJson ?? '[]') as Emulator[] + const runningId = AppStorage.get(appOption.currentRunningEmulator) + const emu = emulators.find((e: Emulator) => e.id === runningId) + // Default SSH port is 2222 + let sshPort = 2222 + if (emu?.portMapping) { + const sshMap = emu.portMapping.find((p) => p.guest === 22) + if (sshMap?.host) sshPort = sshMap.host + } + const cmd = `ssh -p ${sshPort} root@127.0.0.1` + const board = pasteboard.getSystemPasteboard() + const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, cmd) + board.setData(pasteData) + this.getUIContext().getPromptAction().showToast({ + message: `已复制: ${cmd}`, + duration: 2000 + }) + } catch (e) { + hilog.error(DOMAIN, 'VmStatusBar', 'SSH copy failed: %{public}s', JSON.stringify(e)) + } + } + /** * 虚拟机状态变化时自动处理 Agent 连接 * 当状态变为 RUNNING 或 REBOOTING 时,自动尝试连接 Agent @@ -432,9 +456,11 @@ export struct VmStatusBar { } } - // 获取内存信息 (始终获取,以满足核心数据要求) - const memInfo: Record | null = await agent.getMemoryInfo() - if (memInfo) { + // [优化] 一次 shell exec 获取 meminfo + cpu stats + ssh 状态,替代原来三次独立调用 + const sysInfo: SystemInfo | null = await agent.getSystemInfo() + if (sysInfo) { + // 内存信息 (始终处理) + const memInfo = sysInfo.memory this.status.memoryTotal = memInfo['MemTotal'] || 0 const memAvailable = memInfo['MemAvailable'] || 0 if (this.status.memoryTotal > 0) { @@ -449,26 +475,26 @@ export struct VmStatusBar { const swapUsed = this.status.swapTotal - swapFree this.status.swapUsage = Math.round((swapUsed / this.status.swapTotal) * 100) } - } - // 如果启用监控,获取动态数据 - if (this.settings.masterSwitch) { - // 获取 CPU 统计信息并计算使用率 - const cpuStats: CpuStats | null = await agent.getCpuStats() - if (cpuStats) { - if (this.lastCpuStats) { - const diffTotal = cpuStats.total - this.lastCpuStats.total - const diffIdle = cpuStats.idle - this.lastCpuStats.idle - if (diffTotal > 0) { - this.status.cpuUsage = Math.round(((diffTotal - diffIdle) / diffTotal) * 100) + // 如果启用监控,处理 CPU 和 SSH + if (this.settings.masterSwitch) { + // CPU 统计 + const cpuStats = sysInfo.cpuStats + if (cpuStats) { + if (this.lastCpuStats) { + const diffTotal = cpuStats.total - this.lastCpuStats.total + const diffIdle = cpuStats.idle - this.lastCpuStats.idle + if (diffTotal > 0) { + this.status.cpuUsage = Math.round(((diffTotal - diffIdle) / diffTotal) * 100) + } } + const newCpuStats: CpuStats = { total: cpuStats.total, idle: cpuStats.idle } + this.lastCpuStats = newCpuStats } - const newCpuStats: CpuStats = { total: cpuStats.total, idle: cpuStats.idle } - this.lastCpuStats = newCpuStats - } - // 检查 SSH - this.status.sshRunning = await agent.isSshRunning() + // SSH 状态 + this.status.sshRunning = sysInfo.sshRunning + } } // 只有在非外部传入 agent 时才释放 @@ -560,9 +586,17 @@ export struct VmStatusBar { // SSH if (this.settings.showSsh) { - if (this.status.agentConnected) { - Text(this.status.sshRunning ? '🔐' : '🔒') + if (this.status.agentConnected && this.status.sshRunning) { + Text('🔐 SSH') + .fontSize(12) + .fontColor('#4FC3F7') + .onClick(() => { + this.showSshInfo() + }) + } else if (this.status.agentConnected) { + Text('🔒 -') .fontSize(12) + .fontColor('#FFFFFF') } else { Text('🔒 -') .fontSize(12) diff --git a/entry/src/main/ets/components/WebTerminal.ets b/entry/src/main/ets/components/WebTerminal.ets index e425bf43..49de05e8 100644 --- a/entry/src/main/ets/components/WebTerminal.ets +++ b/entry/src/main/ets/components/WebTerminal.ets @@ -8,6 +8,11 @@ import appOption, { CursorShape, savePreference } from '../model/appOption'; import { common } from '@kit.AbilityKit'; import { SymbolGlyphModifier } from '@kit.ArkUI'; import stringToArrayBuffer from '../lib/stringToArrayBuffer'; +import { backgroundRunningManager } from '../lib/backgroundRunningManager'; +import { QemuAgentManager } from '../lib/QemuAgentManager'; +import { createAutoSnapshot } from '../lib/autoSnapshot'; +import { Emulator, Snippet, DEFAULT_SNIPPETS } from '../model/Emulator'; +import { SnippetsPanel } from './SnippetsPanel'; import { applyVirtualKeyModifiers, FunctionKeySequences, @@ -34,12 +39,20 @@ export default struct WebTerminal { @Watch('onFontSizeChanged') @StorageProp(appOption.terminalFontSize) fontSize: number | null = null @Watch('onCursorShapeChanged') @StorageProp(appOption.terminalCursorShape) cursorShape: CursorShape | null = null @Watch('onCursorBlinkChanged') @StorageProp(appOption.terminalCursorBlink) cursorBlink: boolean | null = null + @Watch('onThemeChanged') @StorageProp(appOption.terminalTheme) terminalTheme: string = 'dark' + @Watch('onFontChanged') @StorageProp(appOption.terminalFont) terminalFont: string = 'CodeNewRoman NF Mono' @Watch('saveShowVirtKey') @StorageProp(appOption.showVirtKey) showVirtKey: boolean = true @StorageProp(appOption.vncEnabled) vncEnabled: boolean = true + @State showSnippets: boolean = false + @State snippets: Snippet[] = [] webviewController: WebviewController = new webview.WebviewController() utf8Decoder = util.TextDecoder.create('utf-8', { ignoreBOM: true }) - resizeTimeout?: number = undefined + private lastResizeWidth: number = -1 + private lastResizeHeight: number = -1 + private resizeTimeout: number | undefined = undefined + private vmReady: boolean = false // set to true when first data from VM arrives applicationMode: boolean = false + private destroyed: boolean = false @Builder contextMenu(webController: WebviewController) { @@ -140,6 +153,21 @@ export default struct WebTerminal { .onClick(() => action()) } + @Builder + snippetsButton() { + Button({ buttonStyle: ButtonStyleMode.TEXTUAL }) { + Text('⚡').fontSize(VKeyFontSize) + } + .height(VKeyHeight) + .width(VKeyHeight) + .borderRadius(VKeyRadius) + .margin(VKeyMargin) + .onClick(() => { + this.loadSnippets() + this.showSnippets = !this.showSnippets + }) + } + @Builder settingsButton() { Button({ buttonStyle: ButtonStyleMode.TEXTUAL }) { @@ -197,21 +225,156 @@ export default struct WebTerminal { }) } + // Snippets 相关方法 + private loadSnippets(): void { + try { + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + if (!emulatorId) return + const emulators = this.getEmulators() + if (!emulators) return + const emulator = emulators.find((e: Emulator) => e.id === emulatorId) + if (!emulator) return + // 首次打开,自动填充预设命令 + if (!emulator.snippets || emulator.snippets.length === 0) { + const now = Date.now().toString() + const defaults: Snippet[] = DEFAULT_SNIPPETS.map((s, i): Snippet => ({ + id: now + '_' + i, + label: s.label, + command: s.command, + starred: s.starred, + sortOrder: i + })) + emulator.snippets = defaults + this.saveEmulators(emulators) + } + this.snippets = [...emulator.snippets] + } catch (e) { + hilog.warn(DOMAIN, 'WebTerminal', 'loadSnippets failed: %{public}s', JSON.stringify(e)) + this.snippets = [] + } + } + + private toggleSnippetStar(id: string): void { + try { + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + if (!emulatorId) return + const emulators = this.getEmulators() + if (!emulators) return + const emulator = emulators.find((e: Emulator) => e.id === emulatorId) + if (!emulator?.snippets) return + const snippet = emulator.snippets.find((s: Snippet) => s.id === id) + if (snippet) { + snippet.starred = !snippet.starred + this.saveEmulators(emulators) + this.snippets = [...emulator.snippets] + } + } catch (e) { + hilog.warn(DOMAIN, 'WebTerminal', 'toggleSnippetStar failed: %{public}s', JSON.stringify(e)) + } + } + + private getEmulators(): Emulator[] | null { + const raw: Object | undefined = AppStorage.get(appOption.emulators) + if (!raw) return null + if (Array.isArray(raw)) return raw as Emulator[] + if (typeof raw === 'string') { + try { return JSON.parse(raw as string) as Emulator[] } catch (e) { return null } + } + return null + } + + private saveEmulators(emulators: Emulator[]): void { + AppStorage.setOrCreate(appOption.emulators, JSON.stringify(emulators)) + } + + private addSnippet(label: string, command: string): void { + try { + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + if (!emulatorId) return + const emulators = this.getEmulators() + if (!emulators) return + const emulator = emulators.find((e: Emulator) => e.id === emulatorId) + if (!emulator) return + if (!emulator.snippets) emulator.snippets = [] + const newSnippet: Snippet = { + id: Date.now().toString(), + label: label, + command: command, + starred: false, + sortOrder: emulator.snippets.length + } + emulator.snippets.push(newSnippet) + this.saveEmulators(emulators) + this.snippets = [...emulator.snippets] + } catch (e) { + hilog.warn(DOMAIN, 'WebTerminal', 'addSnippet failed: %{public}s', JSON.stringify(e)) + } + } + + private editSnippet(id: string, label: string, command: string): void { + try { + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + if (!emulatorId) return + const emulators = this.getEmulators() + if (!emulators) return + const emulator = emulators.find((e: Emulator) => e.id === emulatorId) + if (!emulator?.snippets) return + const snippet = emulator.snippets.find((s: Snippet) => s.id === id) + if (snippet) { + snippet.label = label + snippet.command = command + this.saveEmulators(emulators) + this.snippets = [...emulator.snippets] + } + } catch (e) { + hilog.warn(DOMAIN, 'WebTerminal', 'editSnippet failed: %{public}s', JSON.stringify(e)) + } + } + + private deleteSnippet(id: string): void { + try { + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + if (!emulatorId) return + const emulators = this.getEmulators() + if (!emulators) return + const emulator = emulators.find((e: Emulator) => e.id === emulatorId) + if (!emulator?.snippets) return + emulator.snippets = emulator.snippets.filter((s: Snippet) => s.id !== id) + this.saveEmulators(emulators) + this.snippets = [...emulator.snippets] + } catch (e) { + hilog.warn(DOMAIN, 'WebTerminal', 'deleteSnippet failed: %{public}s', JSON.stringify(e)) + } + } + + aboutToDisappear() { + this.destroyed = true + if (this.flushTimeoutID !== undefined) { + clearTimeout(this.flushTimeoutID) + this.flushTimeoutID = undefined + } + if (this.resizeTimeout !== undefined) { + clearTimeout(this.resizeTimeout) + this.resizeTimeout = undefined + } + } + build() { - Column() { - Web({ - src: $rawfile('term/term.html'), - controller: this.webviewController - }) - .javaScriptProxy({ - object: this, - name: 'native', - methodList: ['sendInput', 'resize', 'load', - 'getFontSize', 'getCursorShape', 'getCursorBlink', - 'setApplicationMode'], - controller: this.webviewController, - asyncMethodList: [], - permission: `{ + Stack({ alignContent: Alignment.Bottom }) { + Column() { + Web({ + src: $rawfile('term/term.html'), + controller: this.webviewController + }) + .javaScriptProxy({ + object: this, + name: 'native', + methodList: ['sendInput', 'resize', 'load', + 'getFontSize', 'getCursorShape', 'getCursorBlink', + 'setApplicationMode'], + controller: this.webviewController, + asyncMethodList: [], + permission: `{ "javascriptProxyPermission": { "urlPermissionList": [ { @@ -223,17 +386,45 @@ export default struct WebTerminal { ] } }` - }) - .backgroundColor('#000') - .layoutWeight(1) - .bindContextMenu(this.contextMenu(this.webviewController), ResponseType.RightClick) - if (this.virtKeyEnabled) { - if (this.showVirtKey) { - this.buildVirtualKeys() - } else { - this.buildShowVirtualKeysButton() + }) + .backgroundColor('#000') + .layoutWeight(1) + .bindContextMenu(this.contextMenu(this.webviewController), ResponseType.RightClick) + if (this.virtKeyEnabled) { + if (this.showVirtKey) { + this.buildVirtualKeys() + } else { + this.buildShowVirtualKeysButton() + } } } + .height('100%') + + // Snippets 面板 + if (this.showSnippets) { + SnippetsPanel({ + snippets: this.snippets, + showPanel: this.showSnippets, + onSend: (cmd: string) => { + this.sendInput(cmd) + }, + onDismiss: () => { + this.showSnippets = false + }, + onToggleStar: (id: string) => { + this.toggleSnippetStar(id) + }, + onAdd: (label: string, command: string) => { + this.addSnippet(label, command) + }, + onEdit: (id: string, label: string, command: string) => { + this.editSnippet(id, label, command) + }, + onDelete: (id: string) => { + this.deleteSnippet(id) + } + }) + } } .height('100%') } @@ -292,6 +483,7 @@ export default struct WebTerminal { this.buildVKey('ESC', NormalKeySequences.ESC); this.toggleFnKey('FN', () => this.toggleFn()); this.buildVKey('/', '/', '/'); + this.buildVKey('|', '|', '|'); Row().layoutWeight(1); @@ -308,6 +500,18 @@ export default struct WebTerminal { this.buildVKey('TAB', NormalKeySequences.TAB); this.toggleCtrlKey('CTRL', () => this.toggleCtrl()); this.buildVKey('-', '-', '-'); + Button('COMM', { buttonStyle: ButtonStyleMode.TEXTUAL }) + .height(VKeyHeight) + .width(VKeyWidth) + .borderRadius(VKeyRadius) + .fontSize(VKeyFontSize) + .fontColor($r('sys.color.white')) + .margin({ left: 2, right: 2 }) + .padding({ left: VKeyPadding, right: VKeyPadding }) + .onClick(() => { + this.loadSnippets() + this.showSnippets = !this.showSnippets + }) Row().layoutWeight(1); @@ -383,22 +587,47 @@ export default struct WebTerminal { } } + // QGA 通知 VM 终端尺寸(走 QGA socket,绕过 tty echo) async resize(width: number, height: number): Promise { - hilog.info(DOMAIN, "WebTerminal", 'on resize: %{public}d, %{public}d', width, height) + hilog.info(DOMAIN, "WebTerminal", 'on resize: %{public}d x %{public}d', width, height) + if (width === this.lastResizeWidth && height === this.lastResizeHeight) return + this.lastResizeWidth = width + this.lastResizeHeight = height - if (this.resizeTimeout !== undefined) { - clearTimeout(this.resizeTimeout!!) + if (!this.vmReady) return + + // 通过 QGA 执行 stty 设终端尺寸,走独立 socket 不触发 tty echo + try { + const agent = await QemuAgentManager.acquire('terminal-resize', 0, 2000) + if (agent) { + try { + await agent.execAndGetOutput('/bin/sh', + ['-c', `stty cols ${width} rows ${height} < /dev/ttyAMA0`], 2000) + hilog.info(DOMAIN, 'WebTerminal', 'QGA resize ok: %{public}d x %{public}d', width, height) + } finally { + await QemuAgentManager.release('terminal-resize') + } + } + } catch (_e) { + // QGA 不可用时静默忽略(VM 未启动 guest-agent、boot 中等) } - this.resizeTimeout = setTimeout(() => this.sendInput('\x1b' + `[8;${height};${width}t`), 100) } // 性能优化:输出缓冲区 private pendingChunks: Uint8Array[] = []; private flushTimeoutID: number | undefined = undefined; private readonly FLUSH_INTERVAL = 16; // 60FPS (16.6ms) + private readonly CLEAR_DEBOUNCE_MS = 300; // 清屏 debounce:防止 TUI 高频刷新导致跳屏 + private lastClearScrollbackTime: number = 0; private onData(ab: ArrayBuffer) { + if (this.destroyed) return try { + // VM 首次输出数据,标志已就绪(resize CSI 在用户首次按键时激活) + if (!this.vmReady) { + this.vmReady = true + } + // 将收到的数据块加入队列 this.pendingChunks.push(new Uint8Array(ab)); @@ -444,11 +673,15 @@ export default struct WebTerminal { merged[6] === 0x5C && merged[7] === 0x78 && merged[8] === 0x31 && merged[9] === 0x62 && // \x1b merged[10] === 0x5B && merged[11] === 0x4A // [J ) { - const clearCmd = '\\x1b[3J'; - const encoder = util.TextEncoder.create('utf-8'); - const clearBytes = encoder.encodeInto(clearCmd); - const clearBase64 = new util.Base64Helper().encodeToStringSync(clearBytes); - this.webviewController.runJavaScript(`exports.writeBase64("${clearBase64}", ${this.applicationMode})`); + const now = Date.now() + if (now - this.lastClearScrollbackTime >= this.CLEAR_DEBOUNCE_MS) { + this.lastClearScrollbackTime = now + const clearCmd = '\\x1b[3J' + const encoder = util.TextEncoder.create('utf-8') + const clearBytes = encoder.encodeInto(clearCmd) + const clearBase64 = new util.Base64Helper().encodeToStringSync(clearBytes) + this.webviewController.runJavaScript(`exports.writeBase64("${clearBase64}", ${this.applicationMode})`) + } } // 4. 发送合并后的数据 @@ -464,7 +697,14 @@ export default struct WebTerminal { } private async onShutdown() { + if (this.destroyed) return hilog.info(DOMAIN, 'WebTerminal', 'qemu system exited'); + + // 释放 wake lock,让系统可以正常休眠 + backgroundRunningManager.releaseWakeLockOnly() + + await createAutoSnapshot() + const context = this.getUIContext().getHostContext() as common.UIAbilityContext await context?.terminateSelf() } @@ -486,17 +726,39 @@ export default struct WebTerminal { } onFontSizeChanged() { + if (this.destroyed) return this.webviewController.runJavaScript(`exports.setFontSize(${this.fontSize})`) } onCursorShapeChanged() { + if (this.destroyed) return this.webviewController.runJavaScript(`exports.setCursorShape('${this.cursorShape}')`) } onCursorBlinkChanged() { + if (this.destroyed) return this.webviewController.runJavaScript(`exports.setCursorBlink(${this.cursorBlink})`) } + onThemeChanged() { + if (this.destroyed) return + // 使用 JSON.stringify 确保自定义主题 JSON 被正确转义 + this.webviewController.runJavaScript(`exports.setTheme(${JSON.stringify(this.terminalTheme)})`) + } + + onFontChanged() { + if (this.destroyed) return + this.webviewController.runJavaScript(`exports.setFont(${JSON.stringify(this.terminalFont)}); exports.fit()`) + } + + getTheme(): string { + return this.terminalTheme + } + + getFont(): string { + return this.terminalFont + } + setApplicationMode(mode: boolean) { this.applicationMode = mode hilog.info(DOMAIN, 'WebTerminal', 'set applicationMode to %{public}s', this.applicationMode); diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 767a1187..1525b97b 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,4 @@ -import { AbilityConstant, ConfigurationConstant, UIAbility, Want, bundleManager } from '@kit.AbilityKit'; +import { AbilityConstant, UIAbility, Want, bundleManager } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window, KeyboardAvoidMode, PromptAction, UIContext } from '@kit.ArkUI'; import common from '@ohos.app.ability.common'; @@ -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 { getCpuCount, getMemSize } from '../lib/systemProfile'; const DOMAIN = 0x0000; @@ -43,7 +44,7 @@ function getFreeVncPort(min: number, max: number): number | undefined { export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { - this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK); + // Follow system color mode hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); } @@ -65,12 +66,18 @@ export default class EntryAbility extends UIAbility { await this.extractKernelAndRootFilesystem(appContext); - windowStage.loadContent('pages/Index', async (err) => { + // 检查是否显示启动选择页 + const showLauncher = AppStorage.get(appOption.showLauncher) ?? true + + // 根据 showLauncher 决定加载哪个页面 + const initialPage = showLauncher ? 'pages/LauncherPage' : 'pages/Index' + + windowStage.loadContent(initialPage, async (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } - hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content: %{public}s', initialPage); const mainWindow = windowStage.getMainWindowSync(); const uiContext = mainWindow.getUIContext(); @@ -86,12 +93,15 @@ export default class EntryAbility extends UIAbility { } await this.prepareForSharedFolder(appContext) - const result = this.startDefaultEmulator() - this.promptRootVdaMessage(uiContext) - this.promptPortUsedMessage(uiContext, result.portsSkipped) - this.promptVmStartFailedMessage(uiContext, result.vmStarted) - this.startRunInBackground(uiContext) + // 如果不显示启动选择页,直接启动默认模拟器 + if (!showLauncher) { + const result = this.startDefaultEmulator() + this.promptRootVdaMessage(uiContext) + this.promptPortUsedMessage(uiContext, result.portsSkipped) + this.promptVmStartFailedMessage(uiContext, result.vmStarted) + this.startRunInBackground(uiContext) + } }); } @@ -119,6 +129,21 @@ export default class EntryAbility extends UIAbility { const cpuCount = emulatorToStart.cpu; const memSize = emulatorToStart.memory; + + // Apply performance profile + const profile = AppStorage.get(appOption.performanceProfile) ?? 'balanced' + let adjustedCpu = cpuCount + let adjustedMem = memSize + if (profile === 'powersave') { + adjustedCpu = Math.min(cpuCount, 2) + adjustedMem = Math.min(memSize, 512) + } else if (profile === 'performance') { + adjustedCpu = getCpuCount() + adjustedMem = Math.max(1024, getMemSize() - 1024) + } + hilog.info(DOMAIN, 'testTag', + 'Performance profile: %{public}s, cpu: %{public}d→%{public}d, mem: %{public}d→%{public}dMB', + profile, cpuCount, adjustedCpu, memSize, adjustedMem) const rootVda = emulatorToStart.rootVda; const rootFilesystem = emulatorToStart.rootFilesystem const sharedFolderReadonly = emulatorToStart.sharedFolderReadonly @@ -130,14 +155,22 @@ export default class EntryAbility extends UIAbility { const vmBaseDir = AppStorage.get(appOption.vmBaseDir) as string const kernelFile = AppStorage.get(appOption.kernel) as string - const portUsed = portMappingRaw.map((it): number => it.host!!).filter((p): boolean => napi.checkPortUsed(p)) + // 跳过特权端口 (<1024):bind() 可能触发系统安全策略杀进程导致闪退 + const portUsed = portMappingRaw.map((it): number => it.host!!) + .filter((p): boolean => { + if (p < 1024) { + hilog.warn(DOMAIN, 'testTag', 'host port %{public}d is privileged (<1024), skipped', p) + return true // 视为不可用 + } + return napi.checkPortUsed(p) + }) const portMapping = portMappingRaw.filter((it): boolean => portUsed.indexOf(it.host!!) < 0) hilog.info(DOMAIN, 'testTag', 'host port %{public}s of PortMapping is used by others', portUsed) AppStorage.setOrCreate(appOption.portUsedByOthers, portUsed) hilog.info(DOMAIN, 'testTag', 'startVM with cpu: %{public}d, mem: %{public}dMB, portMapping: %{public}s', - cpuCount, memSize, portMapping) + adjustedCpu, adjustedMem, portMapping) const serialUnixSocket = appTempDir + '/serial_socket' const qmpUnixSocket = appTempDir + '/qmp_socket' @@ -146,14 +179,31 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate(appOption.currentRunningEmulator, emulatorToStart.id) // 读取 VNC 启用配置 - const vncEnabled = AppStorage.get(appOption.vncEnabled)!! + let vncEnabled = AppStorage.get(appOption.vncEnabled) ?? true const vncPortMode = AppStorage.get(appOption.vncPortMode) ?? 'local' - const vncPort = AppStorage.get(appOption.vncPort) ?? 5900 + let vncPort = AppStorage.get(appOption.vncPort) ?? 5900 + + // VNC 端口预检查 — 防止 QEMU 绑端口失败调用 exit() 导致闪退 + if (vncEnabled) { + if (napi.checkPortUsed(vncPort)) { + hilog.warn(DOMAIN, 'testTag', 'VNC port %{public}d is in use, searching for free port', vncPort) + const freePort = getFreeVncPort(5900, 5999) + if (freePort !== undefined) { + vncPort = freePort + AppStorage.setOrCreate(appOption.vncPort, vncPort) + hilog.info(DOMAIN, 'testTag', 'VNC fallback port: %{public}d', vncPort) + } else { + // 无可用端口,降级为串口模式 + vncEnabled = false + hilog.warn(DOMAIN, 'testTag', 'No free VNC port found, disabling VNC (serial-only mode)') + } + } + } const vmStarted = startVm({ baseDir: vmBaseDir, - cpu: cpuCount, - memory: memSize, + cpu: adjustedCpu, + memory: adjustedMem, portMapping: portMapping, kernel: kernelFile, rootVda, @@ -171,6 +221,13 @@ export default class EntryAbility extends UIAbility { if (vmStarted) { AppStorage.setOrCreate(appOption.currentEmulatorStatus, 'RUNNING') + // 用户开启"保持 CPU 唤醒"时申请 wake lock,防止熄屏后 CPU 降频导致 VM RCU stall + const keepAwake = AppStorage.get(appOption.keepAwake) as boolean + if (keepAwake) { + backgroundRunningManager.requestWakeLockForVm().catch((e: Error) => { + hilog.error(DOMAIN, 'testTag', 'requestWakeLockForVm failed: %{public}s', e.message) + }) + } } else { AppStorage.set(appOption.currentRunningEmulator, undefined) AppStorage.setOrCreate(appOption.currentEmulatorStatus, 'ABNORMAL') @@ -181,10 +238,27 @@ export default class EntryAbility extends UIAbility { } private getEmulatorToStart() { + // 优先级:pendingStart(从 LauncherPage 传来)> temporaryStart > defaultStart + const pendingStart: string | undefined = AppStorage.get('pendingStartEmulatorId') as string; + if (pendingStart) { + // 清除 pending 标记 + AppStorage.setOrCreate('pendingStartEmulatorId', ''); + const emulators = AppStorage.get(appOption.emulators) as Emulator[]; + const pending = emulators.find(it => it.id === pendingStart); + if (pending) { + hilog.info(DOMAIN, 'testTag', 'Starting emulator from LauncherPage: %{public}s', pendingStart); + return pending; + } + } const temporaryStart: string | undefined = AppStorage.get(appOption.temporaryStartEmulator) as string; const defaultStart: string | undefined = temporaryStart || AppStorage.get(appOption.defaultStartEmulator) as string; const emulators = AppStorage.get(appOption.emulators) as Emulator[]; - const emulatorToStart = emulators.find(it => it.id === defaultStart)!!; + const emulatorToStart = emulators.find(it => it.id === defaultStart); + if (!emulatorToStart) { + hilog.warn(DOMAIN, 'testTag', 'Configured emulator %{public}s not found, falling back to default', defaultStart); + AppStorage.setOrCreate(appOption.defaultStartEmulator, defaultEmulator.id); + return defaultEmulator; + } return emulatorToStart; } @@ -215,6 +289,8 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate(appOption.showVirtKey, showVirtKey); const runningInBackground = await appPref.get(appOption.runningInBackground, false) as boolean; AppStorage.setOrCreate(appOption.runningInBackground, runningInBackground); + const keepAwake = await appPref.get(appOption.keepAwake, true) as boolean; + AppStorage.setOrCreate(appOption.keepAwake, keepAwake); const displayStatusBar = await appPref.get(appOption.displayStatusBar, false) as boolean; AppStorage.setOrCreate(appOption.displayStatusBar, displayStatusBar); const terminalFontSize = await appPref.get(appOption.terminalFontSize, this.getDefaultTerminalFontSize()) as number; @@ -360,7 +436,7 @@ export default class EntryAbility extends UIAbility { private async getAndClearTemporaryStartEmulator(appPref: preference.Preferences) { const temporaryStart = await appPref.get(appOption.temporaryStartEmulator, undefined) as string | undefined; appPref.deleteSync(appOption.temporaryStartEmulator); - appPref.flush(); + await appPref.flush(); return temporaryStart; } @@ -375,9 +451,9 @@ export default class EntryAbility extends UIAbility { } private promptPortUsedMessage(uiContext: UIContext, portSkipped: number[]) { - const promptFormat = uiContext.getHostContext()?.resourceManager.getStringByNameSync('port_used_prompt'); if (portSkipped.length > 0) { try { + const promptFormat = uiContext.getHostContext()?.resourceManager.getStringByNameSync('port_used_prompt'); const ports = portSkipped.map(i => i.toString()).join(','); const prompt = util.format(promptFormat, ports); const promptAction: PromptAction = uiContext.getPromptAction(); @@ -452,7 +528,7 @@ export default class EntryAbility extends UIAbility { try { const runInBackground = AppStorage.get(appOption.runningInBackground) as boolean if (runInBackground) { - await backgroundRunningManager.grantLocationPermission(uiContext.getHostContext() as common.UIAbilityContext) + await backgroundRunningManager.startBackground(uiContext.getHostContext() as common.UIAbilityContext) } } catch (e) { uiContext.getPromptAction().showToast({ diff --git a/entry/src/main/ets/lib/QemuAgent.ets b/entry/src/main/ets/lib/QemuAgent.ets index 77e9c172..a865b6cf 100644 --- a/entry/src/main/ets/lib/QemuAgent.ets +++ b/entry/src/main/ets/lib/QemuAgent.ets @@ -38,6 +38,10 @@ interface QGAError { // 基础响应类型 type QGAResponseData = Object; +// 类型安全的空响应标记,用于 waitForResponse=false 等不需要实际响应内容的场景 +interface EmptyQgaResponse { +} + // 具体业务接口定义 export interface QGANetworkAddress { 'ip-address': string @@ -100,7 +104,15 @@ export class QemuAgent { private requestId: number = 0 private pendingResponse: string = '' private isConnected: boolean = false - private commandMutex: Promise = Promise.resolve() + // 队列式锁,避免无限 promise 链泄漏 + private lockQueue: (() => void)[] = [] + private locked: boolean = false + // 事件驱动的响应等待(替代忙等轮询) + private responseResolve: ((line: string) => void) | null = null + private responseReject: ((e: Error) => void) | null = null + private responseTimer: number | null = null + // Socket 意外断开回调(由 Manager 设置以触发自动重连) + onDisconnect: (() => void) | null = null constructor(socketPath: string) { this.socketPath = socketPath @@ -139,6 +151,22 @@ export class QemuAgent { const decoder: util.TextDecoder = util.TextDecoder.create('utf-8') const message: string = decoder.decodeToString(new Uint8Array(value.message)) this.pendingResponse += message + // 事件驱动:检查是否有等待中的 Promise,直接解析响应行 + if (this.responseResolve) { + const lineEndIndex = this.pendingResponse.indexOf('\n') + if (lineEndIndex !== -1) { + const line = this.pendingResponse.substring(0, lineEndIndex) + this.pendingResponse = this.pendingResponse.substring(lineEndIndex + 1) + const resolve = this.responseResolve + this.responseResolve = null + this.responseReject = null + if (this.responseTimer !== null) { + clearTimeout(this.responseTimer) + this.responseTimer = null + } + resolve(line) + } + } }) // [修复] 监听错误事件 @@ -151,6 +179,10 @@ export class QemuAgent { this.client.on('close', (): void => { hilog.info(DOMAIN, 'QemuAgent', 'Socket 连接已关闭') this.isConnected = false + if (this.onDisconnect) { + hilog.info(DOMAIN, 'QemuAgent', '触发断线回调,尝试自动重连') + this.onDisconnect() + } }) this.isConnected = true @@ -183,6 +215,16 @@ export class QemuAgent { this.client = null // 立即置空,防止重入 this.isConnected = false this.pendingResponse = '' + // [修复] reject 挂起的 Promise,防止 sendCommand 永久 await + if (this.responseReject) { + this.responseReject(new Error('Disconnected')) + this.responseReject = null + } + this.responseResolve = null + if (this.responseTimer !== null) { + clearTimeout(this.responseTimer) + this.responseTimer = null + } if (client) { try { @@ -191,7 +233,7 @@ export class QemuAgent { client.off('close') await client.close() } catch (e) { - // 忽略关闭错误 + hilog.warn(DOMAIN, 'QemuAgent', '关闭连接时异常: %{public}s', JSON.stringify(e)) } hilog.info(DOMAIN, 'QemuAgent', 'QGA 连接已断开') } @@ -243,62 +285,64 @@ export class QemuAgent { hilog.info(DOMAIN, 'QemuAgent', '发送命令: %{public}s (id=%{public}d)', command, currentId) if (!waitForResponse) { - return new Object() as T + // 发送成功标记:调用方应仅判断非 null,不访问返回值成员 + return {} as EmptyQgaResponse as T } - // 等待响应,使用更合理的超时和间隔 - const startTime: number = Date.now() - + // 事件驱动:等待完整响应行(替代忙等轮询) + const startTime = Date.now() while (Date.now() - startTime < timeoutMs) { - // 检查连接状态 if (!this.isConnected) { hilog.error(DOMAIN, 'QemuAgent', '等待响应期间连接断开 (命令: %{public}s)', command) return null } - // 检查是否有完整的响应行 - const lineEndIndex = this.pendingResponse.indexOf('\n') - if (lineEndIndex !== -1) { - const lineRaw = this.pendingResponse.substring(0, lineEndIndex).trim() - this.pendingResponse = this.pendingResponse.substring(lineEndIndex + 1) - let line = lineRaw; - - if (line.length > 0) { - try { - // [修复] 处理 QGA 特有的 0xFF 引导符 - // 由于 TextDecoder('utf-8') 可能会将 0xFF 解码为 \uFFFD,我们直接定位 '{' 开始解析 - const braceIndex = line.indexOf('{') - if (braceIndex > 0) { - line = line.substring(braceIndex) - } else if (braceIndex === -1) { - // hilog.warn(DOMAIN, 'QemuAgent', '收到非 JSON 数据 (可能为引导符): %{public}s', line) // Original line - continue - } - - const result: QGAResponse = JSON.parse(line) as QGAResponse - - // 检查响应 ID 是否匹配(如果响应包含 ID) - if (result.id !== undefined && result.id !== currentId) { - // hilog.warn(DOMAIN, 'QemuAgent', '收到过时响应: 期望 %{public}d, 收到 %{public}d (内容: %{public}s)', currentId, result.id, line) // Original line - hilog.warn(DOMAIN, 'QemuAgent', '收到过时响应: 期望 %{public}d, 收到 %{public}d', currentId, result.id) - continue // 跳过不匹配的响应 - } - - if (result.error) { - hilog.error(DOMAIN, 'QemuAgent', 'QGA 返回错误: %{public}s (命令: %{public}s)', JSON.stringify(result.error), command) - return null - } - - return (result.return as T) || (new Object() as T) - } catch (e) { - hilog.warn(DOMAIN, 'QemuAgent', 'JSON 解析失败: %{public}s (命令: %{public}s)', line, command) + let line: string + try { + line = await new Promise((resolve, reject) => { + this.responseResolve = resolve + this.responseReject = reject + this.responseTimer = setTimeout(() => { + this.responseResolve = null + this.responseReject = null + reject(new Error('timeout')) + }, Math.max(timeoutMs - (Date.now() - startTime), 10)) + }) + } catch (_) { + // 单次等待超时或 disconnect,继续下一次尝试(isConnected 检查会捕获) + this.responseResolve = null + this.responseReject = null + continue + } + + if (line.length > 0) { + try { + // 处理 QGA 特有的 0xFF 引导符 + const braceIndex = line.indexOf('{') + if (braceIndex > 0) { + line = line.substring(braceIndex) + } else if (braceIndex === -1) { continue } + + const result: QGAResponse = JSON.parse(line) as QGAResponse + + if (result.id !== undefined && result.id !== currentId) { + hilog.warn(DOMAIN, 'QemuAgent', '收到过时响应: 期望 %{public}d, 收到 %{public}d', currentId, result.id) + continue + } + + if (result.error) { + hilog.error(DOMAIN, 'QemuAgent', 'QGA 返回错误: %{public}s (命令: %{public}s)', JSON.stringify(result.error), command) + return null + } + + return (result.return as T) || ({} as EmptyQgaResponse as T) + } catch (e) { + hilog.warn(DOMAIN, 'QemuAgent', 'JSON 解析失败 (命令: %{public}s)', command) + continue } } - - // 等待 50ms 后重试 - await new Promise((resolve: () => void) => setTimeout(resolve, 50)) } // hilog.error(DOMAIN, 'QemuAgent', '等待响应超时 (命令: %{public}s, 已等待 %{public}d ms),强制断开连接', command, Date.now() - startTime) // Original line @@ -319,17 +363,27 @@ export class QemuAgent { } /** - * 命令锁实现 + * 命令锁实现(队列式,避免无限 promise 链泄漏) */ private async lock(): Promise<() => void> { - let release: () => void = () => { } - const newLock = new Promise(resolve => { - release = resolve - }) - const oldLock = this.commandMutex - this.commandMutex = oldLock.then(() => newLock) - await oldLock - return release + return new Promise(resolve => { + this.lockQueue.push(resolve) + this.tryAcquire() + }).then(() => () => this.releaseLock()) + } + + private tryAcquire(): void { + if (!this.locked && this.lockQueue.length > 0) { + this.locked = true + // 安全:上面已通过 length > 0 保证队列非空 + const next = this.lockQueue.shift()! + next() + } + } + + private releaseLock(): void { + this.locked = false + this.tryAcquire() } /** @@ -372,8 +426,9 @@ export class QemuAgent { const view = new Uint8Array(resetBuffer) view[0] = 0xFF await this.client.send({ data: resetBuffer }) - // 稍作延迟让对端处理 - await new Promise(resolve => setTimeout(resolve, 100)) + // QGA 协议要求:0xFF 引导符发送后需等待对端刷新并重置解析器状态 + // 50ms 在绝大多数设备上已足够,无需过长阻塞 + await new Promise(resolve => setTimeout(resolve, 50)) } // 2. 发送同步指令并验证返回 ID @@ -576,6 +631,71 @@ export class QemuAgent { const output = await this.execAndGetOutput('/bin/sh', ['-c', 'pgrep sshd || pidof sshd']) return output !== null && output.trim().length > 0 } + + /** + * [优化] 一次 shell exec 获取 meminfo + cpu stats + ssh 状态,替代原来三次独立调用 + * 节省 2 次 QGA socket 往返(~10s → ~3s) + */ + async getSystemInfo(): Promise { + const output = await this.execAndGetOutput('/bin/sh', + ['-c', 'cat /proc/meminfo /proc/stat; echo "===SSH==="; pgrep sshd || echo NONE; echo "===SSH_END==="'], + 8000) + if (!output) return null + + // 按分隔符拆分三段 + const sshMarker = '\n===SSH===' + const sshEndMarker = '\n===SSH_END===' + const sshStart = output.lastIndexOf(sshMarker) + const sshEnd = output.lastIndexOf(sshEndMarker) + + let combinedData: string + let sshOutput: string + if (sshStart !== -1 && sshEnd !== -1 && sshEnd > sshStart) { + combinedData = output.substring(0, sshStart) + sshOutput = output.substring(sshStart + sshMarker.length + 1, sshEnd).trim() + } else { + combinedData = output + sshOutput = '' + } + + // 解析 /proc/meminfo 部分:提取 kB 行 + const memory: Record = {} + const memRegex = /^(\w+):\s+(\d+)\s*kB/gm + let memMatch: RegExpExecArray | null + while ((memMatch = memRegex.exec(combinedData)) !== null) { + // 只取 meminfo 行(/proc/stat 的 cpu 行不以冒号结尾) + if (memMatch[1] && memMatch[2]) { + memory[memMatch[1]] = parseInt(memMatch[2]) * 1024 + } + } + + // 解析 /proc/stat 部分:提取 cpu 行 + let cpuStats: CpuStats | null = null + const cpuRegex = /^cpu\s\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/gm + const cpuMatch = cpuRegex.exec(combinedData) + if (cpuMatch) { + const user = parseInt(cpuMatch[1]) + const nice = parseInt(cpuMatch[2]) + const system = parseInt(cpuMatch[3]) + const idle = parseInt(cpuMatch[4]) + const iowait = parseInt(cpuMatch[5]) + const irq = parseInt(cpuMatch[6]) + const softirq = parseInt(cpuMatch[7]) + const total = user + nice + system + idle + iowait + irq + softirq + cpuStats = { total, idle } + } + + // 解析 SSH 状态 + const sshRunning = sshOutput.length > 0 && sshOutput !== 'NONE' + + return { memory, cpuStats, sshRunning } + } +} + +export interface SystemInfo { + memory: Record + cpuStats: CpuStats | null + sshRunning: boolean } export interface CpuStats { diff --git a/entry/src/main/ets/lib/QemuAgentManager.ets b/entry/src/main/ets/lib/QemuAgentManager.ets index e6eae574..f77c24f5 100644 --- a/entry/src/main/ets/lib/QemuAgentManager.ets +++ b/entry/src/main/ets/lib/QemuAgentManager.ets @@ -211,6 +211,21 @@ class QemuAgentManagerImpl { } this.agent = new QemuAgent(qgaSocket) + + // 设置断线自动重连回调 + this.agent.onDisconnect = () => { + hilog.info(DOMAIN, 'AgentManager', '检测到断线,尝试自动重连...') + this.agent?.connect().then((ok: boolean) => { + if (ok) { + hilog.info(DOMAIN, 'AgentManager', '自动重连成功') + } else { + hilog.warn(DOMAIN, 'AgentManager', '自动重连失败,等待心跳重试') + } + }).catch((e: Error) => { + hilog.error(DOMAIN, 'AgentManager', '自动重连异常: %{public}s', JSON.stringify(e)) + }) + } + const connected = await this.agent.connect() if (connected) { @@ -241,26 +256,31 @@ class QemuAgentManagerImpl { hilog.info(DOMAIN, 'AgentManager', '启动 QGA 心跳保活 (20s)') this.keepAliveTimer = setInterval(async () => { - const currentAgent = this.agent + // 最外层 try/catch 防止回调内部任意异常导致定时器被引擎静默终止 + try { + const currentAgent = this.agent - if (!currentAgent || !currentAgent.isAlive()) { - this.stopKeepAlive() - return - } + if (!currentAgent || !currentAgent.isAlive()) { + this.stopKeepAlive() + return + } - // [修复] 如果当前有使用者正在操作,不要发送心跳 ping,防止 pendingResponse 被清空导致业务指令超时 - if (this.currentUser) { - hilog.debug(DOMAIN, 'AgentManager', '业务正在占用 Agent,跳过此轮心跳') - return - } + // 如果当前有使用者正在操作,不要发送心跳 ping,防止 pendingResponse 被清空导致业务指令超时 + if (this.currentUser) { + hilog.debug(DOMAIN, 'AgentManager', '业务正在占用 Agent,跳过此轮心跳') + return + } - try { hilog.debug(DOMAIN, 'AgentManager', '发送 QGA 心跳 (ping)...') const success = await currentAgent.ping() if (!success) { hilog.warn(DOMAIN, 'AgentManager', 'QGA 心跳失败,尝试自动重连...') - // P1-10修复: 心跳失败后尝试自动重连一次 + // 重连前再次校验:心跳等待期间可能有其他用户 acquire 了连接 + if (this.currentUser) { + hilog.info(DOMAIN, 'AgentManager', '重连前检测到 %{public}s 正在使用 Agent,取消自动重连', this.currentUser) + return + } const reconnected = await currentAgent.connect() if (reconnected) { hilog.info(DOMAIN, 'AgentManager', 'QGA 自动重连成功') diff --git a/entry/src/main/ets/lib/autoSnapshot.ets b/entry/src/main/ets/lib/autoSnapshot.ets new file mode 100644 index 00000000..b81ddbb0 --- /dev/null +++ b/entry/src/main/ets/lib/autoSnapshot.ets @@ -0,0 +1,34 @@ +import { hilog } from '@kit.PerformanceAnalysisKit' +import napi from 'libentry.so' +import appOption from '../model/appOption' +import { Emulator } from '../model/Emulator' + +const DOMAIN = 0x0000 + +/** + * 创建自动快照(退出 VM 时调用) + * 从 AppStorage 读取当前运行的 emulator,生成 auto- 快照 + */ +export async function createAutoSnapshot(): Promise { + const autoSnapshot = AppStorage.get(appOption.autoSnapshotOnExit) + if (!autoSnapshot) return + + const emulatorId = AppStorage.get(appOption.currentRunningEmulator) + if (!emulatorId) return + + try { + const emulatorsJson = AppStorage.get(appOption.emulators) + const emulators = JSON.parse(emulatorsJson ?? '[]') as Emulator[] + const currentEmulator = emulators.find((e: Emulator) => e.id === emulatorId) + if (currentEmulator?.rootFilesystem) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + const snapshotName = `auto-${timestamp}` + hilog.info(DOMAIN, 'autoSnapshot', 'Creating %{public}s on %{public}s', + snapshotName, currentEmulator.rootFilesystem) + napi.createSnapshot(currentEmulator.rootFilesystem, snapshotName) + hilog.info(DOMAIN, 'autoSnapshot', 'Created: %{public}s', snapshotName) + } + } catch (e) { + hilog.error(DOMAIN, 'autoSnapshot', 'Failed: %{public}s', JSON.stringify(e)) + } +} diff --git a/entry/src/main/ets/lib/backgroundRunningManager.ets b/entry/src/main/ets/lib/backgroundRunningManager.ets index 23b7e282..5f47da95 100644 --- a/entry/src/main/ets/lib/backgroundRunningManager.ets +++ b/entry/src/main/ets/lib/backgroundRunningManager.ets @@ -1,10 +1,57 @@ import { abilityAccessCtrl, common, wantAgent } from '@kit.AbilityKit'; import { backgroundTaskManager } from "@kit.BackgroundTasksKit"; +import runningLock from '@ohos.runningLock'; import { hilog } from "@kit.PerformanceAnalysisKit"; -import { geoLocationManager } from '@kit.LocationKit'; const TAG = '[backgroundRunningManager]' +let lock: runningLock.RunningLock | null = null +/** + * 申请 running lock,防止熄屏后系统降频导致 QEMU VM 拿唔够 CPU 时间 + */ +async function requestWakeLock() { + if (lock !== null) { + if (lock.isHolding()) { + hilog.info(1, TAG, 'running lock already held, skip') + return + } + lock.hold(-1) + hilog.info(1, TAG, 'running lock re-held (indefinite)') + return + } + try { + lock = await runningLock.create('hish_vm', runningLock.RunningLockType.BACKGROUND) + lock.hold(-1) + hilog.info(1, TAG, 'running lock acquired (BACKGROUND, indefinite)') + } catch (e) { + hilog.error(1, TAG, `requestWakeLock failed: ${JSON.stringify(e)}`) + lock = null + } +} + +/** + * 释放 running lock + */ +function releaseWakeLock() { + if (lock === null) { + return + } + try { + if (lock.isHolding()) { + lock.unhold() + } + lock = null + hilog.info(1, TAG, 'running lock released') + } catch (e) { + hilog.error(1, TAG, `releaseWakeLock failed: ${JSON.stringify(e)}`) + lock = null + } +} + +/** + * VM 运行期间需要持续传输串口/VNC 数据,使用 DATA_TRANSFER 模式维持后台运行 + * 同时持有 RUNNING_LOCK 防止熄屏后 CPU 降频 + */ async function startContinuousTask(context: common.UIAbilityContext) { let wantAgentInfo: wantAgent.WantAgentInfo = { wants: [ @@ -17,9 +64,9 @@ async function startContinuousTask(context: common.UIAbilityContext) { requestCode: 0, wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] }; - const wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo) + const wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo); await backgroundTaskManager.startBackgroundRunning( - context, backgroundTaskManager.BackgroundMode.LOCATION, wantAgentObj) + context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, wantAgentObj) } async function stopContinuousTask(context: common.UIAbilityContext) { @@ -29,42 +76,37 @@ async function stopContinuousTask(context: common.UIAbilityContext) { export class backgroundRunningManager { public static async startBackground(context: common.UIAbilityContext): Promise { try { - const granted = await backgroundRunningManager.grantLocationPermission(context); - if (granted) { - geoLocationManager.on("locationChange", { - interval: 15, - locationScenario: geoLocationManager.PowerConsumptionScenario.NO_POWER_CONSUMPTION - }, () => { - }) - await startContinuousTask(context) - return true - } - return false - } catch (e) { - hilog.info(1, TAG, `startBackground failed Cause: ${JSON.stringify(e)}`); - return false - } - } - - public static async grantLocationPermission(context: common.UIAbilityContext) { - try { - const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); - const result = await atManager.requestPermissionsFromUser(context, ['ohos.permission.APPROXIMATELY_LOCATION']); - return result.authResults[0] === 0; + await startContinuousTask(context) + await requestWakeLock() + return true } catch (e) { - hilog.info(1, TAG, `grantLocationPermission failed Cause: ${JSON.stringify(e)}`); + hilog.warn(1, TAG, `startBackground failed: ${JSON.stringify(e)}`) return false } } public static async stopBackground(context: common.UIAbilityContext): Promise { + releaseWakeLock() try { - geoLocationManager.off('locationChange') - stopContinuousTask(context) + await stopContinuousTask(context) return true } catch (e) { - hilog.info(1, TAG, `stopBackground failed Cause: ${JSON.stringify(e)}`); + hilog.warn(1, TAG, `stopBackground failed: ${JSON.stringify(e)}`) return false } } -} \ No newline at end of file + + /** + * 单独释放 running lock(VM 停止但保持后台运行时调用) + */ + public static releaseWakeLockOnly(): void { + releaseWakeLock() + } + + /** + * VM 启动时申请 running lock(唔依赖 runningInBackground 设置) + */ + public static async requestWakeLockForVm(): Promise { + await requestWakeLock() + } +} diff --git a/entry/src/main/ets/lib/copyFile.ets b/entry/src/main/ets/lib/copyFile.ets index b2de7be3..b7ee38f7 100644 --- a/entry/src/main/ets/lib/copyFile.ets +++ b/entry/src/main/ets/lib/copyFile.ets @@ -14,7 +14,7 @@ async function copyFile(srcPathOrUri: string, destPathOrUri: string) { } catch (e) { hilog.error(DOMAIN, 'copyFile', '复制文件失败: %{public}s -> %{public}s, 错误: %{public}s', srcPathOrUri, destPathOrUri, JSON.stringify(e)) - throw new Error(`Copy file failed: ${srcPathOrUri} -> ${destPathOrUri}`) + throw new Error('Copy file failed') } finally { if (srcFile) { try { await fs.close(srcFile.fd) } catch (closeErr) { /* 忽略关闭错误 */ } diff --git a/entry/src/main/ets/lib/deepEquals.ets b/entry/src/main/ets/lib/deepEquals.ets deleted file mode 100644 index 4440b8ad..00000000 --- a/entry/src/main/ets/lib/deepEquals.ets +++ /dev/null @@ -1,41 +0,0 @@ -type ValueType = number | string | boolean | Array | Array | Array | Uint8Array | object - | bigint; - -function deepEquals(obj1: ValueType, obj2: ValueType): boolean { - if (obj1 === obj2) { - return true; - } - - if (Array.isArray(obj1) && Array.isArray(obj2)) { - if (obj1.length !== obj2.length) { - return false; - } - for (let i = 0; i < obj1.length; i++) { - if (!deepEquals(obj1[i], obj2[i])) { - return false; - } - } - return true; - } - - if (typeof obj1 === 'object' && typeof obj2 === 'object') { - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - if (keys1.length !== keys2.length) { - return false; - } - for (const key of keys1) { - if (!keys2.includes(key)) { - return false; - } - if (!deepEquals(obj1[key], obj2[key])) { - return false; - } - } - return true; - } - - return false; -} - -export default deepEquals \ No newline at end of file diff --git a/entry/src/main/ets/lib/deleteDirectoryRecursive.ets b/entry/src/main/ets/lib/deleteDirectoryRecursive.ets index 9a049796..0e489e59 100644 --- a/entry/src/main/ets/lib/deleteDirectoryRecursive.ets +++ b/entry/src/main/ets/lib/deleteDirectoryRecursive.ets @@ -3,11 +3,13 @@ import { hilog } from '@kit.PerformanceAnalysisKit' const DOMAIN = 0x0000 -async function deleteDirectoryRecursive(dirPath: string) { - try { - const dirEntries = await fs.listFile(dirPath, { recursion: false }); - for (const entry of dirEntries) { - const fullPath = `${dirPath}/${entry}`; +async function deleteDirectoryRecursive(dirPath: string): Promise { + const failedItems: string[] = [] + + const dirEntries = await fs.listFile(dirPath, { recursion: false }); + for (const entry of dirEntries) { + const fullPath = `${dirPath}/${entry}`; + try { const stat = await fs.stat(fullPath); if (stat.isDirectory()) { @@ -15,11 +17,27 @@ async function deleteDirectoryRecursive(dirPath: string) { } else { await fs.unlink(fullPath); } + } catch (e) { + // 记录失败项但不中断循环,确保尽可能多地删除文件 + hilog.error(DOMAIN, 'deleteDirectoryRecursive', '删除子项失败: %{public}s, 错误: %{public}s', + fullPath, JSON.stringify(e)); + failedItems.push(fullPath) } + } + + if (failedItems.length > 0) { + // 有子项删除失败时,rmdir 大概率也会失败(目录非空),但仍尝试清理 + hilog.warn(DOMAIN, 'deleteDirectoryRecursive', '部分删除失败 (%{public}d 项): %{public}s', + failedItems.length, dirPath) + } + + try { await fs.rmdir(dirPath); - } catch (err) { + } catch (e) { + // 如果目录非空(有删除失败的子项残留),向调用方抛出明确错误 hilog.error(DOMAIN, 'deleteDirectoryRecursive', '删除目录失败: %{public}s, 错误: %{public}s', - dirPath, JSON.stringify(err)); + dirPath, JSON.stringify(e)); + throw new Error(`Failed to delete directory: ${failedItems.length} items could not be removed`) } } diff --git a/entry/src/main/ets/lib/readQcow2VirtualSize.ets b/entry/src/main/ets/lib/readQcow2VirtualSize.ets index 4c8dc4ac..84994cbc 100644 --- a/entry/src/main/ets/lib/readQcow2VirtualSize.ets +++ b/entry/src/main/ets/lib/readQcow2VirtualSize.ets @@ -1,15 +1,14 @@ import { fileIo as fs } from '@kit.CoreFileKit' -function readQcow2VirtualSize(filePath: string): number { - +async function readQcow2VirtualSize(filePath: string): Promise { try { - const f = fs.openSync(filePath, fs.OpenMode.READ_ONLY) + const f = await fs.open(filePath, fs.OpenMode.READ_ONLY) const HEADER_SIZE = 32; const buffer = new ArrayBuffer(HEADER_SIZE); - const bytesRead = fs.readSync(f.fd, buffer, { offset: 0, length: HEADER_SIZE }) - fs.close(f) + const bytesRead = await fs.read(f.fd, buffer, { offset: 0, length: HEADER_SIZE }) + await fs.close(f) if (bytesRead < HEADER_SIZE) { return 0 diff --git a/entry/src/main/ets/lib/startVm.ets b/entry/src/main/ets/lib/startVm.ets index a4e0eead..ab7ba4f1 100644 --- a/entry/src/main/ets/lib/startVm.ets +++ b/entry/src/main/ets/lib/startVm.ets @@ -2,6 +2,8 @@ import { fileIo as fs } from '@kit.CoreFileKit' import napi from 'libentry.so' import { defaultEmulator, PortMapping } from '../model/Emulator' import deviceInfo from '@ohos.deviceInfo' +import { hilog } from '@kit.PerformanceAnalysisKit' +import appOption from '../model/appOption' interface VmOptions { baseDir: string @@ -27,7 +29,7 @@ function startVm(options: VmOptions): boolean { const sharedFolderOption = options.sharedFolderReadonly ? ",readonly" : "" const basic = ['-machine', 'virt,acpi=on,gic-version=max,' + - 'iommu=none,its=off,usb=off,virtualization=off,memory-backend=mem0,compact-highmem=on,' + + 'iommu=none,its=off,usb=on,virtualization=off,memory-backend=mem0,compact-highmem=on,' + 'dump-guest-core=off,mem-merge=off,hmat=off', '-overcommit', 'cpu-pm=off', '-cpu', 'max,pauth-impdef=on,sve=off,pmu=off', '-accel', 'tcg,thread=multi,tb-size=2048', @@ -41,10 +43,13 @@ function startVm(options: VmOptions): boolean { const network = ["-global", "virtio-net-pci.ctrl_guest_offloads=off", "-global", "virtio-net-pci.packed=on", "-device", "virtio-net-pci-non-transitional," + "netdev=eth0,csum=on,gso=on,guest_tso4=on,guest_tso6=on,guest_ecn=off,mrg_rxbuf=on,tx=bh", - '-netdev', "user,id=eth0" + options.portMapping.map(it => `,hostfwd=tcp::${it.host}-:${it.guest}`).join('')] + '-netdev', `user,id=eth0${options.portMapping + .filter(it => it.host !== undefined && it.host !== null && + it.guest !== undefined && it.guest !== null) + .map(it => `,hostfwd=tcp::${it.host}-:${it.guest}`) + .join('')}`] const shared = - ["-fsdev", "local,security_model=mapped-file,id=fsdev0,path=" + options.sharedFolder + sharedFolderOption, - "-device", "virtio-9p-pci,id=fs0,fsdev=fsdev0,mount_tag=hostshare"] + ["-virtfs", `local,path=${options.sharedFolder},mount_tag=hostshare,security_model=mapped-file${sharedFolderOption},fmode=0666,dmode=0777`] const drive = ["-global", "virtio-blk-pci.scsi=off", "-global", "virtio-scsi-pci.cmd_per_lun=128", "-global", "virtio-scsi-pci.packed=on", "-drive", "if=none,format=qcow2,file=" + options.rootFilesystem + ",id=hd0,cache=writeback,aio=threads,discard=unmap", "-object", "iothread,id=iothread0,poll-max-ns=2000000", @@ -53,14 +58,17 @@ function startVm(options: VmOptions): boolean { "-device", "scsi-hd,drive=hd0,bus=scsi0.0,logical_block_size=4096,physical_block_size=4096,rotation_rate=1,bootindex=1"] const kernelParam = ["-append", "root=" + root + " rw rootfstype=ext4 rootwait console=tty0 console=ttyAMA0 consoleblank=0 " + + "quiet loglevel=3 " + // 抑制 INFO/DEBUG 内核日志污染终端 "elevator=noop skew_tick=1 acpi=on " + "mitigations=off audit=0 nmi_watchdog=0 hardened_usercopy=off random.trust_cpu=on virtio_balloon.config_impl=0 " + "security=none selinux=0 apparmor=0 psi=0 page_alloc.shuffle=0 swiotlb=noforce idle=halt " + "cpuidle.off=1 transparent_hugepage=always pci=noaer scsi_mod.use_blk_mq=1 rng_core.default_quality=1000 " + "systemd.mask=run-lock.mount systemd.mask=sys-kernel-debug.mount systemd.mask=dev-hugepages.mount " + - "cryptomgr.notests rodata=off rcupdate.rcu_cpu_stall_timeout=60 " + + "cryptomgr.notests rodata=off rcupdate.rcu_cpu_stall_timeout=300 " + // 5min,熄屏恢复不误触发 "rcu_expedited=1 init_on_free=0 init_on_alloc=1 page_poison=0 slub_debug=- " + - "TERM=xterm init=" + options.init] + "nohz=on highres=off clocksource=arch_sys_counter " + // 省电:tickless + 高效时钟源 + "net.core.netdev_budget=300 " + // 减少网络软中断 + "TERM=xterm-256color init=" + options.init] const monitor = ['-qmp', 'unix:' + options.qmpUnixSocket + ',server,nowait'] const qga = [ '-device', 'virtio-serial-pci-non-transitional,id=virtio-serial0', @@ -69,10 +77,19 @@ function startVm(options: VmOptions): boolean { ] const sharedUserFolder = deviceInfo.deviceType !== '2in1' ? [] : - ["-fsdev", "local,security_model=mapped-file,id=fsdev1,path=/storage/Users/currentUser", "-device", - "virtio-9p-pci,id=fs1,fsdev=fsdev1,mount_tag=usershare"] + ["-virtfs", "local,path=/storage/Users/currentUser,mount_tag=usershare,security_model=mapped-file,fmode=0666,dmode=0777"] - const vnc: string[] = options.vncEnabled ? [ + // HarmonyOS app进程fd配额有限,过多hostfwd监听socket会耗尽fd,导致VNC init失败→exit(1) + // 保守阈值:映射数>4时禁用VNC,避免vnc_init_func中的socket()返回EMFILE + const MAX_PORT_MAPPING_WITH_VNC = 4 + let vncEnabled = options.vncEnabled + if (vncEnabled && options.portMapping.length > MAX_PORT_MAPPING_WITH_VNC) { + vncEnabled = false + hilog.warn(0x0000, 'startVm', 'Port mapping count %{public}d exceeds VNC-safe limit %{public}d, disabling VNC to prevent fd exhaustion crash', + options.portMapping.length, MAX_PORT_MAPPING_WITH_VNC) + } + + const vnc: string[] = vncEnabled ? [ '-vnc', `${options.vncPortMode === 'lan' ? '0.0.0.0' : '127.0.0.1'}:${options.vncPort - 5900}`, '-device', 'virtio-gpu-pci', '-device', 'virtio-keyboard-pci', @@ -96,13 +113,25 @@ function startVm(options: VmOptions): boolean { ] if (fs.accessSync(options.serialUnixSocket, fs.AccessModeType.EXIST)) { - fs.unlinkSync(options.serialUnixSocket) + try { + fs.unlinkSync(options.serialUnixSocket) + } catch (e) { + hilog.warn(0x0000, 'startVm', 'Failed to unlink serial socket: %{public}s', (e as Error).message ?? 'unknown') + } } if (fs.accessSync(options.qmpUnixSocket, fs.AccessModeType.EXIST)) { - fs.unlinkSync(options.qmpUnixSocket) + try { + fs.unlinkSync(options.qmpUnixSocket) + } catch (e) { + hilog.warn(0x0000, 'startVm', 'Failed to unlink qmp socket: %{public}s', (e as Error).message ?? 'unknown') + } } if (fs.accessSync(options.qgaUnixSocket, fs.AccessModeType.EXIST)) { - fs.unlinkSync(options.qgaUnixSocket) + try { + fs.unlinkSync(options.qgaUnixSocket) + } catch (e) { + hilog.warn(0x0000, 'startVm', 'Failed to unlink qga socket: %{public}s', (e as Error).message ?? 'unknown') + } } if (!fs.accessSync(options.sharedFolder, fs.AccessModeType.EXIST)) { fs.mkdirSync(options.sharedFolder) diff --git a/entry/src/main/ets/model/Emulator.ets b/entry/src/main/ets/model/Emulator.ets index 7da2af88..395f13bd 100644 --- a/entry/src/main/ets/model/Emulator.ets +++ b/entry/src/main/ets/model/Emulator.ets @@ -9,6 +9,14 @@ interface RootFilesystem { path: string } +interface Snippet { + id: string + label: string + command: string + starred: boolean + sortOrder: number +} + interface Emulator { id: string name: string, @@ -19,8 +27,28 @@ interface Emulator { rootVda: string, rootFilesystem: string sharedFolderReadonly: boolean + snippets?: Snippet[] } +// 预设命令(首次创建 VM 时自动填充) +const DEFAULT_SNIPPETS: Snippet[] = [ + { id: '1', label: '更新软件包', command: 'apt update -y && apt upgrade -y\n', starred: true, sortOrder: 0 }, + { id: '2', label: '查看磁盘', command: 'df -h\n', starred: false, sortOrder: 1 }, + { id: '3', label: '查看内存', command: 'free -m\n', starred: false, sortOrder: 2 }, + { id: '4', label: '查看进程', command: 'ps aux --sort=-%mem | head -20\n', starred: false, sortOrder: 3 }, + { id: '5', label: '网络信息', command: 'ip addr && ping -c 2 8.8.8.8\n', starred: false, sortOrder: 4 }, + { id: '6', label: '重启 SSH', command: 'systemctl restart ssh\n', starred: true, sortOrder: 5 }, + { id: '7', label: '系统信息', command: 'uname -a && cat /etc/os-release\n', starred: false, sortOrder: 6 }, + { id: '8', label: '查看端口', command: 'ss -tlnp\n', starred: false, sortOrder: 7 }, + { id: '9', label: 'DNS 测试', command: 'nslookup google.com\n', starred: false, sortOrder: 8 }, + { id: '10', label: '清理缓存', command: 'apt clean && apt autoremove -y\n', starred: false, sortOrder: 9 }, + { id: '11', label: '查看日志', command: 'journalctl -n 50 --no-pager\n', starred: false, sortOrder: 10 }, + { id: '12', label: 'CPU 信息', command: 'lscpu | head -20\n', starred: false, sortOrder: 11 }, + { id: '13', label: '安装软件', command: 'apt install -y ', starred: false, sortOrder: 12 }, + { id: '14', label: '查看文件', command: 'ls -lah\n', starred: false, sortOrder: 13 }, + { id: '15', label: '网络诊断', command: 'traceroute -n 8.8.8.8\n', starred: false, sortOrder: 14 }, +] + const defaultRootfs: RootFilesystem = { id: 'rootfs_aarch64', name: 'Alpine Linux', @@ -39,4 +67,4 @@ const defaultEmulator: Emulator = { sharedFolderReadonly: false } -export { Emulator, PortMapping, RootFilesystem, defaultEmulator, defaultRootfs } +export { Emulator, PortMapping, RootFilesystem, Snippet, DEFAULT_SNIPPETS, defaultEmulator, defaultRootfs } diff --git a/entry/src/main/ets/model/appOption.ets b/entry/src/main/ets/model/appOption.ets index fba8a066..14f4d2b1 100644 --- a/entry/src/main/ets/model/appOption.ets +++ b/entry/src/main/ets/model/appOption.ets @@ -2,54 +2,114 @@ import preference from '@ohos.data.preferences'; type CursorShape = 'BLOCK' | 'BEAM' | 'UNDERLINE' -class appOption { - static preferenceName = 'vm_pref' - static keepScreenOn = 'appKeepScreenOn' - static terminalFontSize = 'terminalFontSize' - static terminalCursorBlink = 'terminalCursorBlink' - static terminalCursorShape = 'terminalCursorShape' - static appTempDir = 'appTempDir' - static appFilesDir = 'appFilesDir' - static vmBaseDir = 'vmBaseDir' - static rootfsBaseDir = 'rootfsBaseDir' - static sharedGuest = 'sharedGuest' - static sharedHost = 'sharedHost' - static kernel = 'kernel' - static portUsedByOthers = 'portUsedByOthers' - static emulators = 'emulators' - static rootFilesystems = 'rootFilesystems' - static defaultStartEmulator = 'defaultStartEmulator' - static currentRunningEmulator = 'currentRunningEmulator' - static currentEmulatorStatus = 'currentEmulatorStatus' - static bundleInfo = 'bundleInfo' - static temporaryStartEmulator = 'temporaryStartEmulator' - static serialChannel = 'serialChannel' - static lastResourceVersion = 'lastResourceVersion'; - static runningInBackground = 'runningInBackground'; - static displayStatusBar = 'displayStatusBar'; - static showVirtKey = 'showVirtKey'; - static isPowerManagementOpen = 'isPowerManagementOpen'; - static vncEnabled = 'vncEnabled'; - static vncPortMode = 'vncPortMode'; // 'local' | 'lan' - static vncPort = 'vncPort'; // VNC port number (e.g. 5900) - // qemu-img 后台任务状态 - static qemuImgTaskRunning = 'qemuImgTaskRunning'; - static qemuImgTaskDescription = 'qemuImgTaskDescription'; - // QGA 连接状态 (Global Truth) - static isQgaConnected = 'isQgaConnected'; +class AppOption { + static readonly preferenceName: string = 'vm_pref' + static readonly keepScreenOn: string = 'appKeepScreenOn' + static readonly terminalFontSize: string = 'terminalFontSize' + static readonly terminalCursorBlink: string = 'terminalCursorBlink' + static readonly terminalCursorShape: string = 'terminalCursorShape' + static readonly terminalTheme: string = 'terminalTheme' + static readonly terminalFont: string = 'terminalFont' + static readonly customTheme: string = 'customTheme' + static readonly customFont: string = 'customFont' + static readonly autoSnapshotOnExit: string = 'autoSnapshotOnExit' + static readonly performanceProfile: string = 'performanceProfile' + static readonly showLauncher: string = 'showLauncher' + static readonly lastEmulatorId: string = 'lastEmulatorId' + static readonly appTempDir: string = 'appTempDir' + static readonly appFilesDir: string = 'appFilesDir' + static readonly vmBaseDir: string = 'vmBaseDir' + static readonly rootfsBaseDir: string = 'rootfsBaseDir' + static readonly sharedGuest: string = 'sharedGuest' + static readonly sharedHost: string = 'sharedHost' + static readonly kernel: string = 'kernel' + static readonly portUsedByOthers: string = 'portUsedByOthers' + static readonly emulators: string = 'emulators' + static readonly rootFilesystems: string = 'rootFilesystems' + static readonly defaultStartEmulator: string = 'defaultStartEmulator' + static readonly currentRunningEmulator: string = 'currentRunningEmulator' + static readonly currentEmulatorStatus: string = 'currentEmulatorStatus' + static readonly bundleInfo: string = 'bundleInfo' + static readonly temporaryStartEmulator: string = 'temporaryStartEmulator' + static readonly serialChannel: string = 'serialChannel' + static readonly lastResourceVersion: string = 'lastResourceVersion' + static readonly runningInBackground: string = 'runningInBackground' + static readonly keepAwake: string = 'keepAwake' + static readonly displayStatusBar: string = 'displayStatusBar' + static readonly showVirtKey: string = 'showVirtKey' + static readonly isPowerManagementOpen: string = 'isPowerManagementOpen' + static readonly vncEnabled: string = 'vncEnabled' + static readonly vncPortMode: string = 'vncPortMode' + static readonly vncPort: string = 'vncPort' + static readonly qemuImgTaskRunning: string = 'qemuImgTaskRunning' + static readonly qemuImgTaskDescription: string = 'qemuImgTaskDescription' + static readonly isQgaConnected: string = 'isQgaConnected' + static readonly terminalDialogOpen: string = 'terminalDialogOpen' } -async function savePreference(key: string, value: object | string | boolean | number, context: UIContext) { - const appContext = context.getHostContext()!!.getApplicationContext(); - const appPref: preference.Preferences = await preference.getPreferences(appContext, appOption.preferenceName); - if (value instanceof Object) { - await appPref.put(key, JSON.stringify(value)); - } else { - await appPref.put(key, value) +let cachedPref: preference.Preferences | null = null +let debounceTimer: number | null = null +const pendingWrites = new Map() + +async function getPref(context: UIContext): Promise { + if (cachedPref === null) { + const appContext = context.getHostContext()!!.getApplicationContext() + cachedPref = await preference.getPreferences(appContext, AppOption.preferenceName) } - await appPref.flush(); + return cachedPref +} + +function flushWrites(): void { + if (debounceTimer !== null || pendingWrites.size === 0) { + return + } + // 收集 50ms 内的所有写入,批量 flush + debounceTimer = setTimeout(async () => { + debounceTimer = null + if (cachedPref === null || pendingWrites.size === 0) { + return + } + // snapshot 防止 flush 期间新写入被 clear 丢失 + const writes = new Map(pendingWrites) + pendingWrites.clear() + // 使用 for...of 替代 forEach,确保异步写入顺序执行并等待完成 + for (const entry of writes.entries()) { + const key = entry[0] + const value = entry[1] + if (value instanceof Object) { + await cachedPref.put(key, JSON.stringify(value)) + } else { + await cachedPref.put(key, value as string | boolean | number) + } + } + await cachedPref.flush() + }, 50) +} + +/** + * 取消待执行的 flush 定时器(用于组件销毁时清理) + */ +function cancelFlushWrites(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer) + debounceTimer = null + } +} + +/** + * 清除缓存的 Preferences 实例(用于应用热重载等场景) + */ +function clearCachedPref(): void { + cachedPref = null + cancelFlushWrites() +} + +async function savePreference(key: string, value: object | string | boolean | number, context: UIContext) { + await getPref(context) + pendingWrites.set(key, value) + flushWrites() } -export default appOption +export default AppOption -export { savePreference, CursorShape } \ No newline at end of file +export { savePreference, CursorShape, cancelFlushWrites, clearCachedPref } \ No newline at end of file diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 99bfcc3d..008b9c99 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -24,7 +24,10 @@ struct PcIndex { contentBuilder: () => { this.buildSimulatorManagement(); } - }) + }), + cancel: () => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, false) + } }) rootfsManagementController: CustomDialogController = new CustomDialogController({ builder: CustomContentDialog({ @@ -32,7 +35,10 @@ struct PcIndex { contentBuilder: () => { this.buildRootfsManagement(); } - }) + }), + cancel: () => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, false) + } }) sharedFolderController: CustomDialogController = new CustomDialogController({ builder: CustomContentDialog({ @@ -40,7 +46,10 @@ struct PcIndex { contentBuilder: () => { this.buildSharedFolder(); } - }) + }), + cancel: () => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, false) + } }) settingController: CustomDialogController = new CustomDialogController({ builder: CustomContentDialog({ @@ -48,7 +57,10 @@ struct PcIndex { contentBuilder: () => { this.buildSettings(); } - }) + }), + cancel: () => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, false) + } }) userGuideController: CustomDialogController = new CustomDialogController({ builder: CustomContentDialog({ @@ -56,7 +68,10 @@ struct PcIndex { contentBuilder: () => { this.buildGuideList(); } - }) + }), + cancel: () => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, false) + } }) webController = new webview.WebviewController() @@ -118,6 +133,7 @@ struct PcIndex { .padding(4) .margin({ right: 10 }) .onClick((event: ClickEvent) => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, true) this.emulatorManagementController.open() }) @@ -136,6 +152,7 @@ struct PcIndex { .padding(4) .margin({ right: 10 }) .onClick((event: ClickEvent) => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, true) this.rootfsManagementController.open() }) @@ -150,6 +167,7 @@ struct PcIndex { .padding(4) .margin({ right: 10 }) .onClick((event: ClickEvent) => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, true) this.sharedFolderController.open() }) @@ -164,6 +182,7 @@ struct PcIndex { .padding(4) .margin({ right: 10 }) .onClick((event: ClickEvent) => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, true) this.settingController.open() }) @@ -181,6 +200,7 @@ struct PcIndex { .borderRadius(4) .padding(4) .onClick((event: ClickEvent) => { + AppStorage.setOrCreate(appOption.terminalDialogOpen, true) this.userGuideController.open() }) } diff --git a/entry/src/main/ets/pages/LauncherPage.ets b/entry/src/main/ets/pages/LauncherPage.ets new file mode 100644 index 00000000..1e0c71de --- /dev/null +++ b/entry/src/main/ets/pages/LauncherPage.ets @@ -0,0 +1,336 @@ +import { hilog } from '@kit.PerformanceAnalysisKit' +import { router } from '@kit.ArkUI' +import { Emulator, defaultEmulator } from '../model/Emulator' +import appOption from '../model/appOption' +import napi from 'libentry.so' +import { startVm } from '../lib/startVm' +import { getCpuCount, getMemSize } from '../lib/systemProfile' + +const DOMAIN = 0x0000 + +interface StartResult { + vmStarted: boolean + portsSkipped: number[] +} + +/** + * 启动固件选择页 + * 用户选择要启动的 VM,支持倒计时自动启动上次使用的固件 + */ +@Entry +@Component +struct LauncherPage { + @StorageLink(appOption.emulators) emulators: Emulator[] = [] + @StorageLink(appOption.lastEmulatorId) lastEmulatorId: string = '' + @StorageLink(appOption.showLauncher) showLauncher: boolean = true + @State countdown: number = 3 + @State countdownTimer: number = -1 + @State isStarting: boolean = false + + aboutToAppear(): void { + // 如果只有一个 emulator 或者用户关了 launcher,直接启动 + if (!this.showLauncher || this.emulators.length <= 1) { + this.startEmulator(this.getDefaultEmulatorId()) + return + } + // 开始倒计时 + this.startCountdown() + } + + aboutToDisappear(): void { + if (this.countdownTimer >= 0) { + clearInterval(this.countdownTimer) + } + } + + getDefaultEmulatorId(): string { + if (this.lastEmulatorId && this.emulators.some(e => e.id === this.lastEmulatorId)) { + return this.lastEmulatorId + } + const defaultId = AppStorage.get(appOption.defaultStartEmulator) + if (defaultId && this.emulators.some(e => e.id === defaultId)) { + return defaultId + } + return this.emulators.length > 0 ? this.emulators[0].id : defaultEmulator.id + } + + startCountdown(): void { + this.countdown = 3 + this.countdownTimer = setInterval(() => { + this.countdown-- + if (this.countdown <= 0) { + clearInterval(this.countdownTimer) + this.startEmulator(this.getDefaultEmulatorId()) + } + }, 1000) + } + + cancelCountdown(): void { + if (this.countdownTimer >= 0) { + clearInterval(this.countdownTimer) + this.countdownTimer = -1 + } + this.countdown = 0 + } + + startEmulator(emulatorId: string): void { + if (this.isStarting) return + this.isStarting = true + this.cancelCountdown() + + // 记录上次启动的 emulator + AppStorage.setOrCreate(appOption.lastEmulatorId, emulatorId) + + hilog.info(DOMAIN, 'LauncherPage', 'Starting emulator: %{public}s', emulatorId) + + // 查找要启动的 emulator + const emulatorToStart = this.emulators.find(e => e.id === emulatorId) || defaultEmulator + + // 启动 VM(复用 EntryAbility 的逻辑) + const result = this.doStartVm(emulatorToStart) + + // 跳转到终端页 + router.replaceUrl({ url: 'pages/Index' }) + } + + private doStartVm(emulatorToStart: Emulator): StartResult { + const appTempDir = AppStorage.get(appOption.appTempDir) as string + const cpuCount = emulatorToStart.cpu + const memSize = emulatorToStart.memory + + // Apply performance profile + const profile = AppStorage.get(appOption.performanceProfile) ?? 'balanced' + let adjustedCpu = cpuCount + let adjustedMem = memSize + if (profile === 'powersave') { + adjustedCpu = Math.min(cpuCount, 2) + adjustedMem = Math.min(memSize, 512) + } else if (profile === 'performance') { + adjustedCpu = getCpuCount() + adjustedMem = Math.max(1024, getMemSize() - 1024) + } + + const rootVda = emulatorToStart.rootVda + const rootFilesystem = emulatorToStart.rootFilesystem + const sharedFolderReadonly = emulatorToStart.sharedFolderReadonly + const portMappingRaw = emulatorToStart.portMapping + const init = emulatorToStart.init + + const sharedFolder = AppStorage.get(appOption.sharedHost) as string + const vmBaseDir = AppStorage.get(appOption.vmBaseDir) as string + const kernelFile = AppStorage.get(appOption.kernel) as string + + // 跳过特权端口 + const portUsed = portMappingRaw.map((it): number => it.host!!) + .filter((p): boolean => { + if (p < 1024) { + hilog.warn(DOMAIN, 'LauncherPage', 'host port %{public}d is privileged (<1024), skipped', p) + return true + } + return napi.checkPortUsed(p) + }) + const portMapping = portMappingRaw.filter((it): boolean => portUsed.indexOf(it.host!!) < 0) + + AppStorage.setOrCreate(appOption.portUsedByOthers, portUsed) + AppStorage.setOrCreate(appOption.currentRunningEmulator, emulatorToStart.id) + + // VNC 配置 + let vncEnabled = AppStorage.get(appOption.vncEnabled) ?? true + const vncPortMode = AppStorage.get(appOption.vncPortMode) ?? 'local' + let vncPort = AppStorage.get(appOption.vncPort) ?? 5900 + + // VNC 端口预检查 + if (vncEnabled && napi.checkPortUsed(vncPort)) { + hilog.warn(DOMAIN, 'LauncherPage', 'VNC port %{public}d is in use, searching for free port', vncPort) + const freePort = this.getFreeVncPort(5900, 5999) + if (freePort !== undefined) { + vncPort = freePort + AppStorage.setOrCreate(appOption.vncPort, vncPort) + } else { + vncEnabled = false + hilog.warn(DOMAIN, 'LauncherPage', 'No free VNC port found, disabling VNC') + } + } + + const serialUnixSocket = appTempDir + '/serial_socket' + const qmpUnixSocket = appTempDir + '/qmp_socket' + const qgaUnixSocket = appTempDir + '/qga_socket' + + const vmStarted = startVm({ + baseDir: vmBaseDir, + cpu: adjustedCpu, + memory: adjustedMem, + portMapping: portMapping, + kernel: kernelFile, + rootVda, + rootFilesystem, + sharedFolder, + serialUnixSocket, + sharedFolderReadonly, + init, + qmpUnixSocket, + qgaUnixSocket, + vncEnabled, + vncPortMode, + vncPort + }) + + if (vmStarted) { + AppStorage.setOrCreate(appOption.currentEmulatorStatus, 'RUNNING') + } + + return { vmStarted, portsSkipped: portUsed } + } + + private getFreeVncPort(min: number, max: number): number | undefined { + for (let port = min; port <= max; port++) { + if (!napi.checkPortUsed(port)) { + return port + } + } + return undefined + } + + build() { + Column() { + // 顶部标题 + Row() { + Text('HiSH') + .fontSize(24) + .fontWeight(FontWeight.Bold) + .fontColor('#ffffff') + Blank() + // 设置入口 + Text('⚙') + .fontSize(20) + .fontColor('#888888') + .onClick(() => { + router.pushUrl({ url: 'pages/SettingsPage' }) + }) + } + .width('100%') + .padding({ left: 20, right: 20, top: 16, bottom: 8 }) + + // 提示文字 + Text('选择要启动的虚拟机') + .fontSize(14) + .fontColor('#888888') + .width('100%') + .padding({ left: 20, bottom: 16 }) + + // 模拟器列表 + List() { + ForEach(this.emulators, (emulator: Emulator) => { + ListItem() { + this.buildEmulatorCard(emulator) + } + }, (emulator: Emulator) => emulator.id) + } + .width('100%') + .layoutWeight(1) + .padding({ left: 16, right: 16 }) + + // 底部工具栏 + Row() { + Text('+ 新建模拟器') + .fontSize(14) + .fontColor('#4FC3F7') + .onClick(() => { + router.pushUrl({ url: 'pages/EditEmulator' }) + }) + Blank() + Text('📁 镜像管理') + .fontSize(14) + .fontColor('#888888') + .onClick(() => { + router.pushUrl({ url: 'pages/RootfsManagement' }) + }) + } + .width('100%') + .padding({ left: 20, right: 20, top: 12, bottom: 16 }) + } + .width('100%') + .height('100%') + .backgroundColor('#0d0d1a') + } + + @Builder + buildEmulatorCard(emulator: Emulator) { + Column() { + Row() { + // 图标 + Text(emulator.name.startsWith('AI') ? '🤖' : + emulator.name.includes('Arch') ? '🧪' : '🐧') + .fontSize(32) + .margin({ right: 12 }) + + // 名称和配置 + Column() { + Text(emulator.name) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor('#ffffff') + Text(`${emulator.cpu} CPU / ${emulator.memory} MB`) + .fontSize(12) + .fontColor('#888888') + .margin({ top: 4 }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + // 倒计时或启动按钮 + if (emulator.id === this.getDefaultEmulatorId() && this.countdown > 0) { + Column() { + Text(`${this.countdown}s`) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#4FC3F7') + Text('自动启动') + .fontSize(10) + .fontColor('#888888') + } + .alignItems(HorizontalAlign.Center) + } else { + Button('启动') + .fontSize(14) + .fontColor('#ffffff') + .backgroundColor('#4FC3F7') + .height(36) + .onClick(() => { + this.startEmulator(emulator.id) + }) + } + } + .width('100%') + .alignItems(VerticalAlign.Center) + + // 取消自动启动按钮(只在倒计时中的默认 emulator 显示) + if (emulator.id === this.getDefaultEmulatorId() && this.countdown > 0) { + Button('取消自动启动') + .fontSize(12) + .fontColor('#888888') + .backgroundColor('#333333') + .height(28) + .margin({ top: 8 }) + .width('100%') + .onClick(() => { + this.cancelCountdown() + }) + } + } + .width('100%') + .padding(16) + .margin({ top: 8, bottom: 8 }) + .backgroundColor(emulator.id === this.getDefaultEmulatorId() ? '#1a1a3a' : '#1a1a2e') + .borderRadius(12) + .border({ + width: emulator.id === this.getDefaultEmulatorId() ? 1 : 0, + color: '#4FC3F7' + }) + .onClick(() => { + if (emulator.id !== this.getDefaultEmulatorId() || this.countdown <= 0) { + this.startEmulator(emulator.id) + } + }) + } +} diff --git a/entry/src/main/ets/pages/VncPage.ets b/entry/src/main/ets/pages/VncPage.ets index 466e5a9c..68c0473d 100644 --- a/entry/src/main/ets/pages/VncPage.ets +++ b/entry/src/main/ets/pages/VncPage.ets @@ -1,9 +1,11 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; -import { ComposeTitleBar } from '@kit.ArkUI' +import { ComposeTitleBar, KeyboardAvoidMode } from '@kit.ArkUI' import napi from 'libentry.so'; import { button2rfb } from '../utils/KeyMap'; import deviceInfo from '@ohos.deviceInfo'; import appOption from '../model/appOption'; +import { pasteboard } from '@kit.BasicServicesKit'; +import util from '@ohos.util'; const DOMAIN = 0x0001; const LOG_TAG = 'NativeVnc'; @@ -39,8 +41,12 @@ struct VncPage { private updateLoopStarted: boolean = false private reconnectTimer: number = -1 private reconnectAttempts: number = 0 + // Pending surface dimensions from onAreaChange before surface is ready (fixes foldable half-screen bug) + private pendingSurfaceWidth: number = -1 + private pendingSurfaceHeight: number = -1 aboutToAppear() { + this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); this.connectVnc(); } @@ -168,7 +174,11 @@ struct VncPage { Column() { if (deviceInfo.deviceType === '2in1') { ComposeTitleBar({ - title: $r('app.string.vnc_console_title') + title: $r('app.string.vnc_console_title'), + menuItems: [{ + value: '粘贴到虚拟机', + action: () => this.pasteToVnc() + }], }) .focusable(false) } @@ -179,9 +189,18 @@ struct VncPage { }) .onLoad(() => { const surfaceId = this.xComponentController.getXComponentSurfaceId(); - const rect = this.xComponentController.getXComponentSurfaceRect(); - this.surfaceWidth = rect.surfaceWidth; - this.surfaceHeight = rect.surfaceHeight; + + // Apply pending dimensions from onAreaChange if available (fixes foldable half-screen bug) + if (this.pendingSurfaceWidth >= 0 && this.pendingSurfaceHeight >= 0) { + this.surfaceWidth = this.pendingSurfaceWidth; + this.surfaceHeight = this.pendingSurfaceHeight; + this.pendingSurfaceWidth = -1; + this.pendingSurfaceHeight = -1; + } else { + const rect = this.xComponentController.getXComponentSurfaceRect(); + this.surfaceWidth = rect.surfaceWidth; + this.surfaceHeight = rect.surfaceHeight; + } try { const sid = BigInt(surfaceId); @@ -206,16 +225,24 @@ struct VncPage { .onAreaChange((_oldValue: Area, newValue: Area) => { const newWidth = Number(newValue.width); const newHeight = Number(newValue.height); - if (newWidth > 0 && newHeight > 0 && this.surfaceReady) { - const sid = BigInt(this.xComponentController.getXComponentSurfaceId()); - const pxWidth = vp2px(newWidth); - const pxHeight = vp2px(newHeight); - if (pxWidth !== this.surfaceWidth || pxHeight !== this.surfaceHeight) { - this.surfaceWidth = pxWidth; - this.surfaceHeight = pxHeight; - napi.vncResizeSurface(sid, pxWidth, pxHeight); - } + if (newWidth <= 0 || newHeight <= 0) return; + + const pxWidth = vp2px(newWidth); + const pxHeight = vp2px(newHeight); + if (pxWidth === this.surfaceWidth && pxHeight === this.surfaceHeight) return; + + if (!this.surfaceReady) { + // Surface not ready yet — store dimensions to apply in onLoad + // This fixes the foldable half-screen bug where onAreaChange fires before onLoad + this.pendingSurfaceWidth = pxWidth; + this.pendingSurfaceHeight = pxHeight; + return; } + + this.surfaceWidth = pxWidth; + this.surfaceHeight = pxHeight; + const sid = BigInt(this.xComponentController.getXComponentSurfaceId()); + napi.vncResizeSurface(sid, pxWidth, pxHeight); }) .width('100%') .layoutWeight(1) @@ -300,4 +327,17 @@ struct VncPage { hilog.error(DOMAIN, LOG_TAG, 'Key event error: %{public}s', JSON.stringify(e)); } } + + private async pasteToVnc(): Promise { + try { + const board = pasteboard.getSystemPasteboard(); + const data = await board.getData(); + const text = data.getPrimaryText(); + if (text && text.length > 0) { + napi.vncSendCutText(text); + } + } catch (e) { + hilog.error(DOMAIN, LOG_TAG, 'Paste to VNC failed: %{public}s', JSON.stringify(e)); + } + } } diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 31415f93..e22bb981 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -7,8 +7,7 @@ "deviceTypes": [ "phone", "tablet", - "2in1", - "tv" + "2in1" ], "requestPermissions": [ { @@ -27,27 +26,10 @@ "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }, { - "name": "ohos.permission.APPROXIMATELY_LOCATION", - "reason": "$string:location_reason", - "usedScene": { - "abilities": [ - "EntryAbility" - ], - "when": "inuse" - } - }, - { - "name": "ohos.permission.LOCATION_IN_BACKGROUND", - "reason": "$string:location_reason", - "usedScene": { - "abilities": [ - "EntryAbility" - ], - "when": "inuse" - } + "name": "ohos.permission.READ_WRITE_USER_FILE" }, { - "name": "ohos.permission.READ_WRITE_USER_FILE" + "name": "ohos.permission.RUNNING_LOCK" } ], "deliveryWithInstall": true, @@ -65,7 +47,7 @@ "exported": true, "orientation": "auto_rotation_restricted", "backgroundModes": [ - "location" + "dataTransfer" ], "skills": [ { diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 49581b3e..3f351941 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -312,6 +312,14 @@ "name": "force_background_desc", "value": "Force the app run in background" }, + { + "name": "keep_awake", + "value": "Keep CPU awake" + }, + { + "name": "keep_awake_desc", + "value": "Prevent CPU throttling when screen is off. Enable if running background tasks in VM" + }, { "name": "user_guide", "value": "User guide" @@ -900,6 +908,10 @@ "name": "msg_default_rootfs_delete", "value": "Cannot delete default image" }, + { + "name": "msg_rootfs_not_found", + "value": "Selected image no longer exists, please select again" + }, { "name": "dialog_delete_rootfs_msg", "value": "Deleting image cannot be undone. Confirm?" @@ -1647,6 +1659,10 @@ { "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": "restart_emulator_prompt", + "value": "HiSH will restart and boot \"%s\". Please save your data in the current emulator first. Start now?" } ] } \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 6366d42d..d8ba8e0c 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,6 +1,7 @@ { "src": [ "pages/Index", + "pages/LauncherPage", "pages/SettingsPage", "pages/EditEmulator", "pages/EmulatorManagementPage", diff --git a/entry/src/main/resources/rawfile/term/CodeNewRomanNerdFontMono-Regular.otf b/entry/src/main/resources/rawfile/term/CodeNewRomanNerdFontMono-Regular.otf new file mode 100644 index 00000000..1221b196 Binary files /dev/null and b/entry/src/main/resources/rawfile/term/CodeNewRomanNerdFontMono-Regular.otf differ diff --git a/entry/src/main/resources/rawfile/term/SourceCodePro-Regular.ttf b/entry/src/main/resources/rawfile/term/SourceCodePro-Regular.ttf new file mode 100644 index 00000000..10c73d7d Binary files /dev/null and b/entry/src/main/resources/rawfile/term/SourceCodePro-Regular.ttf differ diff --git a/entry/src/main/resources/rawfile/term/term.css b/entry/src/main/resources/rawfile/term/term.css index 408f9f8c..ada47d4e 100644 --- a/entry/src/main/resources/rawfile/term/term.css +++ b/entry/src/main/resources/rawfile/term/term.css @@ -1,3 +1,17 @@ +@font-face { + font-family: 'CodeNewRomanNerdFontMono'; + src: url('CodeNewRomanNerdFontMono-Regular.otf') format('opentype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'SourceCodePro'; + src: url('SourceCodePro-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + body { margin: 0; background: #000; diff --git a/entry/src/main/resources/rawfile/term/term.html b/entry/src/main/resources/rawfile/term/term.html index 40354ecb..8ef3e1e7 100644 --- a/entry/src/main/resources/rawfile/term/term.html +++ b/entry/src/main/resources/rawfile/term/term.html @@ -20,7 +20,14 @@ margin: 0; padding: 0; overflow: hidden; - /* Critical for passing clicks/touches to xterm */ + } + #terminal-container { + height: 100%; + width: 100%; + overflow: hidden; + } + #terminal { + /* JS ResizeObserver handles scale + centering */; } diff --git a/entry/src/main/resources/rawfile/term/term.js b/entry/src/main/resources/rawfile/term/term.js index 196a750f..17e6a031 100644 --- a/entry/src/main/resources/rawfile/term/term.js +++ b/entry/src/main/resources/rawfile/term/term.js @@ -3,8 +3,9 @@ var term = new Terminal({ cursorBlink: true, allowProposedApi: true, // Needed for some addons allowTransparency: true, // User preference: Transparency supported - fontFamily: 'monospace, "Droid Sans Mono", "Courier New", "Courier", monospace', - fontSize: 14, // Default, will be overridden by native.getFontSize() + fontFamily: '"CodeNewRomanNerdFontMono", monospace', + fontSize: 14, // Default, overridden by native.getFontSize() in syncPrefs + letterSpacing: 0, // 等宽字体不需要调整间距 theme: { background: 'rgba(0, 0, 0, 0)', // Transparent by default to show effects behind foreground: '#ffffff', @@ -12,6 +13,8 @@ var term = new Terminal({ }, screenReaderMode: false, // Disabled to fix touch scrolling issues (was conflicting with native selection) scrollback: 3000, // [Optimization] Limit scrollback to 3000 lines (Ring Buffer) to prevent memory overflow + smoothScrollDuration: 0, // 禁用滚动动画,提升 TUI 响应 + termName: 'xterm-256color', // 告诉 VM 终端支持 256 色,修复 OpenCode 等 TUI 黑屏 }); // Initialize Addons @@ -61,7 +64,8 @@ window.onload = function () { const maxRetries = 10; function initialize() { - // Disable WebGL as per user request (Compatibility Mode) + // Enable WebGL for better rendering on high DPI screens + // 改为 false 提升复杂 TUI 工具兼容性(如 opencode/tmux 等) let shouldEnableWebGL = false; // Check if native is ready @@ -99,24 +103,14 @@ window.onload = function () { term.resize(80, 24); // Fallback } - // 3. Load WebGL - if (shouldEnableWebGL) { - try { - webglAddon.onContextLoss(e => { - webglAddon.dispose(); - }); - term.loadAddon(webglAddon); - console.log("WebGL renderer loaded"); - } catch (e) { - console.warn("WebGL renderer failed to load, falling back to canvas", e); - } - } + // 3. Skip WebGL, use Canvas renderer for better TUI compatibility + console.log("Using Canvas renderer (WebGL disabled for TUI compatibility)"); // 4. Initialize and Start Shell try { - // Restore HiSH Startup Logo + // Boot splash is already shown, update status + exports.setBootStatus('Connecting to VM...'); term.writeln( - 'HiSH is starting...\r\n\r\n' + ' | | _) __| | |\r\n' + ' __ | | \\__ \\ __ |\r\n' + ' _| _| _| ____/ _| _|\r\n' @@ -154,22 +148,38 @@ function syncPrefs() { const shape = native.getCursorShape(); if (shape) exports.setCursorShape(shape); // Use our adapter logic } + if (native.getTheme) { + const theme = native.getTheme(); + if (theme) exports.setTheme(theme); + } + if (native.getFont) { + const font = native.getFont(); + if (font) exports.setFont(font); + } } catch (e) { console.warn("Failed to sync prefs", e); } } function setupEventListeners() { + // fit 后抑制 onData(防 textarea/Ime 残留发往 VM),覆盖折叠屏完整动画周期 + var _suppressOnDataUntil = 0; + var _suppressOnDataMs = 500; + // 1. Input from User (Keyboard/Mouse) -> Send to VM term.onData(data => { - // Pass data directly to native (matching original hterm behavior) + if (Date.now() < _suppressOnDataUntil) return; if (native && native.sendInput) { native.sendInput(data); } }); - // 2. Resize -> Notify Native + // 2. Resize -> Notify Native(走 QGA stty,不经过串口,无 echo) + let lastCols = -1, lastRows = -1; term.onResize(size => { + if (size.cols === lastCols && size.rows === lastRows) return; + lastCols = size.cols; + lastRows = size.rows; if (native && native.resize) { native.resize(size.cols, size.rows); } @@ -183,10 +193,25 @@ function setupEventListeners() { } }); - // 4. Handle Window Resize - window.addEventListener('resize', () => { - setTimeout(() => fitAddon.fit(), 100); - }); + // 4. 屏幕尺寸变化时 fit 画布(xterm 文字自动适配),但不发 CSI 给 VM + let fitTimer = null; + function scheduleFit() { + clearTimeout(fitTimer); + fitTimer = setTimeout(function() { + _suppressOnDataUntil = Date.now() + _suppressOnDataMs; + fitAddon.fit(); + // 二次 fit 兜底折叠动画 + setTimeout(function() { + _suppressOnDataUntil = Date.now() + _suppressOnDataMs; + fitAddon.fit(); + }, 400); + }, 100); + } + const terminalEl = document.getElementById('terminal'); + if (terminalEl && window.ResizeObserver) { + new ResizeObserver(function() { scheduleFit(); }).observe(terminalEl); + } + window.addEventListener('resize', function() { scheduleFit(); }); // 5. Prevent default browser behaviors that might interfere document.addEventListener('keydown', (e) => { @@ -252,8 +277,12 @@ function setupMirroredInputFix(termEl) { // --- Implementation of exports matching term.js.bak --- // exports.write(data) - Write data from VM to terminal +let firstDataReceived = false; exports.write = (data, applicationMode) => { - // legacy hterm code imply data is Binary String (UTF-8/Latin1 bytes) + if (!firstDataReceived) { + exports.hideBootSplash(); + firstDataReceived = true; + } try { const uint8 = strToUint8Array(data); term.write(uint8); @@ -266,6 +295,10 @@ exports.write = (data, applicationMode) => { }; exports.writeBase64 = (base64Data, applicationMode) => { + if (!firstDataReceived) { + exports.hideBootSplash(); + firstDataReceived = true; + } try { let binaryString = atob(base64Data); @@ -316,6 +349,11 @@ exports.copy = () => { return term.getSelection(); }; +// 手动触发 fit(设置面板改字体后调用,初始化请勿调用此方法) +exports.fit = () => { + if (fitAddon) fitAddon.fit(); +}; + exports.setFontSize = (size) => { term.options.fontSize = size; fitAddon.fit(); @@ -361,3 +399,397 @@ term.options.linkHandler = { exports.selectAll = () => { term.selectAll(); }; + +// ============== Terminal Themes ============== +const THEMES = { + 'dark': { + background: 'rgba(0, 0, 0, 0)', + foreground: '#ffffff', + cursor: '#ffffff', + cursorAccent: '#000000', + selectionBackground: '#ffffff44', + black: '#1a1a2e', red: '#e06c75', green: '#98c379', + yellow: '#e5c07b', blue: '#61afef', magenta: '#c678dd', + cyan: '#56b6c2', white: '#abb2bf', + brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379', + brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd', + brightCyan: '#56b6c2', brightWhite: '#ffffff' + }, + 'dracula': { + background: 'rgba(40, 42, 54, 0.92)', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + cursorAccent: '#282a36', + selectionBackground: '#44475a', + black: '#21222c', red: '#ff5555', green: '#50fa7b', + yellow: '#f1fa8c', blue: '#bd93f9', magenta: '#ff79c6', + cyan: '#8be9fd', white: '#f8f8f2', + brightBlack: '#6272a4', brightRed: '#ff6e6e', brightGreen: '#69ff94', + brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df', + brightCyan: '#a4ffff', brightWhite: '#ffffff' + }, + 'solarized-dark': { + background: 'rgba(0, 43, 54, 0.92)', + foreground: '#839496', + cursor: '#839496', + cursorAccent: '#002b36', + selectionBackground: '#586e75', + black: '#073642', red: '#dc322f', green: '#859900', + yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', + cyan: '#2aa198', white: '#eee8d5', + brightBlack: '#002b36', brightRed: '#cb4b16', brightGreen: '#586e75', + brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', brightWhite: '#fdf6e3' + }, + 'solarized-light': { + background: 'rgba(253, 246, 227, 0.95)', + foreground: '#657b83', + cursor: '#657b83', + cursorAccent: '#fdf6e3', + selectionBackground: '#eee8d5', + black: '#073642', red: '#dc322f', green: '#859900', + yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', + cyan: '#2aa198', white: '#eee8d5', + brightBlack: '#002b36', brightRed: '#cb4b16', brightGreen: '#586e75', + brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', brightWhite: '#fdf6e3' + }, + 'monokai': { + background: 'rgba(39, 40, 34, 0.92)', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + cursorAccent: '#272822', + selectionBackground: '#49483e', + black: '#272822', red: '#f92672', green: '#a6e22e', + yellow: '#f4bf75', blue: '#66d9ef', magenta: '#ae81ff', + cyan: '#a1efe4', white: '#f8f8f2', + brightBlack: '#75715e', brightRed: '#f92672', brightGreen: '#a6e22e', + brightYellow: '#f4bf75', brightBlue: '#66d9ef', brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', brightWhite: '#f9f8f5' + }, + 'nord': { + background: 'rgba(46, 52, 64, 0.92)', + foreground: '#d8dee9', + cursor: '#d8dee9', + cursorAccent: '#2e3440', + selectionBackground: '#4c566a', + black: '#3b4252', red: '#bf616a', green: '#a3be8c', + yellow: '#ebcb8b', blue: '#81a1c1', magenta: '#b48ead', + cyan: '#88c0d0', white: '#e5e9f0', + brightBlack: '#4c566a', brightRed: '#bf616a', brightGreen: '#a3be8c', + brightYellow: '#ebcb8b', brightBlue: '#81a1c1', brightMagenta: '#b48ead', + brightCyan: '#8fbcbb', brightWhite: '#eceff4' + }, + 'gruvbox-dark': { + background: 'rgba(40, 40, 40, 0.92)', + foreground: '#ebdbb2', + cursor: '#ebdbb2', + cursorAccent: '#282828', + selectionBackground: '#504945', + black: '#282828', red: '#cc241d', green: '#98971a', + yellow: '#d79921', blue: '#458588', magenta: '#b16286', + cyan: '#689d6a', white: '#a89984', + brightBlack: '#928374', brightRed: '#fb4934', brightGreen: '#b8bb26', + brightYellow: '#fabd2f', brightBlue: '#83a598', brightMagenta: '#d3869b', + brightCyan: '#8ec07c', brightWhite: '#ebdbb2' + } +}; +let currentTheme = 'dark'; + +exports.setTheme = (nameOrJson) => { + // 支持预设主题名或 JSON 自定义主题 + let theme = THEMES[nameOrJson]; + if (theme) { + // 预设主题 + currentTheme = nameOrJson; + term.options.theme = theme; + document.body.setAttribute('data-theme', nameOrJson); + } else if (nameOrJson && nameOrJson.startsWith('{')) { + // 自定义主题 JSON + try { + theme = JSON.parse(nameOrJson); + // 合并默认值,确保所有必要字段存在 + const base = THEMES['dark']; + term.options.theme = Object.assign({}, base, theme); + currentTheme = 'custom'; + document.body.setAttribute('data-theme', 'custom'); + } catch (e) { + console.error('Invalid custom theme JSON', e); + } + } +}; + +exports.getThemes = () => JSON.stringify(Object.keys(THEMES)); + +exports.getCurrentTheme = () => currentTheme; + +// Apply theme on load +exports.setTheme('dark'); + +// ============== Terminal Search ============== +let searchQuery = ''; +let searchIndex = -1; +let searchResults = []; +let searchOverlay = null; +let searchInput = null; + +function buildSearchOverlay() { + if (searchOverlay) return; + + searchOverlay = document.createElement('div'); + searchOverlay.id = 'search-overlay'; + searchOverlay.style.cssText = 'position:absolute;top:8px;right:8px;z-index:100;display:none;' + + 'background:#1e1e2e;border:1px solid #444;border-radius:8px;padding:6px 10px;' + + 'box-shadow:0 4px 12px rgba(0,0,0,0.5);align-items:center;gap:4px;'; + + searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = 'Search...'; + searchInput.style.cssText = 'background:transparent;border:none;color:#cdd6f4;font-size:13px;' + + 'outline:none;width:160px;font-family:monospace;'; + + const countLabel = document.createElement('span'); + countLabel.id = 'search-count'; + countLabel.style.cssText = 'color:#6c7086;font-size:11px;min-width:40px;text-align:center;'; + + const prevBtn = document.createElement('button'); + prevBtn.textContent = '▲'; + prevBtn.style.cssText = 'background:#313244;border:none;color:#cdd6f4;border-radius:4px;' + + 'cursor:pointer;padding:2px 6px;font-size:10px;'; + prevBtn.onclick = () => searchInBuffer(-1); + + const nextBtn = document.createElement('button'); + nextBtn.textContent = '▼'; + nextBtn.style.cssText = 'background:#313244;border:none;color:#cdd6f4;border-radius:4px;' + + 'cursor:pointer;padding:2px 6px;font-size:10px;'; + nextBtn.onclick = () => searchInBuffer(1); + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = 'background:transparent;border:none;color:#6c7086;cursor:pointer;' + + 'font-size:13px;padding:2px 4px;'; + closeBtn.onclick = () => exports.hideSearch(); + + searchOverlay.appendChild(searchInput); + searchOverlay.appendChild(countLabel); + searchOverlay.appendChild(prevBtn); + searchOverlay.appendChild(nextBtn); + searchOverlay.appendChild(closeBtn); + + const container = document.getElementById('terminal-container'); + if (container) container.appendChild(searchOverlay); + + searchInput.addEventListener('input', () => { + searchQuery = searchInput.value; + if (searchQuery.length > 0) { + doSearch(); + } else { + clearSearchHighlights(); + searchResults = []; + searchIndex = -1; + updateSearchCount(); + } + }); + + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchInBuffer(e.shiftKey ? -1 : 1); + } else if (e.key === 'Escape') { + e.preventDefault(); + exports.hideSearch(); + term.focus(); + } + }); +} + +function doSearch() { + searchResults = []; + searchIndex = -1; + const needle = searchQuery.toLowerCase(); + const buffer = term.buffer.active; + + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row); + if (!line) continue; + const text = line.translateToString(true).toLowerCase(); + let pos = -1; + while ((pos = text.indexOf(needle, pos + 1)) !== -1) { + searchResults.push({ row: row, col: pos }); + } + } + + updateSearchCount(); + if (searchResults.length > 0) { + searchIndex = 0; + highlightResult(0); + } +} + +function searchInBuffer(direction) { + if (searchResults.length === 0) return; + + searchIndex += direction; + if (searchIndex < 0) searchIndex = searchResults.length - 1; + if (searchIndex >= searchResults.length) searchIndex = 0; + + highlightResult(searchIndex); + updateSearchCount(); +} + +function highlightResult(index) { + if (index < 0 || index >= searchResults.length) return; + + const result = searchResults[index]; + const buffer = term.buffer.active; + const viewportRows = term.rows; + const targetScroll = result.row - Math.floor(viewportRows / 2); + + if (targetScroll >= 0 && targetScroll <= buffer.length - viewportRows) { + term.scrollToLine(targetScroll); + } else if (result.row < buffer.length - viewportRows) { + term.scrollToLine(Math.max(0, result.row - 2)); + } + + term.select(result.col, result.row, searchQuery.length); + term.scrollToLine(Math.max(0, result.row - Math.floor(viewportRows / 2))); +} + +function clearSearchHighlights() { + term.clearSelection(); +} + +function updateSearchCount() { + const label = document.getElementById('search-count'); + if (label) { + if (searchResults.length > 0) { + label.textContent = `${searchIndex + 1}/${searchResults.length}`; + } else if (searchQuery.length > 0) { + label.textContent = '0/0'; + } else { + label.textContent = ''; + } + } +} + +exports.showSearch = () => { + buildSearchOverlay(); + searchOverlay.style.display = 'flex'; + searchInput.value = ''; + searchQuery = ''; + searchResults = []; + searchIndex = -1; + updateSearchCount(); + clearSearchHighlights(); + setTimeout(() => searchInput.focus(), 50); +}; + +exports.hideSearch = () => { + if (searchOverlay) { + searchOverlay.style.display = 'none'; + } + clearSearchHighlights(); + searchQuery = ''; + searchResults = []; + searchIndex = -1; +}; + +exports.findNext = () => searchInBuffer(1); +exports.findPrevious = () => searchInBuffer(-1); + +// Keyboard shortcut: Ctrl+F or Cmd+F to open search +document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + if (searchOverlay && searchOverlay.style.display === 'flex') { + exports.hideSearch(); + } else { + exports.showSearch(); + } + } +}); + +// ============== Boot Splash Screen ============== +let bootSplash = null; + +function buildBootSplash() { + if (bootSplash) return; + bootSplash = document.createElement('div'); + bootSplash.id = 'boot-splash'; + bootSplash.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:50;' + + 'display:flex;flex-direction:column;align-items:center;justify-content:center;' + + 'background:rgba(0,0,0,0.95);transition:opacity 0.4s ease-out;'; + bootSplash.innerHTML = ` +
+
+     |  | _)   __|  |  |
+     __ |  | \\\\__ \\\\  __ |
+    _| _| _| ____/ _| _|
+            
+
+ Booting VM... +
+
+ + + +
+
+ `; + + const container = document.getElementById('terminal-container'); + if (container) container.appendChild(bootSplash); +} + +exports.showBootSplash = () => { + buildBootSplash(); + bootSplash.style.display = 'flex'; + bootSplash.style.opacity = '1'; +}; + +exports.hideBootSplash = () => { + if (bootSplash) { + bootSplash.style.display = 'none'; + } +}; + +exports.setBootStatus = (status) => { + const el = document.getElementById('boot-status'); + if (el) el.textContent = status; +}; + +// Show boot splash on initialization (before VM starts) +exports.showBootSplash(); + +// ============== Font Management ============== +const FONT_LIST = [ + { name: 'CodeNewRoman NF Mono', family: '"CodeNewRomanNerdFontMono", monospace' }, + { name: 'Source Code Pro', family: '"SourceCodePro", monospace' }, + { name: 'Monospace', family: 'monospace' }, +]; + +exports.setFont = (fontName) => { + // 先查预设字体名,匹配不到则视为原始 CSS font-family 字符串 + const font = FONT_LIST.find(f => f.name === fontName); + if (font) { + term.options.fontFamily = font.family; + } else if (fontName && fontName.length > 0) { + // 自定义字体:直接使用用户输入的 font-family 值 + term.options.fontFamily = fontName; + } + // fit 由调用方负责(syncPrefs 期间 fit 由 initialize 统一触发,避免提前 resize 到未启动的 VM) +}; + +exports.getFonts = () => JSON.stringify(FONT_LIST.map(f => f.name)); + +exports.getCurrentFont = () => { + // Find current font from list matching term.options.fontFamily + const current = term.options.fontFamily; + const match = FONT_LIST.find(f => f.family === current); + return match ? match.name : FONT_LIST[0].name; +}; diff --git a/entry/src/main/resources/zh_CN/element/string.json b/entry/src/main/resources/zh_CN/element/string.json index 0479401d..1f450cc8 100644 --- a/entry/src/main/resources/zh_CN/element/string.json +++ b/entry/src/main/resources/zh_CN/element/string.json @@ -312,6 +312,14 @@ "name": "force_background_desc", "value": "切换到后台后继续保持Linux运行" }, + { + "name": "keep_awake", + "value": "保持 CPU 唤醒" + }, + { + "name": "keep_awake_desc", + "value": "防止熄屏后 CPU 降频。如果 VM 有后台任务需要跑,请开启" + }, { "name": "user_guide", "value": "使用指南" @@ -900,6 +908,10 @@ "name": "msg_default_rootfs_delete", "value": "不支持删除默认镜像" }, + { + "name": "msg_rootfs_not_found", + "value": "所选镜像文件已不存在,请重新选择" + }, { "name": "dialog_delete_rootfs_msg", "value": "您正在操作删除镜像,删除后不可恢复,确定操作?" @@ -1647,6 +1659,10 @@ { "name": "guest_status_bar_toast", "value": "开启“状态栏”需要确保模拟器中的 qemu-guest-agent 正常运行" + }, + { + "name": "restart_emulator_prompt", + "value": "将重启HiSH并启动\"%s\",建议保存好当前模拟器中的数据再操作。是否现在启动?" } ] } \ No newline at end of file