diff --git a/curios/harmony-connector/companion/.gitignore b/curios/harmony-connector/companion/.gitignore new file mode 100644 index 0000000..ab6f752 --- /dev/null +++ b/curios/harmony-connector/companion/.gitignore @@ -0,0 +1,22 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer +# Local signing configuration and credentials +/build-profile.json5 +/signing/ +/material/ +*.p12 +*.p7b +*.cer +*.csr +*.pem +*.key diff --git a/curios/harmony-connector/companion/AppScope/app.json5 b/curios/harmony-connector/companion/AppScope/app.json5 new file mode 100644 index 0000000..3fc5fb5 --- /dev/null +++ b/curios/harmony-connector/companion/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "ink.render.trove.harmonyconnector", + "vendor": "Trove", + "versionCode": 1, + "versionName": "0.1.0", + "icon": "$media:layered_image", + "label": "$string:app_name" + } +} diff --git a/curios/harmony-connector/companion/AppScope/resources/base/element/string.json b/curios/harmony-connector/companion/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..bdd890c --- /dev/null +++ b/curios/harmony-connector/companion/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "Harmony Connector" + } + ] +} diff --git a/curios/harmony-connector/companion/AppScope/resources/base/media/background.png b/curios/harmony-connector/companion/AppScope/resources/base/media/background.png new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/curios/harmony-connector/companion/AppScope/resources/base/media/background.png differ diff --git a/curios/harmony-connector/companion/AppScope/resources/base/media/foreground.png b/curios/harmony-connector/companion/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/curios/harmony-connector/companion/AppScope/resources/base/media/foreground.png differ diff --git a/curios/harmony-connector/companion/AppScope/resources/base/media/layered_image.json b/curios/harmony-connector/companion/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/curios/harmony-connector/companion/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/curios/harmony-connector/companion/build-profile.template.json5 b/curios/harmony-connector/companion/build-profile.template.json5 new file mode 100644 index 0000000..ff7128a --- /dev/null +++ b/curios/harmony-connector/companion/build-profile.template.json5 @@ -0,0 +1,41 @@ +{ + "app": { + "signingConfigs": [], + "products": [ + { + "name": "default", + "targetSdkVersion": "26.0.0", + "compatibleSdkVersion": "5.0.5(17)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug" + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} diff --git a/curios/harmony-connector/companion/code-linter.json5 b/curios/harmony-connector/companion/code-linter.json5 new file mode 100644 index 0000000..073990f --- /dev/null +++ b/curios/harmony-connector/companion/code-linter.json5 @@ -0,0 +1,32 @@ +{ + "files": [ + "**/*.ets" + ], + "ignore": [ + "**/src/ohosTest/**/*", + "**/src/test/**/*", + "**/src/mock/**/*", + "**/node_modules/**/*", + "**/oh_modules/**/*", + "**/build/**/*", + "**/.preview/**/*" + ], + "ruleSet": [ + "plugin:@performance/recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@security/no-unsafe-aes": "error", + "@security/no-unsafe-hash": "error", + "@security/no-unsafe-mac": "warn", + "@security/no-unsafe-dh": "error", + "@security/no-unsafe-dsa": "error", + "@security/no-unsafe-ecdsa": "error", + "@security/no-unsafe-rsa-encrypt": "error", + "@security/no-unsafe-rsa-sign": "error", + "@security/no-unsafe-rsa-key": "error", + "@security/no-unsafe-dsa-key": "error", + "@security/no-unsafe-dh-key": "error", + "@security/no-unsafe-3des": "error" + } +} \ No newline at end of file diff --git a/curios/harmony-connector/companion/entry/.gitignore b/curios/harmony-connector/companion/entry/.gitignore new file mode 100644 index 0000000..e2713a2 --- /dev/null +++ b/curios/harmony-connector/companion/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/curios/harmony-connector/companion/entry/build-profile.json5 b/curios/harmony-connector/companion/entry/build-profile.json5 new file mode 100644 index 0000000..65845b1 --- /dev/null +++ b/curios/harmony-connector/companion/entry/build-profile.json5 @@ -0,0 +1,38 @@ +{ + "apiType": "stageMode", + "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + }, + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt", + "arguments": "", + "cppFlags": "", + "abiFilters": [ + "arm64-v8a" + ] + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + } + ], + "targets": [ + { + "name": "default" + } + ] +} diff --git a/curios/harmony-connector/companion/entry/hvigorfile.ts b/curios/harmony-connector/companion/entry/hvigorfile.ts new file mode 100644 index 0000000..52c2861 --- /dev/null +++ b/curios/harmony-connector/companion/entry/hvigorfile.ts @@ -0,0 +1,7 @@ +// @ts-nocheck – Template file, only used when copied into a project directory +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks /* Built-in plugin of Hvigor. It cannot be modified. */, + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */, +}; diff --git a/curios/harmony-connector/companion/entry/obfuscation-rules.txt b/curios/harmony-connector/companion/entry/obfuscation-rules.txt new file mode 100644 index 0000000..ded9d40 --- /dev/null +++ b/curios/harmony-connector/companion/entry/obfuscation-rules.txt @@ -0,0 +1,20 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/curios/harmony-connector/companion/entry/oh-package-lock.json5 b/curios/harmony-connector/companion/entry/oh-package-lock.json5 new file mode 100644 index 0000000..efe2f1c --- /dev/null +++ b/curios/harmony-connector/companion/entry/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "libharmony_stream.so@src/main/cpp/types/libharmony_stream": "libharmony_stream.so@src/main/cpp/types/libharmony_stream" + }, + "packages": { + "libharmony_stream.so@src/main/cpp/types/libharmony_stream": { + "name": "libharmony_stream.so", + "version": "0.1.0", + "resolved": "src/main/cpp/types/libharmony_stream", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/curios/harmony-connector/companion/entry/oh-package.json5 b/curios/harmony-connector/companion/entry/oh-package.json5 new file mode 100644 index 0000000..dbf40ee --- /dev/null +++ b/curios/harmony-connector/companion/entry/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "System-authorized H.264 screen sharing", + "main": "", + "author": "Trove", + "dependencies": { + "libharmony_stream.so": "file:./src/main/cpp/types/libharmony_stream" + } +} diff --git a/curios/harmony-connector/companion/entry/src/main/cpp/CMakeLists.txt b/curios/harmony-connector/companion/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..e0beb1e --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.5.0) +project(harmony_stream) +set(CMAKE_CXX_STANDARD 17) +add_library(harmony_stream SHARED stream.cpp) +target_compile_options(harmony_stream PRIVATE -Wall -Wextra) +target_link_libraries(harmony_stream PUBLIC libace_napi.z.so + libnative_avscreen_capture.so libnative_media_venc.so + libnative_media_codecbase.so libnative_media_core.so libnative_window.so) diff --git a/curios/harmony-connector/companion/entry/src/main/cpp/stream.cpp b/curios/harmony-connector/companion/entry/src/main/cpp/stream.cpp new file mode 100644 index 0000000..5804d6b --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/cpp/stream.cpp @@ -0,0 +1,354 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using Clock = std::chrono::steady_clock; +constexpr size_t MAX_PACKET = 2 * 1024 * 1024; +constexpr size_t MAX_QUEUE = 4 * 1024 * 1024; +constexpr int FPS = 30; + +struct Packet { + std::vector data; + uint32_t flags; + int64_t pts; +}; + +void put32(std::vector &out, uint32_t value) +{ + for (int shift = 24; shift >= 0; shift -= 8) out.push_back(value >> shift); +} + +// One worker owns all media resources and sockets. Callbacks only copy into a +// bounded queue; slow consumers terminate the session instead of accumulating delay. +class Stream { +public: + ~Stream() { stop(); } + void start(int width, int height, int port, std::string token) + { + stop(); + cancelled = false; + activated = false; + frames = 0; + bytes = 0; + { + std::lock_guard lock(mutex); + error.clear(); + state = "waiting"; + packets.clear(); + queuedBytes = 0; + } + worker = std::thread([=] { run(width, height, port, token); }); + } + void stop() + { + cancelled = true; + if (worker.joinable()) worker.join(); + } + napi_value status(napi_env env) + { + std::lock_guard lock(mutex); + napi_value result, value; + napi_create_object(env, &result); + for (auto item : {std::make_pair("state", state), std::make_pair("error", error)}) { + napi_create_string_utf8(env, item.second.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, item.first, value); + } + napi_create_double(env, static_cast(frames.load()), &value); + napi_set_named_property(env, result, "frames", value); + napi_create_double(env, static_cast(bytes.load()), &value); + napi_set_named_property(env, result, "bytes", value); + return result; + } + +private: + std::atomic cancelled{true}; + std::atomic activated{false}; + std::atomic frames{0}, bytes{0}; + std::mutex mutex; + std::thread worker; + std::string state = "stopped", error; + std::deque packets; + size_t queuedBytes = 0; + + void fail(const std::string &message) + { + std::lock_guard lock(mutex); + if (error.empty()) error = message; + cancelled = true; + } + bool check(int code, const char *operation) + { + if (!code) return true; + fail(std::string(operation) + " (" + std::to_string(code) + ")"); + return false; + } + static void captureState(OH_AVScreenCapture *, OH_AVScreenCaptureStateCode code, void *context) + { + auto &self = *static_cast(context); + if (code == OH_SCREEN_CAPTURE_STATE_STARTED) { + self.activated = true; + std::lock_guard lock(self.mutex); + self.state = "sharing"; + } else if (code == OH_SCREEN_CAPTURE_STATE_CANCELED) { + self.fail("手机未同意共享屏幕。"); + } else if (code == OH_SCREEN_CAPTURE_STATE_STOPPED_BY_USER || + code == OH_SCREEN_CAPTURE_STATE_INTERRUPTED_BY_OTHER || + code == OH_SCREEN_CAPTURE_STATE_STOPPED_BY_CALL || + code == OH_SCREEN_CAPTURE_STATE_STOPPED_BY_USER_SWITCHES) { + self.cancelled = true; + } + // Private scenes remain masked by the system; never substitute an old frame. + } + static void captureError(OH_AVScreenCapture *, int32_t code, void *context) + { + static_cast(context)->fail("Screen capture error (" + std::to_string(code) + ")"); + } + static void encoderError(OH_AVCodec *, int32_t code, void *context) + { + static_cast(context)->fail("Video encoder error (" + std::to_string(code) + ")"); + } + static void output(OH_AVCodec *encoder, uint32_t index, OH_AVBuffer *buffer, void *context) + { + auto &self = *static_cast(context); + OH_AVCodecBufferAttr attr{}; + const auto capacity = OH_AVBuffer_GetCapacity(buffer); + auto *data = OH_AVBuffer_GetAddr(buffer); + if (OH_AVBuffer_GetBufferAttr(buffer, &attr) != AV_ERR_OK || attr.offset < 0 || + attr.size < 0 || attr.size > static_cast(MAX_PACKET) || + static_cast(attr.offset) + attr.size > capacity || (!data && attr.size)) { + self.fail("编码器返回无效数据。"); + } else if (attr.size && !self.cancelled) { + Packet packet{{data + attr.offset, data + attr.offset + attr.size}, attr.flags, attr.pts}; + std::lock_guard lock(self.mutex); + if (self.packets.size() >= 6 || self.queuedBytes + packet.data.size() > MAX_QUEUE) { + self.error = "Mac 接收速度不足,共享已停止,请重新连接。"; + self.cancelled = true; + } else { + self.queuedBytes += packet.data.size(); + self.packets.push_back(std::move(packet)); + } + } + self.check(OH_VideoEncoder_FreeOutputBuffer(encoder, index), "release encoded buffer"); + } + bool io(int socket, uint8_t *data, size_t size, bool sendData, int timeoutMs) + { + auto deadline = Clock::now() + std::chrono::milliseconds(timeoutMs); + while (size && !cancelled && Clock::now() < deadline) { + pollfd p{socket, static_cast(sendData ? POLLOUT : POLLIN), 0}; + int ready = poll(&p, 1, 50); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0 || (ready && (p.revents & (POLLERR | POLLHUP | POLLNVAL)))) return false; + if (!ready) continue; + auto count = sendData ? send(socket, data, size, MSG_NOSIGNAL | MSG_DONTWAIT) + : recv(socket, data, size, MSG_DONTWAIT); + if (count < 0 && (errno == EINTR || errno == EAGAIN)) continue; + if (count <= 0) return false; + size -= count; + data += count; + } + return size == 0; + } + bool setup(OH_AVCodec *encoder, OH_AVScreenCapture *capture, OHNativeWindow **surface, int width, int height) + { + OH_AVCodecCallback callbacks{encoderError, [](OH_AVCodec *, OH_AVFormat *, void *) {}, + [](OH_AVCodec *, uint32_t, OH_AVBuffer *, void *) {}, output}; + if (!check(OH_VideoEncoder_RegisterCallback(encoder, callbacks, this), "encoder callbacks")) return false; + auto *format = OH_AVFormat_Create(); + if (!format) { fail("无法创建视频配置。"); return false; } + OH_AVFormat_SetIntValue(format, OH_MD_KEY_WIDTH, width); + OH_AVFormat_SetIntValue(format, OH_MD_KEY_HEIGHT, height); + OH_AVFormat_SetIntValue(format, OH_MD_KEY_PIXEL_FORMAT, AV_PIXEL_FORMAT_SURFACE_FORMAT); + OH_AVFormat_SetDoubleValue(format, OH_MD_KEY_FRAME_RATE, FPS); + OH_AVFormat_SetLongValue(format, OH_MD_KEY_BITRATE, 4000000); + OH_AVFormat_SetIntValue(format, OH_MD_KEY_VIDEO_ENCODE_BITRATE_MODE, BITRATE_MODE_CBR); + OH_AVFormat_SetIntValue(format, OH_MD_KEY_PROFILE, AVC_PROFILE_BASELINE); + OH_AVFormat_SetIntValue(format, OH_MD_KEY_I_FRAME_INTERVAL, 1000); + const int configured = OH_VideoEncoder_Configure(encoder, format); + OH_AVFormat_Destroy(format); + if (!check(configured, "configure encoder") || + !check(OH_VideoEncoder_GetSurface(encoder, surface), "encoder surface") || + !check(OH_VideoEncoder_Prepare(encoder), "prepare encoder") || + !check(OH_VideoEncoder_Start(encoder), "start encoder")) return false; + OH_AVScreenCaptureConfig config{}; + config.captureMode = OH_CAPTURE_HOME_SCREEN; + config.dataType = OH_ORIGINAL_STREAM; + config.videoInfo.videoCapInfo.videoSource = OH_VIDEO_SOURCE_SURFACE_RGBA; + config.videoInfo.videoCapInfo.videoFrameWidth = width; + config.videoInfo.videoCapInfo.videoFrameHeight = height; + return check(OH_AVScreenCapture_SetStateCallback(capture, captureState, this), "capture state") && + check(OH_AVScreenCapture_SetErrorCallback(capture, captureError, this), "capture errors") && + check(OH_AVScreenCapture_SetMicrophoneEnabled(capture, false), "disable microphone") && + check(OH_AVScreenCapture_Init(capture, config), "initialize capture") && + check(OH_AVScreenCapture_StartScreenCaptureWithSurface(capture, *surface), "request screen sharing"); + } + void run(int displayWidth, int displayHeight, int port, const std::string &token) + { + int listener = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0), client = -1; + OH_AVCodec *encoder = nullptr; + OH_AVScreenCapture *capture = nullptr; + OHNativeWindow *surface = nullptr; + bool mediaStarted = false; + auto perform = [&] { + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(port); + if (listener < 0 || bind(listener, reinterpret_cast(&address), sizeof(address)) || + listen(listener, 1)) { fail("无法建立共享连接,请在 Mac 重新开始。"); return; } + auto deadline = Clock::now() + std::chrono::seconds(45); + while (!cancelled && Clock::now() < deadline) { + pollfd p{listener, POLLIN, 0}; + if (poll(&p, 1, 100) <= 0) continue; + client = accept(listener, nullptr, nullptr); + if (client < 0) continue; + std::vector auth(64); + if (io(client, auth.data(), auth.size(), false, 2000) && + std::equal(auth.begin(), auth.end(), token.begin())) break; + close(client); + client = -1; + } + if (client < 0 || cancelled) { if (!cancelled) fail("Mac 连接超时,请重新开始。"); return; } + close(listener); + listener = -1; + const int noDelay = 1; + setsockopt(client, IPPROTO_TCP, TCP_NODELAY, &noDelay, sizeof(noDelay)); + auto *capability = OH_AVCodec_GetCapabilityByCategory(OH_AVCODEC_MIMETYPE_VIDEO_AVC, true, HARDWARE); + if (!capability) { fail("设备没有可用的 H.264 硬件编码器。"); return; } + int32_t widthAlignment = 0, heightAlignment = 0; + if (!check(OH_AVCapability_GetVideoWidthAlignment(capability, &widthAlignment), "width alignment") || + !check(OH_AVCapability_GetVideoHeightAlignment(capability, &heightAlignment), "height alignment")) return; + if (widthAlignment < 1 || heightAlignment < 1 || widthAlignment > 1280 || heightAlignment > 1280) { + fail("编码器返回不支持的画面尺寸约束。"); return; + } + widthAlignment = std::lcm(widthAlignment, 2); + heightAlignment = std::lcm(heightAlignment, 2); + const double scale = std::min(1.0, 1280.0 / std::max(displayWidth, displayHeight)); + const int width = static_cast(displayWidth * scale) / widthAlignment * widthAlignment; + const int height = static_cast(displayHeight * scale) / heightAlignment * heightAlignment; + if (width < 2 || height < 2 || !OH_AVCapability_IsVideoSizeSupported(capability, width, height)) { + fail("硬件编码器不支持当前屏幕尺寸。"); return; + } + std::vector hello{'H', 'C', 'S', '1'}; + for (auto value : {width, height, displayWidth, displayHeight, FPS}) put32(hello, value); + if (!io(client, hello.data(), hello.size(), true, 500)) return; + const char *encoderName = OH_AVCapability_GetName(capability); + if (!encoderName) { fail("设备未返回硬件编码器名称。"); return; } + encoder = OH_VideoEncoder_CreateByName(encoderName); + capture = OH_AVScreenCapture_Create(); + if (!encoder || !capture) { fail("设备无法创建录屏编码器。"); return; } + { std::lock_guard lock(mutex); state = "authorizing"; } + mediaStarted = setup(encoder, capture, &surface, width, height); + if (!mediaStarted) return; + auto lastFrame = Clock::now(); + while (!cancelled) { + if (activated.exchange(false) && + !check(OH_AVScreenCapture_SetMaxVideoFrameRate(capture, FPS), "frame rate")) break; + // Closing the host socket or sending 0 always stops capture. + uint8_t command; + const auto count = recv(client, &command, 1, MSG_DONTWAIT); + if (count == 0 || (count > 0 && command == 0)) break; + if (count < 0 && errno != EAGAIN && errno != EINTR) break; + Packet packet; + { + std::lock_guard lock(mutex); + if (!packets.empty()) { + packet = std::move(packets.front()); + packets.pop_front(); + queuedBytes -= packet.data.size(); + } + } + if (packet.data.empty()) { + if (Clock::now() - lastFrame > std::chrono::seconds(45)) { + fail("未收到视频画面,请确认手机上的共享授权。"); break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + continue; + } + std::vector header; + put32(header, packet.data.size()); + put32(header, packet.flags); + put32(header, static_cast(packet.pts) >> 32); + put32(header, static_cast(packet.pts)); + if (!io(client, header.data(), header.size(), true, 500) || + !io(client, packet.data.data(), packet.data.size(), true, 500)) break; + bytes += packet.data.size(); + if (!(packet.flags & AVCODEC_BUFFER_FLAGS_CODEC_DATA)) frames++; + lastFrame = Clock::now(); + } + }; + perform(); + cancelled = true; + if (capture) { + if (mediaStarted) OH_AVScreenCapture_StopScreenCapture(capture); + OH_AVScreenCapture_Release(capture); + } + if (encoder) { + if (mediaStarted) OH_VideoEncoder_NotifyEndOfStream(encoder); + OH_VideoEncoder_Stop(encoder); + OH_VideoEncoder_Destroy(encoder); + } + if (surface) OH_NativeWindow_DestroyNativeWindow(surface); + if (client >= 0) close(client); + if (listener >= 0) close(listener); + std::lock_guard lock(mutex); + packets.clear(); + queuedBytes = 0; + state = "stopped"; + } +}; + +Stream stream; +napi_value Start(napi_env env, napi_callback_info info) +{ + size_t argc = 4; + napi_value args[4]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + int32_t width = 0, height = 0, port = 0; + char token[65]{}; + size_t length = 0; + bool valid = argc == 4 && napi_get_value_int32(env, args[0], &width) == napi_ok && + napi_get_value_int32(env, args[1], &height) == napi_ok && + napi_get_value_int32(env, args[2], &port) == napi_ok && + napi_get_value_string_utf8(env, args[3], nullptr, 0, &length) == napi_ok && length == 64 && + napi_get_value_string_utf8(env, args[3], token, sizeof(token), &length) == napi_ok && + width >= 2 && height >= 2 && width <= 8192 && height <= 8192 && port >= 1024 && port <= 65535 && + std::all_of(token, token + 64, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }); + if (!valid) { napi_throw_error(env, "invalid_config", "请从 Mac 的 Harmony Connector 开始投屏。"); return nullptr; } + stream.start(width, height, port, std::string(token, 64)); + return nullptr; +} +napi_value Stop(napi_env, napi_callback_info) { stream.stop(); return nullptr; } +napi_value Status(napi_env env, napi_callback_info) { return stream.status(env); } +napi_value Init(napi_env env, napi_value exports) +{ + napi_property_descriptor methods[] = { + {"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"stop", nullptr, Stop, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"status", nullptr, Status, nullptr, nullptr, nullptr, napi_default, nullptr} + }; + napi_define_properties(env, exports, 3, methods); + napi_add_env_cleanup_hook(env, [](void *) { stream.stop(); }, nullptr); + return exports; +} +napi_module module = {1, 0, nullptr, Init, "harmony_stream", nullptr, {0}}; +__attribute__((constructor)) void Register() { napi_module_register(&module); } +} // namespace diff --git a/curios/harmony-connector/companion/entry/src/main/cpp/types/libharmony_stream/index.d.ts b/curios/harmony-connector/companion/entry/src/main/cpp/types/libharmony_stream/index.d.ts new file mode 100644 index 0000000..613c3df --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/cpp/types/libharmony_stream/index.d.ts @@ -0,0 +1,9 @@ +export interface StreamStatus { + state: string; + error: string; + frames: number; + bytes: number; +} +export const start: (width: number, height: number, port: number, token: string) => void; +export const stop: () => void; +export const status: () => StreamStatus; diff --git a/curios/harmony-connector/companion/entry/src/main/cpp/types/libharmony_stream/oh-package.json5 b/curios/harmony-connector/companion/entry/src/main/cpp/types/libharmony_stream/oh-package.json5 new file mode 100644 index 0000000..03734e4 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/cpp/types/libharmony_stream/oh-package.json5 @@ -0,0 +1,5 @@ +{ + "name": "libharmony_stream.so", + "version": "0.1.0", + "types": "./index.d.ts" +} diff --git a/curios/harmony-connector/companion/entry/src/main/ets/entryability/EntryAbility.ets b/curios/harmony-connector/companion/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..3948288 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for details. + * Modified for Harmony Connector by Trove. + */ +import { UIAbility, Want } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; +import stream from 'libharmony_stream.so'; + +export default class EntryAbility extends UIAbility { + private configure(want: Want): void { + const token = want.parameters?.['token']; + const port = want.parameters?.['port']; + if (typeof token === 'string' && typeof port === 'number' && + /^[a-f0-9]{64}$/.test(token) && port >= 1024 && port <= 65535) { + stream.stop(); + AppStorage.setOrCreate('streamToken', token); + AppStorage.setOrCreate('streamPort', port); + } + } + onCreate(want: Want): void { this.configure(want); } + onNewWant(want: Want): void { this.configure(want); } + onWindowStageCreate(stage: window.WindowStage): void { + stage.loadContent('pages/Index').catch(() => { stream.stop(); }); + } + onDestroy(): void { stream.stop(); } +} diff --git a/curios/harmony-connector/companion/entry/src/main/ets/pages/Index.ets b/curios/harmony-connector/companion/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..8014c17 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for details. + * Modified for Harmony Connector by Trove. + */ +import { common, wantAgent } from '@kit.AbilityKit'; +import { display } from '@kit.ArkUI'; +import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; +import stream from 'libharmony_stream.so'; + +@Entry +@Component +struct Index { + @StorageLink('streamToken') token: string = ''; + @StorageLink('streamPort') port: number = 0; + @State active: boolean = false; + @State message: string = '在 Mac 上连接手机并开始投屏。'; + @State stats: string = ''; + private timer: number = 0; + private backgroundRunning: boolean = false; + private keeping: boolean = false; + private generation: number = 0; + private displayWidth: number = 0; + private displayHeight: number = 0; + private previousFrames: number = 0; + private previousBytes: number = 0; + private previousTime: number = 0; + + private context(): common.UIAbilityContext { + return this.getUIContext().getHostContext() as common.UIAbilityContext; + } + private async keepRunning(): Promise { + if (this.backgroundRunning || this.keeping) return; + this.keeping = true; + const generation = this.generation; + try { + const agent = await wantAgent.getWantAgent({ + wants: [{ bundleName: 'ink.render.trove.harmonyconnector', abilityName: 'EntryAbility' }], + actionType: wantAgent.OperationType.START_ABILITY, + requestCode: 1, + actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] + }); + if (!this.active || generation !== this.generation) return; + await backgroundTaskManager.startBackgroundRunning(this.context(), + backgroundTaskManager.BackgroundMode.AUDIO_RECORDING, agent); + this.backgroundRunning = true; + if (!this.active || generation !== this.generation) await this.stop(); + } catch (error) { + if (this.active) { + await this.stop(); + this.message = `无法保持后台共享:${error.message}`; + } + } finally { this.keeping = false; } + } + private async stop(): Promise { + this.generation++; + stream.stop(); + this.active = false; + this.stats = ''; + if (this.backgroundRunning) { + this.backgroundRunning = false; + try { await backgroundTaskManager.stopBackgroundRunning(this.context()); } catch (_) {} + } + this.message = '共享已停止。可在 Mac 上重新开始。'; + } + private start(): void { + try { + const screen = display.getDefaultDisplaySync(); + this.displayWidth = screen.width; + this.displayHeight = screen.height; + this.previousTime = Date.now(); + this.previousFrames = 0; + this.previousBytes = 0; + this.generation++; + stream.start(screen.width, screen.height, this.port, this.token); + this.active = true; + this.message = '正在连接 Mac,请同意系统的屏幕共享请求。'; + } catch (error) { this.message = error.message; } + } + private update(): void { + if (!this.active) return; + const status = stream.status(); + if (status.state === 'stopped') { + this.stop().then(() => { if (status.error) this.message = status.error; }); + return; + } + if (status.state !== 'sharing') return; + let screen: display.Display; + try { screen = display.getDefaultDisplaySync(); } catch (_) { this.stop(); return; } + if (screen.width !== this.displayWidth || screen.height !== this.displayHeight) { + this.stop().then(() => { this.message = '屏幕方向已改变,请在 Mac 重新开始投屏。'; }); + return; + } + this.keepRunning(); + const now = Date.now(); + const seconds = (now - this.previousTime) / 1000; + this.message = '正在共享屏幕'; + if (seconds >= 1) { + this.stats = `${((status.frames - this.previousFrames) / seconds).toFixed(1)} 帧/秒 · ${((status.bytes - this.previousBytes) * 8 / seconds / 1000000).toFixed(2)} Mbps`; + this.previousTime = now; + this.previousFrames = status.frames; + this.previousBytes = status.bytes; + } + } + aboutToAppear(): void { this.timer = setInterval(() => this.update(), 250); } + aboutToDisappear(): void { clearInterval(this.timer); this.stop(); } + + build() { + Column({ space: 24 }) { + Text('Harmony Connector').fontSize(30).fontWeight(FontWeight.Bold) + Text(this.message).fontSize(18).textAlign(TextAlign.Center) + Text('通过 USB 将画面共享到 Mac。系统会遮挡受保护的内容,麦克风始终关闭。') + .fontSize(15).fontColor('#64748B').textAlign(TextAlign.Center) + if (this.stats) { Text(this.stats).fontSize(16) } + Button(this.active ? '停止共享' : '共享屏幕') + .enabled(this.active || (this.token.length === 64 && this.port > 0)) + .onClick(() => { if (this.active) this.stop(); else this.start(); }) + Text('共享期间可随时返回这里停止,也可在系统录屏通知中停止。') + .fontSize(13).fontColor('#64748B').textAlign(TextAlign.Center) + } + .width('100%').height('100%').padding(32).justifyContent(FlexAlign.Center) + } +} diff --git a/curios/harmony-connector/companion/entry/src/main/module.json5 b/curios/harmony-connector/companion/entry/src/main/module.json5 new file mode 100644 index 0000000..c90a38e --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/module.json5 @@ -0,0 +1,47 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "ohos.want.action.home" + ] + } + ], + "backgroundModes": [ + "audioRecording" + ] + } + ], + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + }, + { + "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" + } + ] + } +} diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/element/color.json b/curios/harmony-connector/companion/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..3c71296 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/element/float.json b/curios/harmony-connector/companion/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..33ea223 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/element/string.json b/curios/harmony-connector/companion/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..96f6f79 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "通过 USB 将手机画面共享到 Mac" + }, + { + "name": "EntryAbility_desc", + "value": "通过 USB 将手机画面共享到 Mac" + }, + { + "name": "EntryAbility_label", + "value": "Harmony Connector" + } + ] +} diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/media/background.png b/curios/harmony-connector/companion/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/curios/harmony-connector/companion/entry/src/main/resources/base/media/background.png differ diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/media/foreground.png b/curios/harmony-connector/companion/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/curios/harmony-connector/companion/entry/src/main/resources/base/media/foreground.png differ diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/media/layered_image.json b/curios/harmony-connector/companion/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/media/startIcon.png b/curios/harmony-connector/companion/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/curios/harmony-connector/companion/entry/src/main/resources/base/media/startIcon.png differ diff --git a/curios/harmony-connector/companion/entry/src/main/resources/base/profile/main_pages.json b/curios/harmony-connector/companion/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..1898d94 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/curios/harmony-connector/companion/entry/src/main/resources/dark/element/color.json b/curios/harmony-connector/companion/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..79b11c2 --- /dev/null +++ b/curios/harmony-connector/companion/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#000000" + } + ] +} \ No newline at end of file diff --git a/curios/harmony-connector/companion/hvigor/hvigor-config.json5 b/curios/harmony-connector/companion/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..2140b7a --- /dev/null +++ b/curios/harmony-connector/companion/hvigor/hvigor-config.json5 @@ -0,0 +1,23 @@ +{ + "modelVersion": "26.0.0", + "dependencies": { + }, + "execution": { + // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + // "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + } +} diff --git a/curios/harmony-connector/companion/hvigorfile.ts b/curios/harmony-connector/companion/hvigorfile.ts new file mode 100644 index 0000000..8890d28 --- /dev/null +++ b/curios/harmony-connector/companion/hvigorfile.ts @@ -0,0 +1,7 @@ +// @ts-nocheck – Template file, only used when copied into a project directory +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks /* Built-in plugin of Hvigor. It cannot be modified. */, + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */, +}; diff --git a/curios/harmony-connector/companion/legal/Apache-2.0.txt b/curios/harmony-connector/companion/legal/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/curios/harmony-connector/companion/legal/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/curios/harmony-connector/companion/legal/NOTICE.md b/curios/harmony-connector/companion/legal/NOTICE.md new file mode 100644 index 0000000..4fc1350 --- /dev/null +++ b/curios/harmony-connector/companion/legal/NOTICE.md @@ -0,0 +1,17 @@ +# Template attribution + +The initial HarmonyOS Empty Ability project was generated with Huawei's official +`@deveco/deveco-cli` 1.3.0-stable on 2026-09-10. Generated project configuration, +resource images, and the initial ArkTS entry/page came from that template. + +The generated ArkTS sources carry: + +Copyright (c) 2026 Huawei Device Co., Ltd. + +Licensed under the Apache License, Version 2.0. A copy is provided in +`Apache-2.0.txt`. + +Trove replaced the sample page and ability lifecycle with Harmony Connector's +screen-sharing interface and lifecycle. The native screen capture/transport +module and Mac measurement tools were written for this repository. No DevEco +private screen agent or phone system library is redistributed. diff --git a/curios/harmony-connector/companion/oh-package.json5 b/curios/harmony-connector/companion/oh-package.json5 new file mode 100644 index 0000000..7ae4fd7 --- /dev/null +++ b/curios/harmony-connector/companion/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "modelVersion": "26.0.0", + "description": "Harmony Connector screen sharing companion", + "dependencies": {}, + "devDependencies": {} +} diff --git a/docs/harmony-realtime-development.md b/docs/harmony-realtime-development.md new file mode 100644 index 0000000..b9a6405 --- /dev/null +++ b/docs/harmony-realtime-development.md @@ -0,0 +1,78 @@ +# Harmony Connector 连续视频开发验证 + +当前为待真机验证的开发组件,尚未接入插件播放器,也未发布新插件版本。已发布的 0.3.2 仍使用逐帧截图。不要将本篇的编码配置或本机生成视频测试结果描述成手机实测性能。 + +## 已完成与待验证 + +2026-09-10 的原链路真机采集耗时为 696、803、802 ms,约 1.3 fps。截图命令、PNG 文件回传和图像转换是逐帧执行的;调整界面定时器不足以实现实时视频。 + +新增源码: + +- `curios/harmony-connector/companion`:官方 Empty Ability 模板上的普通手机应用。用户点击共享后,经系统授权,将 AVScreenCapture Surface 接到 H.264 编码器。初始配置最长边 1280、30 fps、4 Mbps CBR、Baseline、1 秒关键帧间隔,不采集音频。 +- `tools/harmony-stream.py`:独立 Mac 开发验证程序,建立本会话 HDC 转发、启动组件、接收与解码视频,输出接收帧率、解码帧数和码率。默认不保存画面文件、不发送输入。`--view` 用于人工观看。 +- `tools/harmony-stream.test.py`:协议尺寸与长度限制、断流、超时、非 H.264 数据,以及真正经过 FFmpeg 编码与解码的 20 帧生成视频测试。 + +已使用官方 Command Line Tools 26.0.0.821 编译出 arm64-v8a 未签名 HAP,包含 ArkTS 和本地 C++ 动态库;编译兼容级别 API 17,使用的采集接口最低 API 14。编译成功不代表商业设备运行授权与编码能力已经验证。 + +签名工具已登录,但调用 `signature generate` 返回账号尚未完成开发者实名认证。尚未安装伴随应用、取得真机 H.264 视频、测量呈现帧率或端到端延迟。 + +## 构建与签名 + +需要完整官方 Command Line Tools、Node.js 22+、官方 `@deveco/deveco-cli@1.3.0-stable`。`DEVECO_CLI_CLT_PATH` 指向包含 `sdk`、`tool`、`hvigor` 的 `command-line-tools` 根目录,不是单独的 HDC 所在目录。 + +```sh +cd curios/harmony-connector/companion +# 首次准备;已有配置时保留本机签名。 +test -f build-profile.json5 || cp build-profile.template.json5 build-profile.json5 + +# 预先按本机实际路径设置 DEVECO_CLI_CLT_PATH。 +npx --yes @deveco/deveco-cli@1.3.0-stable build --modules entry --build-mode debug +``` + +得到 `entry/build/default/outputs/default/entry-default-unsigned.hap`。真实手机安装必须有合法签名;需要已实名认证的华为开发者账号,按官方流程配置可调试设备。认证入口是 [AppGallery Connect](https://developer.huawei.com/consumer/cn/service/josp/agc/index.html),见[官方开发准备](https://developer.huawei.com/consumer/cn/appgallery/devstart/)。 + +```sh +npx --yes @deveco/deveco-cli@1.3.0-stable auth login +npx --yes @deveco/deveco-cli@1.3.0-stable signature generate --product default +npx --yes @deveco/deveco-cli@1.3.0-stable build --modules entry --build-mode debug +``` + +`build-profile.json5`、签名证书、profile 和密码材料均属于本机文件,不提交到仓库。模板 `build-profile.template.json5` 不包含签名配置。不要将绑定调试设备的 HAP 当作所有用户均可安装的发行包;正式手机组件需要发布者签名和正式分发流程。 + +## 真机验证 + +先确认 `hdc list targets -v` 显示目标为 `Connected`。安装刚构建的 **signed** HAP,包名为 `ink.render.trove.harmonyconnector`。安装应选择明确目标,不自动安装到其他设备。 + +在仓库根目录执行以下命令,将 HDC 路径替换为本机工具路径;连接多台设备时另加 `--target`: + +```sh +python3 tools/harmony-stream.py --hdc /absolute/path/to/hdc --seconds 20 +# 人工观看连续画面;需 ffplay。 +python3 tools/harmony-stream.py --hdc /absolute/path/to/hdc --seconds 20 --view +``` + +运行时在手机点击「共享屏幕」,同意系统请求,滚动页面或移动窗口产生持续变化。默认验证需要 FFmpeg;它只用于开发测量,未引入生产插件依赖。 + +接收帧率目标至少 20 fps,默认解码模式同时要求解码帧数达到收到视频帧数的 95%。这些只是开发验收门槛,不能代替端到端延迟测量。`--view` 的 FFplay 不提供解码帧计数,需另外运行默认模式;人工确认的流畅程度应单独记录。静态画面可能由系统抑制重复帧,性能测试必须保持画面变化。 + +停止共享、系统拒绝、拔线、关闭验证程序都必须终止手机采集并清理仅本会话的转发。当前原型在屏幕尺寸变化时停止共享,提示重新开始;自动旋转重建、插件播放器集成及流式会话中的输入验证仍待完成。 + +## 传输与资源边界 + +手机仅监听 IPv4 loopback,由启动端提供随机远端端口和 32 字节会话随机值。连接后首先发送 64 个小写十六进制字符进行会话校验,不通过则断开。正常用户操作仍需手机点击共享以及系统录屏授权。 + +验证成功后,手机发送固定 24 字节头:`HCS1` + 5 个大端 u32,依次为编码宽、高、屏幕宽、高、配置帧率。随后每包为大端 `u32 payloadLength + u32 flags + i64 ptsUs + Annex B H.264 payload`,沿用公共编码器的 buffer flags。SPS/PPS 配置数据与编码帧均按输出顺序保留。 + +单包上限 2 MiB;采集回调复制数据后归还编码器 buffer。发送队列上限 6 包和 4 MiB,超过就结束会话,不积压旧帧,也不任意丢弃参考帧。Socket 发送超时 500 ms。主机发送一个零字节或断开连接可停止采集。系统停止与错误统一由资源所属线程收尾,释放 capture、encoder、surface、socket。 + +这仍是开发协议,尚未替代正式产品的配对、版本协商和播放器生命周期设计。普通录屏授权也不会赋予任意输入注入权限;后续控制需复用已授权的 UITest 能力并验证响应。 + +## 自动化验证 + +```sh +python3 tools/harmony-stream.test.py -v +``` + +测试不连接手机;使用本地 socket pair 验证接收行为,使用生成图案验证实际 H.264 解码。当前 8 项通过,20 帧均成功解码。它们不证明手机端授权、编码吞吐、后台保活或 USB 实时性。 + +API、权限和被拒绝的无安装路线见[连续视频调研](research/harmony-realtime-stream.md)。 diff --git a/docs/research/harmony-realtime-stream.md b/docs/research/harmony-realtime-stream.md new file mode 100644 index 0000000..21778e9 --- /dev/null +++ b/docs/research/harmony-realtime-stream.md @@ -0,0 +1,159 @@ +# Harmony Connector 实时视频流调研 + +调研日期:2026-09-10。承接 [投屏与控制调研](harmony-screen-control.md),本篇只核查替换逐张截图链路所需的公开 API、进程授权和构建条件。文献研究与本机集成验证分别记录;尚未验证真机 H.264 视频性能。 + +## 结论与实现顺序 + +真正的视频流应为 **AVScreenCapture → VideoEncoder 输入 Surface → H.264 输出回调 → 持续 socket → 主机解码**。公开 C API 足以表达这个管线,包含系统录屏授权、帧率控制和编码输出;HDC 只负责连接和转发。`OH_ENCODED_STREAM` 虽然存在于枚举,但当前文档明确尚不支持,不能只修改枚举便取得 H.264。[采集数据类型](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-base-h.md)、[Surface 视频编码](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding.md) + +最小验证先尝试无音频、限时停止的普通 shell 原生程序。**没有查到“C API 必须有 UIAbilityContext”的限制;也没有官方文档保证商业手机允许 shell 进程完成录屏授权。** 这必须由实际返回码、系统弹窗和首个可解码帧判定,不能把“未验证”写成“必须安装 HAP”。公开接口不接受 UIAbilityContext;OpenHarmony 媒体服务会自行启动授权窗口。本次集成测试在程序执行阶段已遇到权限拒绝,详见下文,尚未到达 API 授权阶段。[公共 C API](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/interfaces/kits/c/native_avscreen_capture.h)、[授权窗口调用](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/services/services/screen_capture/server/screen_capture_server.cpp#L2174) + +UITest addon 的公开源码扩展仍仅提供连续 JPEG,没有 H.264 入口。可以验证其长连接 JPEG 是否明显改善当前每帧启动截图命令和下载文件的开销,但不得称为 H.264 视频通道。[扩展 ABI](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_c_api.h)、[采集分派](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L305) + +## 本地 SDK 核查 + +本机下载的官方 `commandline-tools-mac-arm64-26.0.0.821.zip` 包含以下公共文件,研究直接读取 ZIP 中的头文件核查声明,不依赖论坛示例: + +```text +command-line-tools/sdk/default/openharmony/native/sysroot/usr/include/ + multimedia/player_framework/native_avscreen_capture.h + multimedia/player_framework/native_avscreen_capture_base.h + multimedia/player_framework/native_avcodec_videoencoder.h + multimedia/player_framework/native_avcodec_base.h + multimedia/player_framework/native_avformat.h + multimedia/player_framework/native_avbuffer.h + multimedia/player_framework/native_avbuffer_info.h +``` + +头文件及 aarch64 stub 库存在,只证明可以编译和链接,不证明目标手机提供全部 API 或允许该进程采集。应优先使用满足管线的较早公开 API,通过实际返回值检查能力;不能仅因 SDK 是 26.0.0 就要求手机同版本。 + +| 能力 | 公共接口 | 起始 API | +| --- | --- | --- | +| 创建、初始化、停止、释放 | `OH_AVScreenCapture_Create/Init/StopScreenCapture/Release` | 10 | +| 状态、错误事件 | `OH_AVScreenCapture_SetStateCallback/SetErrorCallback` | 12 | +| 向编码器 Surface 采集 | `OH_AVScreenCapture_StartScreenCaptureWithSurface` | 12 | +| 限制采集帧率 | `OH_AVScreenCapture_SetMaxVideoFrameRate`,启动后调用,最高 60 | 14 | +| 编码器输出 buffer 回调 | `OH_VideoEncoder_RegisterCallback`、`OH_AVCodecCallback` | 11 | +| 编码器输入 Surface | `OH_VideoEncoder_GetSurface` | 9 | +| 编码 B 帧开关 | `OH_MD_KEY_VIDEO_ENCODER_ENABLE_B_FRAME`,默认关闭 | 20 | +| 手机、平板可选屏幕 Picker | `OH_AVScreenCapture_StrategyForPickerPopUp` | 23 | + +以上声明可对照 [采集 API](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-h.md)、[视频编码器 API](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-avcodec-kit/capi-native-avcodec-videoencoder-h.md)、[华为 Picker 条件](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/using-avscreencapture-for-file-with-window)。普通录屏授权弹窗与 API 23 的屏幕/窗口选择器是两项能力;不能以设备没有后者判断无法授权录屏。 + +## 授权:先验证 shell,不绕过系统授权 + +本地 SDK 的 `SetStateCallback` 声明要求在启动前注册;说明录屏开始前由系统请求用户同意,实际启动成功回调 `OH_SCREEN_CAPTURE_STATE_STARTED`,拒绝回调 `OH_SCREEN_CAPTURE_STATE_CANCELED`。即便 `StartScreenCaptureWithSurface` 返回成功,也只能表示启动请求被接受,必须继续等待状态和编码数据。[状态回调 API](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-h.md#oh_avscreencapture_setstatecallback) + +OpenHarmony 当前源码调用链提供了可检验的依据: + +1. `ScreenCaptureImpl::StartScreenCaptureWithSurface` 在 `ORIGINAL_STREAM` 模式内部启用隐私授权,再进入服务端。无需调用内部的 `SetPrivacyAuthorityEnabled`,它不是本地公共 C 头文件中的接口。[客户端实现](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/frameworks/native/screen_capture/screen_capture_impl.cpp#L397) +2. 服务端从 IPC 取得真实 UID、PID、token,并根据 UID 查询应用包名。普通 UID 进入用户授权逻辑;启用授权时请求系统窗口,未启用授权时才检查 `CAPTURE_SCREEN`。不是要求应用预先拥有免弹窗权限。[调用者与权限](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/services/services/screen_capture/server/screen_capture_server.cpp#L1288) +3. 授权请求带 `sessionId`、`callerUid`、`appLabel`,由媒体服务连接系统扩展组件,调用传入的应用 token 为 `nullptr`。已核查的链路未见必须是 HAP token 的检查。[窗口参数](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/services/services/screen_capture/server/screen_capture_server.cpp#L2058) + +因此 shell 程序在 API 形态上可尝试,**商业固件能否为 shell UID 建立授权弹窗、通知和实际采集仍未知**。最小验证应记录 create/init/configure/getSurface/start 的逐步返回、状态回调、编码输出数量,以及停止结果;设置有限等待与退出。遇到权限拒绝即停止,不使用 root、系统签名、权限白名单或调用系统内部免授权接口补救。 + +若 shell 路径失败,可把同一 C++ 管线放入普通 HAP 的 native 模块,从手机页面按钮发起。这样调用者具有正常包名和应用生命周期,符合官方示例的应用使用方式,但仍需真机验收,不能从源码推导所有商业设备必然成功。[华为取流实践](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-media-46) + +## 最小采集与编码契约 + +以下是本项目组装的流程,不是已运行的完整示例。每个返回值均需检查;回调中的停止和释放应派发到管理线程,避免同步销毁回调所属对象。 + +```text +encoder = OH_VideoEncoder_CreateByMime(OH_AVCODEC_MIMETYPE_VIDEO_AVC) +OH_VideoEncoder_RegisterCallback(encoder, callbacks, session) +format = OH_AVFormat_Create() +设置目标宽高、RGBA surface 输入、帧率、CBR、码率、GOP +OH_VideoEncoder_Configure(encoder, format) +OH_VideoEncoder_GetSurface(encoder, &window) +OH_VideoEncoder_Prepare(encoder) +OH_VideoEncoder_Start(encoder) + +capture = OH_AVScreenCapture_Create() +OH_AVScreenCapture_SetStateCallback(capture, onState, session) +OH_AVScreenCapture_SetErrorCallback(capture, onError, session) +config = 全部初始化为零 +config.captureMode = OH_CAPTURE_HOME_SCREEN +config.dataType = OH_ORIGINAL_STREAM +config.videoInfo.videoCapInfo.videoSource = OH_VIDEO_SOURCE_SURFACE_RGBA +config.videoInfo.videoCapInfo.videoFrameWidth/Height = 目标宽高 +OH_AVScreenCapture_SetMicrophoneEnabled(capture, false) +OH_AVScreenCapture_Init(capture, config) +OH_AVScreenCapture_StartScreenCaptureWithSurface(capture, window) +等待 onState(STARTED),再 OH_AVScreenCapture_SetMaxVideoFrameRate(capture, 30) +``` + +Surface 模式无需 `OnNeedInputBuffer` 填充像素,无需每帧 PNG 文件和截图下载。Surface 来自编码器并必须在 `Configure` 之后、`Prepare` 之前获取;窗口生命周期由应用管理。音频采集采样率及通道数同时为零表示忽略音频,首版明确关闭麦克风,不索取麦克风权限。[Surface 编码流程](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding.md)、[无音频条件](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-avscreencapture-oh-audiocaptureinfo.md) + +输出回调为 `onNewOutputBuffer(OH_AVCodec*, uint32_t index, OH_AVBuffer*, void*)`。通过 `OH_AVBuffer_GetBufferAttr/GetAddr/GetCapacity` 检查 offset、size、PTS、flags;复制合法范围的数据后尽快 `OH_VideoEncoder_FreeOutputBuffer(encoder,index)`。参数集、同步帧和普通帧按原始顺序传输,不能随便丢弃依赖帧;遇到积压丢弃到下一关键帧并请求 IDR。`OH_AVCodecBufferAttr.pts` 是微秒,不能与网络到达时间混为端到端时延。[编码数据读取](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding.md)、[buffer 属性](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-avcodec-kit/capi-core-oh-avcodecbufferattr.md) + +推荐链接公共库 `libnative_avscreen_capture.so`、`libnative_media_venc.so`、`libnative_media_codecbase.so`、`libnative_media_core.so`、`libnative_window.so`。由实际使用符号补充 native buffer 依赖;不复制设备私有库或 DevEco 私有 agent。[采集依赖](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/media/avscreencapture-c-basic-process.md)、[编码依赖](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding-configuration-typical-scenarios.md) + +停止顺序应先停止采集输入,通知编码器 EOS 并有界等待,释放采集和编码器、销毁 NativeWindow,再关闭 socket 和本会话转发;拒绝、错误、断线也走相同可重入清理路径。不同旋转尺寸先更新主机几何信息,暂停旧坐标输入,再重建需要变更尺寸的编码器。 + +## 低延迟配置与实际指标 + +官方把短距投屏列为低时延编码场景,推荐 CBR、根据目标分辨率/帧率设码率,并支持运行期通过 `OH_MD_KEY_REQUEST_I_FRAME` 请求 IDR。这里建议先从最长边 1280、30 fps、H.264、CBR 4 Mbps、1 秒 GOP 开始测量;这些数值是原型起点,不是官方对该手机的性能保证。[典型场景编码配置](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding-configuration-typical-scenarios.md) + +`OH_MD_KEY_VIDEO_ENABLE_LOW_LATENCY` 名字易产生误解:公共 SDK 将其定义为**视频解码**配置,本研究未确认它对编码器有效。编码 B 帧默认关闭,应保留此行为以避免显示顺序重排。API 26 新增的 CBRHQ 应先通过编码能力查询,不能直接强制较旧设备使用。[编解码参数声明](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-avcodec-kit/capi-native-avcodec-base-h.md) + +工程上应将采集、编码回调和 socket 发送分开:发送线程使用有界队列、超时与取消;不能在编码回调等待网络。主机也需要持续解码并只渲染最新可用画面,避免 30 fps 视频经慢速 JSON/base64 轮询再次积压。指标分别记录编码帧率、收到帧率、呈现帧率、码率、排队时长、首帧时间;没有时钟同步或可视运动实验时,不把采集耗时称为端到端延迟。 + +## UITest 扩展:可优化的 JPEG 路线 + +当前锁定的 OpenHarmony UITest 提交与上一轮一致:`b04e30c40cf266c9abfbd52933e43a0456eb99a2`。 + +- `startCapture("copyScreen", callback, options)` 读取 `scale`、`displayId`,内部将 screenshot PixelMap 转为 JPEG。`scale` 对外检查严格 `0 < scale < 1`。 +- `screen_copy.cpp` 使用独立截图和编码线程,以最近帧替换前帧,只在图像变化时回调。源码中的 50 ms sleep 用于熄屏轮询,**不是运行态固定 20 fps 上限**。 +- 回调参数是临时 `Text`,执行完立即 `free(data)`,异步发送必须先复制;不能缓存裸指针。 +- `LowLevelFunctions` 没有获取原始 PixelMap、编码器 Surface 或 H.264 的函数。额外直接调用 AVScreenCapture 属于独立的公共 API 路线,不是 UITest 已提供 H.264。 + +[JPEG 实现](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp)、[回调生命周期](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L305)、[ABI](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_c_api.h) + +## 若需伴随 HAP:最小应用职责 + +ArkTS 仅负责正常应用入口、开始/停止共享、授权状态和后台任务;C++ 负责采集、编码及 loopback TCP。录屏授权不赋予任意输入注入权,控制继续复用已授权 HDC 的 UITest。若使用 socket,应用声明普通 `ohos.permission.INTERNET`;仅监听 loopback,并要求本会话随机 token。不要为了投屏申请系统的免授权采集或输入注入权限。 + +持续录屏退后台需要合法长时任务,否则应用可能挂起。官方任务类型表将 `AUDIO_RECORDING`/`audioRecording` 对应录音、录屏退后台,声明 `ohos.permission.KEEP_BACKGROUND_RUNNING` 并在共享期间保持可见通知,停止时结束任务;`taskKeeping` 在手机上有额外受限权限条件,不适合作为默认捷径。[长时任务规范](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/task-management/continuous-task.md)、[华为取流与后台说明](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-media-46) + +真机 HAP 安装必须签名。调试 profile 绑定包名、证书和允许调试的设备;DevEco Studio 自动签名或 AGC 手动签名均有官方流程。下载 Command Line Tools 和网页登录本身没有生成可安装的签名 HAP;不得用系统样例证书假装适用于商业手机。需要 HAP 时先完成可构建源代码,再配置有效调试签名并进行真机授权测试。[自动签名](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-signing-auto)、[手动签名](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-signing-manual) + +### 官方 CLI 自动签名路径 + +华为官网推荐 `@deveco/deveco-cli@stable`,1.3.0 版本新增自动调试签名;本次查询 npm stable 标签为 `1.3.0-stable`。可以在隔离的工具目录安装,使用 Node 22 及以上,配置 `DEVECO_CLI_CLT_PATH` 为完整解压的 Command Line Tools 根目录。只解压 HDC 与 native SDK 不构成该 CLI 所需完整工具链。[官网工具入口](https://developer.huawei.com/consumer/cn/deveco-studio)、[1.3.0 版本说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-deveco-cli-releasenote)、[官方 npm 包](https://www.npmjs.com/package/@deveco/deveco-cli) + +官方包 README 给出的流程如下;本轮已尝试签名,结果见集成记录: + +```text +devecocli auth status +devecocli auth login # 未登录时打开浏览器授权 +devecocli create --app-name HarmonyConnector --bundle-name <本项目包名> --project-path <工程目录> +# 在完成代码的工程目录: +devecocli signature generate --product default +devecocli build --modules entry --build-mode debug +``` + +`signature generate` 生成 p12、csr、p7b、cer 并写工程 `build-profile.json5`。默认使用用户自身团队;需要团队时指定已查询的 team-id,不虚构;已有签名无需加 `--force`。CLI 登录目前不支持海外账户。它的脚手架只提供 Empty Ability,native 模块仍需项目自行配置。[官方 CLI 文档](https://www.npmjs.com/package/@deveco/deveco-cli) + +## 本次集成验证的已知边界 + +同一任务的真机执行方报告:原链路连续 3 帧耗时约 696、803、802 ms,约为 1.3 fps;此为采集周期,不是端到端时延。使用公共 NDK 编译的 `aarch64-linux-ohos` 采集原型被传到本会话独有的 `/data/local/tmp/` 路径并设置执行权限,直接启动返回 `Permission denied`。HDC 自身退出状态为 0,不代表设备端命令成功。程序未执行任何采集 API,未显示授权弹窗,因此本次证据只定位到执行权限边界,不能证明 AVScreenCapture 被拒绝。 + +UITest addon 未进入 `OnInit`。随后读取设备日志确认:MUSL-LDSO 报 `Xpm check failed`、`errno for mmap is:13`,UiTestKit_Addon 的 `dlopen` 返回 `Permission denied`。这将本次失败定位到临时未签名模块的加载校验,不能解释为 ABI 不兼容;没有尝试关闭或绕过 XPM。临时程序、模块和手机上的本会话文件均已清理。 + +华为公开 LLDB 调试矩阵将 user 镜像、SELinux 开启场景的支持范围列为签名 debug HAP;并区分其与 root 镜像上的普通可执行文件调试。它与上述限制相符,但不能仅凭该文档判定具体拒绝来自 SELinux、代码签名还是其他策略。继续走正常应用签名路径,不以关闭安全策略推进。[华为 Native 调试条件](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/debug-lldb) + +## 最小验收 + +1. 普通 shell 程序是否能创建采集和编码器、显示系统授权;分别验证同意、拒绝和超时。 +2. 取得 H.264 参数集和关键帧,并在 Mac 成功解码连续变化画面。 +3. 验证屏幕旋转、锁屏、隐私画面、断线和用户主动停止,保留系统遮挡行为。 +4. 持续运行时记录呈现帧率与排队增长;同时验证输入后画面及时变化。 +5. 会话结束不再采集、不再注入输入;清理本会话的临时程序/文件和端口转发。 + +公开源码和 SDK 只能支撑上述实现契约,不能替代 Pura 70 Ultra 的实际授权、解码与延迟验收。 + +## 伴随应用构建进展 + +集成方已创建普通手机应用 `ink.render.trove.harmonyconnector`,实现系统授权的 Surface 采集、H.264 编码、带会话校验的 loopback TCP 和停止收尾。官方 CLI 已成功构建 ArkTS 与 arm64-v8a native 模块并生成未签名 HAP。独立 Mac 接收器的 8 项测试通过,其中生成的 20 帧视频经过真实 H.264 解码全部返回。 + +官方 CLI 登录成功,但签名预检返回账号尚未完成开发者实名认证,未生成真机可安装的调试签名。因此未安装组件、未取得手机视频流,插件播放器集成、旋转重建和端到端时延验收仍未完成。源码和可复现命令见[开发验证说明](../harmony-realtime-development.md)。 diff --git a/tools/harmony-stream.py b/tools/harmony-stream.py new file mode 100644 index 0000000..6adbab4 --- /dev/null +++ b/tools/harmony-stream.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Measure the companion's HCS1 video stream; no screenshots/files are retained. + +Development tool, not the plugin's production player. Requires official HDC, +the signed companion on the phone, and ffmpeg (or ffplay with --view). +""" +import argparse +import json +import os +from pathlib import Path +import re +import secrets +import select +import shutil +import socket +import struct +import subprocess +import threading +import time + +BUNDLE = "ink.render.trove.harmonyconnector" +MAX_PACKET = 2 * 1024 * 1024 + + +def read_exact(sock, size, deadline): + result = bytearray() + while len(result) < size: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Video stream timed out") + sock.settimeout(min(remaining, 0.2)) + try: + part = sock.recv(size - len(result)) + except socket.timeout: + continue + if not part: + raise EOFError("Screen sharing stopped or USB disconnected") + result.extend(part) + return bytes(result) + + +def read_hello(sock, deadline): + magic, width, height, display_width, display_height, fps = struct.unpack( + ">4s5I", read_exact(sock, 24, deadline)) + if (magic != b"HCS1" or not 1 <= fps <= 60 or + not all(2 <= d <= 8192 for d in (width, height, display_width, display_height)) or + width % 2 or height % 2 or max(width, height) > 1280): + raise ValueError("Unsupported stream header") + return {"width": width, "height": height, "displayWidth": display_width, + "displayHeight": display_height, "configuredFps": fps} + + +def read_packet(sock, deadline): + size, flags, pts = struct.unpack( + ">IIq", read_exact(sock, 16, deadline)) + if not 0 < size <= MAX_PACKET or flags & ~0x7f or pts < 0: + raise ValueError("Invalid video packet") + data = read_exact(sock, size, deadline) + if not data.startswith((b"\0\0\1", b"\0\0\0\1")): + raise ValueError("Encoder did not return Annex B H.264") + return flags, pts, data + + +class Decoder: + def __init__(self, view): + name = "ffplay" if view else "ffmpeg" + executable = shutil.which(name) + if not executable: + raise RuntimeError(f"Development measurement requires {name} on PATH") + args = [executable, "-hide_banner", "-loglevel", "error", + "-flags", "low_delay", "-probesize", "32", "-analyzeduration", "0", + "-f", "h264", "-i", "pipe:0"] + if view: + args += ["-window_title", "Harmony Connector · H.264 preview", "-autoexit"] + else: + args += ["-progress", "pipe:1", "-stats_period", "0.25", "-f", "null", "-"] + self.frames = 0 + self.error = bytearray() + self.process = subprocess.Popen(args, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + os.set_blocking(self.process.stdin.fileno(), False) + self.threads = [threading.Thread(target=self.progress, daemon=True), + threading.Thread(target=self.errors, daemon=True)] + for thread in self.threads: + thread.start() + + def progress(self): + for line in self.process.stdout: + if line.startswith(b"frame="): + self.frames = int(line.split(b"=", 1)[1]) + + def errors(self): + for line in self.process.stderr: + if len(self.error) < 8192: + self.error.extend(line[:8192 - len(self.error)]) + + def write(self, data): + deadline = time.monotonic() + 1 + view = memoryview(data) + while view: + if self.process.poll() is not None: + raise RuntimeError("Video decoder exited: " + self.error.decode(errors="replace")) + if time.monotonic() >= deadline: + raise TimeoutError("Decoder is falling behind; stopped instead of queuing video") + if not select.select([], [self.process.stdin], [], 0.05)[1]: + continue + try: + count = os.write(self.process.stdin.fileno(), view) + view = view[count:] + except BlockingIOError: + continue + + def close(self): + self.process.stdin.close() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.terminate() + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + for thread in self.threads: + thread.join(timeout=1) + self.process.stdout.close() + self.process.stderr.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--hdc", required=True, type=Path) + parser.add_argument("--target") + parser.add_argument("--seconds", type=int, default=20) + parser.add_argument("--view", action="store_true") + args = parser.parse_args() + if not 5 <= args.seconds <= 120 or not args.hdc.is_file(): + parser.error("Use an existing HDC executable and 5–120 measurement seconds") + + def hdc(*command): + try: + result = subprocess.run([str(args.hdc.resolve()), *command], capture_output=True, + text=True, timeout=10, check=False) + except subprocess.TimeoutExpired: + raise RuntimeError("HDC command timed out") from None + if result.returncode: + raise RuntimeError("HDC command did not complete successfully") + if re.search(r"\b(fail(?:ed)?|error)\b", result.stdout + result.stderr, re.I): + raise RuntimeError("HDC command failed: " + result.stdout + result.stderr) + return result.stdout + + targets = [line.split()[0] for line in hdc("list", "targets", "-v").splitlines() + if "Connected" in line.split()] + target = args.target or (targets[0] if len(targets) == 1 else None) + if target not in targets: + raise RuntimeError("Select one connected, authorized phone with --target") + installed = hdc("-t", target, "shell", f"bm dump -n {BUNDLE}") + if BUNDLE not in installed: + raise RuntimeError("Install the signed Harmony Connector companion before measuring") + decoder = Decoder(args.view) + sock = None + forward = False + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + local_port = reservation.getsockname()[1] + remote_port = 20000 + secrets.randbelow(40000) + local, remote = f"tcp:{local_port}", f"tcp:{remote_port}" + token = secrets.token_hex(32) + frames = total_bytes = 0 + started = first_frame = None + try: + hdc("-t", target, "fport", local, remote) + forward = True + hdc("-t", target, "shell", f"aa start -b {BUNDLE} -a EntryAbility -m entry " + f"--ps token {token} --pi port {remote_port}") + print("请在手机点击「共享屏幕」,并同意系统授权。", flush=True) + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + try: + sock = socket.create_connection(("127.0.0.1", local_port), timeout=1) + sock.sendall(token.encode("ascii")) + hello = read_hello(sock, min(deadline, time.monotonic() + 2)) + break + except (OSError, EOFError, TimeoutError): + if sock: + sock.close() + sock = None + time.sleep(0.2) + if not sock: + raise TimeoutError("Phone did not start screen sharing") + print(json.dumps({"stage": "connected", **hello}), flush=True) + started = time.monotonic() + last_report = started + while first_frame is None or time.monotonic() - first_frame < args.seconds: + flags, _, data = read_packet(sock, time.monotonic() + (45 if first_frame is None else 3)) + decoder.write(data) + total_bytes += len(data) + now = time.monotonic() + if not flags & 8: + frames += 1 + if first_frame is None: + first_frame = now + print(json.dumps({"stage": "first-frame", "waitMs": round((now - started) * 1000)}), flush=True) + if first_frame and now - last_report >= 1: + elapsed = now - first_frame + print(json.dumps({"stage": "receiving", "frames": frames, + "receivedFps": round((frames - 1) / elapsed, 1), + "decodedFrames": decoder.frames, + "mbps": round(total_bytes * 8 / elapsed / 1000000, 2)}), flush=True) + last_report = now + elapsed = time.monotonic() - first_frame + finally: + if sock: + try: + sock.sendall(b"\0") + except OSError: + pass + sock.close() + try: + if forward: + hdc("-t", target, "fport", "rm", local, remote) + finally: + decoder.close() + passed = (frames - 1) / elapsed >= 20 and (args.view or decoder.frames >= frames * 0.95) + print(json.dumps({"passed": passed, "frames": frames, "decodedFrames": decoder.frames, + "receivedFps": round((frames - 1) / elapsed, 1), "durationSeconds": round(elapsed, 2), + "endToEndLatencyMeasured": False, "inputSent": False, "screenFilesSaved": False}), flush=True) + if not passed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/harmony-stream.test.py b/tools/harmony-stream.test.py new file mode 100644 index 0000000..5ad7fac --- /dev/null +++ b/tools/harmony-stream.test.py @@ -0,0 +1,87 @@ +import importlib.util +from pathlib import Path +import re +import shutil +import socket +import struct +import subprocess +import time +import unittest + +spec = importlib.util.spec_from_file_location("harmony_stream", Path(__file__).with_name("harmony-stream.py")) +stream = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stream) + + +class StreamProtocolTests(unittest.TestCase): + def setUp(self): + self.sender, self.receiver = socket.socketpair() + self.deadline = time.monotonic() + 1 + + def tearDown(self): + self.sender.close() + self.receiver.close() + + def test_negotiated_dimensions(self): + self.sender.sendall(struct.pack(">4s5I", b"HCS1", 566, 1280, 1260, 2844, 30)) + hello = stream.read_hello(self.receiver, self.deadline) + self.assertEqual((hello["width"], hello["displayHeight"], hello["configuredFps"]), (566, 2844, 30)) + + def test_unknown_version_rejected(self): + self.sender.sendall(struct.pack(">4s5I", b"HCS2", 640, 480, 1280, 960, 30)) + with self.assertRaises(ValueError): + stream.read_hello(self.receiver, self.deadline) + + def test_dimensions_bounded_before_video_allocations(self): + self.sender.sendall(struct.pack(">4s5I", b"HCS1", 65536, 480, 1280, 960, 30)) + with self.assertRaises(ValueError): + stream.read_hello(self.receiver, self.deadline) + + def test_oversized_packet_rejected_without_reading_payload(self): + self.sender.sendall(struct.pack(">IIq", stream.MAX_PACKET + 1, 2, 0)) + with self.assertRaises(ValueError): + stream.read_packet(self.receiver, self.deadline) + + def test_disconnect_in_packet_is_not_a_frame(self): + self.sender.sendall(struct.pack(">IIq", 32, 2, 42) + b"\0\0\1\x65") + self.sender.shutdown(socket.SHUT_WR) + with self.assertRaises(EOFError): + stream.read_packet(self.receiver, self.deadline) + + def test_silent_connection_has_bounded_wait(self): + with self.assertRaises(TimeoutError): + stream.read_packet(self.receiver, time.monotonic() + 0.02) + + def test_non_h264_payload_rejected(self): + self.sender.sendall(struct.pack(">IIq", 8, 2, 42) + b"JPEGdata") + with self.assertRaises(ValueError): + stream.read_packet(self.receiver, self.deadline) + + @unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg required for actual decoder validation") + def test_twenty_generated_frames_survive_transport_and_decode(self): + encoded = subprocess.run([ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", + "testsrc2=size=160x96:rate=30", "-frames:v", "20", "-c:v", "libx264", + "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", + "-g", "10", "-bf", "0", "-x264-params", "aud=1:repeat-headers=1", + "-f", "h264", "pipe:1"], capture_output=True, check=True, timeout=10).stdout + # Access unit delimiters separate independently timestamped encoder callbacks. + boundaries = [match.start() for match in re.finditer(b"\x00\x00\x00\x01\x09", encoded)] + self.assertEqual(len(boundaries), 20) + decoder = stream.Decoder(False) + try: + for index, (start, end) in enumerate(zip(boundaries, boundaries[1:] + [len(encoded)])): + data = encoded[start:end] + flags = 2 if index % 10 == 0 else 0 + self.sender.sendall(struct.pack(">IIq", len(data), flags, index * 33333) + data) + actual_flags, pts, actual_data = stream.read_packet(self.receiver, time.monotonic() + 1) + self.assertEqual((actual_flags, pts), (flags, index * 33333)) + decoder.write(actual_data) + finally: + decoder.close() + self.assertEqual(decoder.process.returncode, 0, decoder.error.decode()) + self.assertEqual(decoder.frames, 20, decoder.error.decode()) + + +if __name__ == "__main__": + unittest.main()