mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
[feat] add HarmonyOS desktop and mobile clients,issue #117
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
#include "audio.h"
|
||||
#include <ohaudio/native_audiostreambuilder.h>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <hilog/log.h>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
bool AudioPlayer::Start() {
|
||||
Stop();
|
||||
OH_AudioStreamBuilder* builder = nullptr;
|
||||
if (OH_AudioStreamBuilder_Create(&builder, AUDIOSTREAM_TYPE_RENDERER) != AUDIOSTREAM_SUCCESS) return false;
|
||||
OH_AudioStreamBuilder_SetSamplingRate(builder, 48000);
|
||||
OH_AudioStreamBuilder_SetChannelCount(builder, 1);
|
||||
OH_AudioStreamBuilder_SetSampleFormat(builder, AUDIOSTREAM_SAMPLE_S16LE);
|
||||
OH_AudioStreamBuilder_SetEncodingType(builder, AUDIOSTREAM_ENCODING_TYPE_RAW);
|
||||
OH_AudioStreamBuilder_SetRendererInfo(builder, AUDIOSTREAM_USAGE_MUSIC);
|
||||
OH_AudioRenderer_Callbacks callbacks{};
|
||||
callbacks.OH_AudioRenderer_OnWriteData = [](OH_AudioRenderer*, void* user, void* buffer, int32_t length) {
|
||||
auto* self = static_cast<AudioPlayer*>(user);
|
||||
if (!buffer || length <= 0) return 0;
|
||||
std::memset(buffer, 0, length);
|
||||
std::lock_guard lock(self->mutex_);
|
||||
auto* output = static_cast<uint8_t*>(buffer);
|
||||
const auto count = std::min(size_t(length), self->queue_.size());
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
output[i] = self->queue_.front(); self->queue_.pop_front();
|
||||
}
|
||||
if (count && !self->rendered_bytes_)
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "Audio playback received PCM: %{public}zu bytes at 48000 Hz mono", count);
|
||||
self->rendered_bytes_ += count;
|
||||
return 0;
|
||||
};
|
||||
callbacks.OH_AudioRenderer_OnError = [](OH_AudioRenderer*, void*, OH_AudioStream_Result error) {
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "CrossDesk", "Audio playback error: %{public}d", int(error));
|
||||
return 0;
|
||||
};
|
||||
OH_AudioStreamBuilder_SetRendererCallback(builder, callbacks, this);
|
||||
auto result = OH_AudioStreamBuilder_GenerateRenderer(builder, &renderer_);
|
||||
OH_AudioStreamBuilder_Destroy(builder);
|
||||
if (result != AUDIOSTREAM_SUCCESS || OH_AudioRenderer_Start(renderer_) != AUDIOSTREAM_SUCCESS) {
|
||||
Stop(); return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void AudioPlayer::Stop() {
|
||||
if (renderer_) { OH_AudioRenderer_Stop(renderer_); OH_AudioRenderer_Release(renderer_); renderer_ = nullptr; }
|
||||
std::lock_guard lock(mutex_);
|
||||
if (rendered_bytes_) OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "Audio playback stopped after %{public}llu PCM bytes", (unsigned long long)rendered_bytes_);
|
||||
queue_.clear(); rendered_bytes_ = 0;
|
||||
}
|
||||
void AudioPlayer::Push(const char* data, size_t size) {
|
||||
if (!data || size == 0 || size > 19200 || (size & 1)) return;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (queue_.size() + size > 19200) queue_.clear(); // At most 200 ms of mono PCM.
|
||||
queue_.insert(queue_.end(), data, data + size);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _AUDIO_H_
|
||||
#define _AUDIO_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <ohaudio/native_audiorenderer.h>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
class AudioPlayer {
|
||||
public:
|
||||
~AudioPlayer() { Stop(); }
|
||||
bool Start();
|
||||
void Stop();
|
||||
void Push(const char* data, size_t size);
|
||||
private:
|
||||
OH_AudioRenderer* renderer_ = nullptr;
|
||||
std::mutex mutex_;
|
||||
std::deque<uint8_t> queue_;
|
||||
uint64_t rendered_bytes_ = 0;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,209 @@
|
||||
#include "capture.h"
|
||||
#include <native_buffer/native_buffer.h>
|
||||
#include <native_buffer/buffer_common.h>
|
||||
#include <multimedia/player_framework/native_avbuffer.h>
|
||||
#include <libyuv.h>
|
||||
#include <algorithm>
|
||||
#include <hilog/log.h>
|
||||
#include <native_window/external_window.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
bool Capture::Start(int width, int height, uint64_t display, Frame frame, State state,
|
||||
Audio audio, int fps, bool persistent_permission, bool show_cursor) {
|
||||
Stop(); frame_ = std::move(frame); state_ = std::move(state); audio_ = std::move(audio);
|
||||
show_cursor_ = show_cursor;
|
||||
first_video_ = true;
|
||||
first_data_ = true;
|
||||
max_frame_rate_ = std::clamp(fps, 1, 60);
|
||||
capture_ = OH_AVScreenCapture_Create();
|
||||
if (!capture_) return false;
|
||||
window_ = gpu_surface_.Start(width, height, [this](const uint8_t* pixels, int width, int height, int stride) {
|
||||
if (active_) ConvertRgba(pixels, width, height, stride);
|
||||
});
|
||||
const bool surface_mode = window_ != nullptr || PrepareSurface(width, height);
|
||||
const auto state_result = OH_AVScreenCapture_SetStateCallback(capture_, [](OH_AVScreenCapture* capture, OH_AVScreenCaptureStateCode code, void* user) {
|
||||
auto* self = static_cast<Capture*>(user);
|
||||
if (code == OH_SCREEN_CAPTURE_STATE_STARTED) {
|
||||
// Both operations require a running capture. StartScreenCapture may
|
||||
// return while the system is still awaiting the user's authorization.
|
||||
const auto rate = OH_AVScreenCapture_SetMaxVideoFrameRate(capture, self->max_frame_rate_);
|
||||
if (rate != AV_SCREEN_CAPTURE_ERR_OK)
|
||||
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Capture frame rate unavailable: %{public}d", int(rate));
|
||||
}
|
||||
if (code == OH_SCREEN_CAPTURE_STATE_STARTED || code == OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE ||
|
||||
code == OH_SCREEN_CAPTURE_STATE_RESUMED_BY_USER || code == OH_SCREEN_CAPTURE_STATE_RESUMED_BY_APP) {
|
||||
// Native viewers draw the separately transmitted cursor. Only advertise
|
||||
// that mode once the recorder confirms it has removed the video cursor.
|
||||
const auto result = OH_AVScreenCapture_ShowCursor(capture, self->show_cursor_);
|
||||
if (result == AV_SCREEN_CAPTURE_ERR_OK) {
|
||||
self->cursor_mode_ = self->show_cursor_ ? CaptureCursorMode::embedded : CaptureCursorMode::separate;
|
||||
} else {
|
||||
self->cursor_mode_ = !self->show_cursor_ &&
|
||||
OH_AVScreenCapture_ShowCursor(capture, true) == AV_SCREEN_CAPTURE_ERR_OK
|
||||
? CaptureCursorMode::embedded : CaptureCursorMode::unknown;
|
||||
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Capture cursor setting failed: %{public}d", int(result));
|
||||
}
|
||||
}
|
||||
self->active_ = CaptureActiveForState(code, self->active_);
|
||||
self->state_(static_cast<int>(code));
|
||||
}, this);
|
||||
const auto error_result = OH_AVScreenCapture_SetErrorCallback(capture_, [](OH_AVScreenCapture*, int32_t error, void* user) {
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Capture error: %{public}d", error);
|
||||
auto* self = static_cast<Capture*>(user); self->active_ = false; self->state_(-1);
|
||||
}, this);
|
||||
const auto data_result = OH_AVScreenCapture_SetDataCallback(capture_, [](OH_AVScreenCapture*, OH_AVBuffer* buffer,
|
||||
OH_AVScreenCaptureBufferType type, int64_t, void* user) {
|
||||
auto* self = static_cast<Capture*>(user);
|
||||
if (self->first_data_.exchange(false))
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Capture first data: type=%{public}d active=%{public}d", int(type), int(self->active_.load()));
|
||||
if (!self->active_) return;
|
||||
if (type == OH_SCREEN_CAPTURE_BUFFERTYPE_AUDIO_INNER && self->audio_) {
|
||||
OH_AVCodecBufferAttr attr{};
|
||||
auto* data = OH_AVBuffer_GetAddr(buffer);
|
||||
if (data && OH_AVBuffer_GetBufferAttr(buffer, &attr) == AV_ERR_OK && attr.offset >= 0 && attr.size > 0 &&
|
||||
int64_t(attr.offset) + attr.size <= OH_AVBuffer_GetCapacity(buffer))
|
||||
self->audio_(reinterpret_cast<const char*>(data + attr.offset), attr.size);
|
||||
return;
|
||||
}
|
||||
if (type != OH_SCREEN_CAPTURE_BUFFERTYPE_VIDEO) return;
|
||||
const bool first_video = self->first_video_.exchange(false);
|
||||
auto* native = OH_AVBuffer_GetNativeBuffer(buffer);
|
||||
if (!native) {
|
||||
if (first_video) OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Capture video has no native buffer");
|
||||
return;
|
||||
}
|
||||
self->Convert(native, first_video);
|
||||
OH_NativeBuffer_Unreference(native);
|
||||
}, this);
|
||||
OH_AVScreenCaptureConfig config{};
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Capture request: %{public}dx%{public}d display=%{public}llu fps=%{public}d",
|
||||
width, height, static_cast<unsigned long long>(display), max_frame_rate_);
|
||||
config.captureMode = OH_CAPTURE_SPECIFIED_SCREEN; config.dataType = OH_ORIGINAL_STREAM;
|
||||
config.videoInfo.videoCapInfo = {display, nullptr, 0, width, height, OH_VIDEO_SOURCE_SURFACE_RGBA};
|
||||
if (audio_) config.audioInfo.innerCapInfo = {48000, 1, OH_ALL_PLAYBACK};
|
||||
const auto check = [](const char* stage, OH_AVSCREEN_CAPTURE_ErrCode result) {
|
||||
if (result == AV_SCREEN_CAPTURE_ERR_OK) return true;
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Capture %{public}s failed: %{public}d", stage, int(result));
|
||||
return false;
|
||||
};
|
||||
if (!check("state callback", state_result) || !check("error callback", error_result) ||
|
||||
!check("data callback", data_result) || !check("initialization", OH_AVScreenCapture_Init(capture_, config)) ||
|
||||
!check("microphone disable", OH_AVScreenCapture_SetMicrophoneEnabled(capture_, false))) {
|
||||
Stop(); return false;
|
||||
}
|
||||
if (persistent_permission) {
|
||||
// With CUSTOM_SCREEN_RECORDING granted, the system can reuse consent for
|
||||
// this specified display. Disabling the picker alone never grants access.
|
||||
auto* strategy = OH_AVScreenCapture_CreateCaptureStrategy();
|
||||
bool configured = false;
|
||||
if (strategy) {
|
||||
configured = OH_AVScreenCapture_StrategyForPickerPopUp(strategy, false) == AV_SCREEN_CAPTURE_ERR_OK &&
|
||||
OH_AVScreenCapture_SetCaptureStrategy(capture_, strategy) == AV_SCREEN_CAPTURE_ERR_OK;
|
||||
OH_AVScreenCapture_ReleaseCaptureStrategy(strategy);
|
||||
}
|
||||
if (!configured)
|
||||
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Persistent capture strategy unavailable; using system authorization");
|
||||
}
|
||||
if (!check("start", surface_mode ? OH_AVScreenCapture_StartScreenCaptureWithSurface(capture_, window_)
|
||||
: OH_AVScreenCapture_StartScreenCapture(capture_))) {
|
||||
Stop(); return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool Capture::PrepareSurface(int width, int height) {
|
||||
image_ = OH_ConsumerSurface_Create();
|
||||
if (!image_) return false;
|
||||
if (OH_ConsumerSurface_SetDefaultUsage(image_, NATIVEBUFFER_USAGE_CPU_READ | NATIVEBUFFER_USAGE_CPU_READ_OFTEN |
|
||||
NATIVEBUFFER_USAGE_HW_RENDER) != 0 ||
|
||||
OH_ConsumerSurface_SetDefaultSize(image_, width, height) != 0 ||
|
||||
!(window_ = OH_NativeImage_AcquireNativeWindow(image_))) {
|
||||
OH_NativeImage_Destroy(&image_); return false;
|
||||
}
|
||||
surface_pending_ = false; surface_stopping_ = false;
|
||||
OH_OnFrameAvailableListener listener{this, [](void* context) {
|
||||
auto* self = static_cast<Capture*>(context);
|
||||
std::lock_guard lock(self->surface_mutex_);
|
||||
self->surface_pending_ = true; self->surface_ready_.notify_one();
|
||||
}};
|
||||
if (OH_NativeImage_SetOnFrameAvailableListener(image_, listener) != 0) {
|
||||
window_ = nullptr; // The native image owns its producer window.
|
||||
OH_NativeImage_Destroy(&image_); return false;
|
||||
}
|
||||
surface_thread_ = std::thread([this] { ConsumeSurface(); });
|
||||
return true;
|
||||
}
|
||||
void Capture::ConsumeSurface() {
|
||||
for (;;) {
|
||||
{
|
||||
std::unique_lock lock(surface_mutex_);
|
||||
surface_ready_.wait(lock, [this] { return surface_stopping_ || surface_pending_; });
|
||||
if (surface_stopping_) return;
|
||||
surface_pending_ = false;
|
||||
}
|
||||
// Notifications may coalesce while conversion is running. Drain the queue
|
||||
// so a static desktop cannot leave its newest frame waiting indefinitely.
|
||||
for (;;) {
|
||||
{ std::lock_guard lock(surface_mutex_); if (surface_stopping_) return; }
|
||||
OHNativeWindowBuffer* buffer = nullptr;
|
||||
int fence = -1;
|
||||
if (OH_NativeImage_AcquireNativeWindowBuffer(image_, &buffer, &fence) != 0) break;
|
||||
bool ready = true;
|
||||
if (fence >= 0) {
|
||||
pollfd descriptor{fence, POLLIN, 0};
|
||||
int result;
|
||||
do { result = poll(&descriptor, 1, 1000); } while (result < 0 && errno == EINTR);
|
||||
ready = result > 0 && (descriptor.revents & POLLIN);
|
||||
}
|
||||
OH_NativeBuffer* native = nullptr;
|
||||
if (ready && active_ && OH_NativeBuffer_FromNativeWindowBuffer(buffer, &native) == 0 && native)
|
||||
Convert(native, first_video_.exchange(false));
|
||||
// Return an unsignaled fence to the producer on timeout; never let it
|
||||
// reuse memory while a GPU write is still outstanding.
|
||||
OH_NativeImage_ReleaseNativeWindowBuffer(image_, buffer, ready ? -1 : fence);
|
||||
if (ready && fence >= 0) close(fence);
|
||||
}
|
||||
}
|
||||
}
|
||||
void Capture::Convert(OH_NativeBuffer* native, bool first_video) {
|
||||
OH_NativeBuffer_Config config{}; OH_NativeBuffer_GetConfig(native, &config);
|
||||
if (first_video)
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Capture buffer: %{public}dx%{public}d format=%{public}d stride=%{public}d",
|
||||
config.width, config.height, config.format, config.stride);
|
||||
void* pixels = nullptr;
|
||||
if (config.format == NATIVEBUFFER_PIXEL_FMT_RGBA_8888 &&
|
||||
config.width > 0 && config.height > 0 && config.width <= 4096 && config.height <= 4096 &&
|
||||
!(config.width & 1) && !(config.height & 1) && config.stride >= config.width * 4 &&
|
||||
OH_NativeBuffer_Map(native, &pixels) == 0 && pixels) {
|
||||
ConvertRgba(static_cast<const uint8_t*>(pixels), config.width, config.height, config.stride);
|
||||
OH_NativeBuffer_Unmap(native);
|
||||
}
|
||||
}
|
||||
void Capture::ConvertRgba(const uint8_t* pixels, int width, int height, int stride) {
|
||||
std::lock_guard lock(data_mutex_);
|
||||
const size_t area = size_t(width) * height;
|
||||
nv12_.resize(area * 3 / 2);
|
||||
if (libyuv::ABGRToNV12(pixels, stride, nv12_.data(), width, nv12_.data() + area, width, width, height) == 0 && active_) {
|
||||
MiniRtcVideoFrame video{};
|
||||
video.data = reinterpret_cast<const char*>(nv12_.data()); video.size = nv12_.size();
|
||||
video.width = width; video.height = height;
|
||||
frame_(video);
|
||||
}
|
||||
}
|
||||
void Capture::Stop() {
|
||||
cursor_mode_ = CaptureCursorMode::unknown;
|
||||
active_ = false;
|
||||
if (capture_) { OH_AVScreenCapture_StopScreenCapture(capture_); OH_AVScreenCapture_Release(capture_); capture_ = nullptr; }
|
||||
gpu_surface_.Stop();
|
||||
if (image_) {
|
||||
OH_NativeImage_UnsetOnFrameAvailableListener(image_);
|
||||
{ std::lock_guard lock(surface_mutex_); surface_stopping_ = true; surface_ready_.notify_one(); }
|
||||
if (surface_thread_.joinable()) surface_thread_.join();
|
||||
window_ = nullptr; // OH_NativeImage_Destroy releases the producer window.
|
||||
OH_NativeImage_Destroy(&image_);
|
||||
}
|
||||
window_ = nullptr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CAPTURE_H_
|
||||
#define _CAPTURE_H_
|
||||
|
||||
#include <minirtc.h>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <multimedia/player_framework/native_avscreen_capture.h>
|
||||
#include <native_image/native_image.h>
|
||||
#include "capture_surface.h"
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
// Microphone notifications do not change video/input availability.
|
||||
inline bool CaptureActiveForState(int state, bool current) {
|
||||
if (state < 0) return false;
|
||||
switch (state) {
|
||||
case OH_SCREEN_CAPTURE_STATE_STARTED:
|
||||
case OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE:
|
||||
case OH_SCREEN_CAPTURE_STATE_RESUMED_BY_USER:
|
||||
case OH_SCREEN_CAPTURE_STATE_RESUMED_BY_APP: return true;
|
||||
case OH_SCREEN_CAPTURE_STATE_CANCELED:
|
||||
case OH_SCREEN_CAPTURE_STATE_STOPPED_BY_USER:
|
||||
case OH_SCREEN_CAPTURE_STATE_INTERRUPTED_BY_OTHER:
|
||||
case OH_SCREEN_CAPTURE_STATE_STOPPED_BY_CALL:
|
||||
case OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE:
|
||||
case OH_SCREEN_CAPTURE_STATE_STOPPED_BY_USER_SWITCHES:
|
||||
case OH_SCREEN_CAPTURE_STATE_PAUSED_BY_USER:
|
||||
case OH_SCREEN_CAPTURE_STATE_PAUSED_BY_APP: return false;
|
||||
default: return current;
|
||||
}
|
||||
}
|
||||
enum class CaptureCursorMode { unknown, separate, embedded };
|
||||
|
||||
class Capture {
|
||||
public:
|
||||
using Frame = std::function<void(const MiniRtcVideoFrame&)>;
|
||||
using State = std::function<void(int)>;
|
||||
using Audio = std::function<void(const char*, size_t)>;
|
||||
~Capture() { Stop(); }
|
||||
bool Start(int width, int height, uint64_t display, Frame frame, State state,
|
||||
Audio audio = {}, int fps = 30, bool persistent_permission = false,
|
||||
bool show_cursor = true);
|
||||
void Stop();
|
||||
CaptureCursorMode CursorMode() const { return cursor_mode_; }
|
||||
bool CursorEmbedded() const { return CursorMode() == CaptureCursorMode::embedded; }
|
||||
private:
|
||||
bool PrepareSurface(int width, int height);
|
||||
void ConsumeSurface();
|
||||
void Convert(OH_NativeBuffer* native, bool first_video);
|
||||
void ConvertRgba(const uint8_t* pixels, int width, int height, int stride);
|
||||
CaptureSurface gpu_surface_;
|
||||
OH_NativeImage* image_ = nullptr;
|
||||
OHNativeWindow* window_ = nullptr;
|
||||
std::mutex surface_mutex_;
|
||||
std::condition_variable surface_ready_;
|
||||
bool surface_stopping_ = true, surface_pending_ = false;
|
||||
std::thread surface_thread_;
|
||||
OH_AVScreenCapture* capture_ = nullptr;
|
||||
std::atomic<CaptureCursorMode> cursor_mode_{CaptureCursorMode::unknown};
|
||||
bool show_cursor_ = true;
|
||||
int max_frame_rate_ = 30;
|
||||
Frame frame_;
|
||||
State state_;
|
||||
Audio audio_;
|
||||
std::mutex data_mutex_;
|
||||
std::vector<uint8_t> nv12_;
|
||||
std::atomic<bool> active_{false};
|
||||
std::atomic<bool> first_video_{true};
|
||||
std::atomic<bool> first_data_{true};
|
||||
};
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "capture_surface.h"
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <GLES2/gl2ext.h>
|
||||
#include <hilog/log.h>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
OHNativeWindow* CaptureSurface::Start(int width, int height, Frame frame) {
|
||||
Stop();
|
||||
if (width <= 0 || height <= 0 || (width & 1) || (height & 1) || !frame) return nullptr;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
prepared_ = false; stopping_ = false; pending_ = 0; window_ = nullptr;
|
||||
}
|
||||
thread_ = std::thread([this, width, height, frame = std::move(frame)] { Run(width, height, frame); });
|
||||
std::unique_lock lock(mutex_);
|
||||
ready_.wait(lock, [this] { return prepared_; });
|
||||
auto* window = window_;
|
||||
lock.unlock();
|
||||
if (!window) Stop(); // Finish failed EGL cleanup before trying the CPU path.
|
||||
return window;
|
||||
}
|
||||
void CaptureSurface::Stop() {
|
||||
{ std::lock_guard lock(mutex_); stopping_ = true; ready_.notify_one(); }
|
||||
if (thread_.joinable()) thread_.join();
|
||||
}
|
||||
void CaptureSurface::Run(int width, int height, Frame frame) {
|
||||
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
EGLContext context = EGL_NO_CONTEXT;
|
||||
EGLSurface target = EGL_NO_SURFACE;
|
||||
OH_NativeImage* image = nullptr;
|
||||
GLuint input = 0, output = 0, fbo = 0, program = 0, vao = 0;
|
||||
bool current = false;
|
||||
const auto prepare = [&]() -> bool {
|
||||
if (!eglInitialize(display, nullptr, nullptr) || !eglBindAPI(EGL_OPENGL_ES_API)) return false;
|
||||
const EGLint attributes[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
|
||||
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE};
|
||||
EGLConfig config{}; EGLint count = 0;
|
||||
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
|
||||
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
|
||||
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
||||
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
||||
target = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
||||
if (context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE) return false;
|
||||
if (!(current = eglMakeCurrent(display, target, target, context))) return false;
|
||||
GLint limit = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &limit);
|
||||
if (width > limit || height > limit) return false;
|
||||
const char* vertex = R"(#version 300 es
|
||||
uniform mat4 transform; out vec2 uv;
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
uv = (transform * vec4(p, 0.0, 1.0)).xy;
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
})";
|
||||
const char* fragment = R"(#version 300 es
|
||||
#extension GL_OES_EGL_image_external_essl3 : require
|
||||
precision mediump float;
|
||||
uniform samplerExternalOES source; in vec2 uv; out vec4 color;
|
||||
void main() { color = texture(source, uv); })";
|
||||
const auto shader = [](GLenum type, const char* source) {
|
||||
GLuint value = glCreateShader(type); glShaderSource(value, 1, &source, nullptr); glCompileShader(value);
|
||||
GLint ok = 0; glGetShaderiv(value, GL_COMPILE_STATUS, &ok);
|
||||
if (!ok) { glDeleteShader(value); return GLuint(0); } return value;
|
||||
};
|
||||
GLuint v = shader(GL_VERTEX_SHADER, vertex), f = shader(GL_FRAGMENT_SHADER, fragment);
|
||||
if (!v || !f) { glDeleteShader(v); glDeleteShader(f); return false; }
|
||||
program = glCreateProgram(); glAttachShader(program, v); glAttachShader(program, f); glLinkProgram(program);
|
||||
glDeleteShader(v); glDeleteShader(f);
|
||||
GLint linked = 0; glGetProgramiv(program, GL_LINK_STATUS, &linked); if (!linked) return false;
|
||||
glGenTextures(1, &input); glBindTexture(GL_TEXTURE_EXTERNAL_OES, input);
|
||||
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
image = OH_NativeImage_Create(input, GL_TEXTURE_EXTERNAL_OES);
|
||||
if (!image) return false;
|
||||
if (OH_ConsumerSurface_SetDefaultSize(image, width, height) != 0) return false;
|
||||
OH_OnFrameAvailableListener listener{this, [](void* context) {
|
||||
auto* self = static_cast<CaptureSurface*>(context);
|
||||
std::lock_guard lock(self->mutex_);
|
||||
self->pending_ = std::min(self->pending_ + 1, 64u); self->ready_.notify_one();
|
||||
}};
|
||||
if (OH_NativeImage_SetOnFrameAvailableListener(image, listener) != 0) return false;
|
||||
glGenTextures(1, &output); glBindTexture(GL_TEXTURE_2D, output);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
glGenFramebuffers(1, &fbo); glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, output, 0);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false;
|
||||
glGenVertexArrays(1, &vao); glBindVertexArray(vao);
|
||||
window_ = OH_NativeImage_AcquireNativeWindow(image);
|
||||
return window_ != nullptr;
|
||||
};
|
||||
const bool success = prepare();
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!success) window_ = nullptr;
|
||||
prepared_ = true; ready_.notify_one();
|
||||
}
|
||||
if (success) {
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Capture uses GLES surface readback");
|
||||
std::vector<uint8_t> pixels(size_t(width) * height * 4);
|
||||
const auto source_location = glGetUniformLocation(program, "source");
|
||||
const auto transform_location = glGetUniformLocation(program, "transform");
|
||||
bool reported_error = false;
|
||||
for (;;) {
|
||||
{
|
||||
std::unique_lock lock(mutex_);
|
||||
ready_.wait(lock, [this] { return stopping_ || pending_ > 0; });
|
||||
if (stopping_) break;
|
||||
--pending_;
|
||||
}
|
||||
if (OH_NativeImage_UpdateSurfaceImage(image) != 0) continue;
|
||||
float transform[16]{};
|
||||
if (OH_NativeImage_GetTransformMatrixV2(image, transform) != 0) continue;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo); glViewport(0, 0, width, height);
|
||||
glUseProgram(program); glBindVertexArray(vao);
|
||||
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_EXTERNAL_OES, input);
|
||||
glUniform1i(source_location, 0);
|
||||
glUniformMatrix4fv(transform_location, 1, GL_FALSE, transform);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||||
const auto error = glGetError();
|
||||
if (error == GL_NO_ERROR) {
|
||||
// NativeImage's V2 matrix already accounts for the producer's buffer
|
||||
// orientation. Flipping glReadPixels rows again inverts the desktop.
|
||||
frame(pixels.data(), width, height, width * 4);
|
||||
} else if (!reported_error) {
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Capture GLES readback failed: %{public}u", error);
|
||||
reported_error = true;
|
||||
}
|
||||
}
|
||||
} else OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "GLES capture surface unavailable");
|
||||
if (image) { OH_NativeImage_UnsetOnFrameAvailableListener(image); OH_NativeImage_Destroy(&image); }
|
||||
if (current) {
|
||||
glDeleteVertexArrays(1, &vao); glDeleteFramebuffers(1, &fbo);
|
||||
glDeleteTextures(1, &input); glDeleteTextures(1, &output); glDeleteProgram(program);
|
||||
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
}
|
||||
if (target != EGL_NO_SURFACE) eglDestroySurface(display, target);
|
||||
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||
// The default display is shared with Slint/ArkUI and other video windows.
|
||||
// Terminating it here would invalidate their contexts on disconnect.
|
||||
eglReleaseThread();
|
||||
{ std::lock_guard lock(mutex_); window_ = nullptr; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CAPTURE_SURFACE_H_
|
||||
#define _CAPTURE_SURFACE_H_
|
||||
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <native_image/native_image.h>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
// Owns the capture texture and its EGL context on one worker. Some graphics
|
||||
// drivers expose GPU-backed capture buffers that cannot be read via mmap.
|
||||
class CaptureSurface {
|
||||
public:
|
||||
using Frame = std::function<void(const uint8_t*, int, int, int)>;
|
||||
~CaptureSurface() { Stop(); }
|
||||
OHNativeWindow* Start(int width, int height, Frame frame);
|
||||
void Stop();
|
||||
private:
|
||||
void Run(int width, int height, Frame frame);
|
||||
std::mutex mutex_;
|
||||
std::condition_variable ready_;
|
||||
bool prepared_ = false, stopping_ = true;
|
||||
unsigned pending_ = 0;
|
||||
OHNativeWindow* window_ = nullptr; // Owned by the worker's OH_NativeImage.
|
||||
std::thread thread_;
|
||||
};
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,131 @@
|
||||
#include "input.h"
|
||||
#include "key_mapping.h"
|
||||
#include <multimodalinput/oh_input_manager.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <hilog/log.h>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
namespace {
|
||||
void ReportResult(const char* kind, int result, int& previous) {
|
||||
if (result == previous) return;
|
||||
previous = result;
|
||||
OH_LOG_Print(LOG_APP, result == INPUT_SUCCESS ? LOG_INFO : LOG_WARN, 0, "SlintDesktop",
|
||||
"Host %{public}s injection result: %{public}d", kind, result);
|
||||
}
|
||||
KeyboardKey NormalizeKey(int key, uint32_t scan, bool extended) {
|
||||
const int native = FromVirtualKey(key, scan, extended);
|
||||
return {native ? ToVirtualKey(native) : 0, scan, extended};
|
||||
}
|
||||
}
|
||||
bool Input::Configure(int width, int height, int display) {
|
||||
if (width <= 0 || height <= 0 || display < 0 || !Release()) return false;
|
||||
width_ = width; height_ = height; display_ = display;
|
||||
return true;
|
||||
}
|
||||
bool Input::Key(const KeyboardKey& key, bool down) {
|
||||
const int code = FromVirtualKey(key.key_code, key.scan_code, key.extended);
|
||||
if (!code) return false;
|
||||
auto* event = OH_Input_CreateKeyEvent();
|
||||
if (!event) return false;
|
||||
OH_Input_SetKeyEventKeyCode(event, code);
|
||||
OH_Input_SetKeyEventAction(event, down ? KEY_ACTION_DOWN : KEY_ACTION_UP);
|
||||
const int result = OH_Input_InjectKeyEvent(event);
|
||||
ReportResult("keyboard", result, last_key_result_);
|
||||
const bool ok = result == INPUT_SUCCESS;
|
||||
OH_Input_DestroyKeyEvent(&event);
|
||||
if (ok) { if (down) keys_[key.key_code] = key; else keys_.erase(key.key_code); }
|
||||
return ok;
|
||||
}
|
||||
bool Input::Pointer(int flag, float x, float y, int scroll) {
|
||||
if (flag < move || flag > wheel_horizontal || width_ <= 0 || height_ <= 0 ||
|
||||
!std::isfinite(x) || !std::isfinite(y)) return false;
|
||||
auto* event = OH_Input_CreateMouseEvent();
|
||||
if (!event) return false;
|
||||
x_ = std::clamp(x, 0.f, 1.f); y_ = std::clamp(y, 0.f, 1.f);
|
||||
OH_Input_SetMouseEventDisplayId(event, display_);
|
||||
OH_Input_SetMouseEventDisplayX(event, std::lround(x_ * (width_ - 1)));
|
||||
OH_Input_SetMouseEventDisplayY(event, std::lround(y_ * (height_ - 1)));
|
||||
const int buttons[] = {MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_MIDDLE};
|
||||
int button = MOUSE_BUTTON_NONE;
|
||||
// WMS rejects drag moves with BUTTON_NONE, even if a preceding down event
|
||||
// populated the SDK's pressed-button set. Carry the held button on moves.
|
||||
for (int i = 0; i < 3; ++i) if (buttons_ & (1u << i)) { button = buttons[i]; break; }
|
||||
OH_Input_SetMouseEventButton(event, button);
|
||||
bool ok = true;
|
||||
const auto inject = [&] {
|
||||
const int result = OH_Input_InjectMouseEvent(event);
|
||||
ReportResult("mouse", result, last_mouse_result_);
|
||||
return result == INPUT_SUCCESS;
|
||||
};
|
||||
if (flag >= wheel_vertical) {
|
||||
OH_Input_SetMouseEventAxisType(event, flag == wheel_vertical ? MOUSE_AXIS_SCROLL_VERTICAL : MOUSE_AXIS_SCROLL_HORIZONTAL);
|
||||
for (int action : {MOUSE_ACTION_AXIS_BEGIN, MOUSE_ACTION_AXIS_UPDATE, MOUSE_ACTION_AXIS_END}) {
|
||||
// Wire values are wheel steps. Only UPDATE carries a delta; repeating
|
||||
// it on all three phases scrolls three times for one remote event.
|
||||
OH_Input_SetMouseEventAxisValue(event, action == MOUSE_ACTION_AXIS_UPDATE ? std::clamp(scroll, -1200, 1200) : 0);
|
||||
OH_Input_SetMouseEventAction(event, action);
|
||||
ok = inject() && ok;
|
||||
}
|
||||
} else {
|
||||
OH_Input_SetMouseEventAction(event, flag == move ? MOUSE_ACTION_MOVE :
|
||||
(flag & 1) ? MOUSE_ACTION_BUTTON_DOWN : MOUSE_ACTION_BUTTON_UP);
|
||||
if (flag != move) {
|
||||
const int index = (flag - 1) / 2;
|
||||
OH_Input_SetMouseEventButton(event, buttons[index]);
|
||||
}
|
||||
ok = inject();
|
||||
if (ok && flag != move) {
|
||||
const unsigned bit = 1u << ((flag - 1) / 2);
|
||||
if (flag & 1) buttons_ |= bit; else buttons_ &= ~bit;
|
||||
}
|
||||
}
|
||||
OH_Input_DestroyMouseEvent(&event);
|
||||
return ok;
|
||||
}
|
||||
bool Input::Apply(const RemoteAction& action) {
|
||||
if (action.type == mouse) return Pointer(action.m.flag, action.m.x, action.m.y, action.m.s);
|
||||
if (action.type == keyboard) {
|
||||
if (action.k.flag != key_down && action.k.flag != key_up) return false;
|
||||
return Key(NormalizeKey(static_cast<int>(action.k.key_value), action.k.scan_code, action.k.extended), action.k.flag == key_down);
|
||||
}
|
||||
if (action.type == keyboard_state) {
|
||||
if (action.ks.pressed_count > kMaxKeyboardStateKeys) return false;
|
||||
if (action.ks.seq && have_key_sequence_ && static_cast<int32_t>(action.ks.seq - key_sequence_) <= 0) return true;
|
||||
key_sequence_ = action.ks.seq; have_key_sequence_ = true;
|
||||
PressedKeys next;
|
||||
for (size_t i = 0; i < action.ks.pressed_count; ++i) {
|
||||
const auto& wire = action.ks.pressed_keys[i];
|
||||
const auto key = NormalizeKey(wire.key_value, wire.scan_code, wire.extended);
|
||||
if (key.key_code) next[key.key_code] = key;
|
||||
}
|
||||
bool ok = true;
|
||||
for (const auto& change : ReconcileKeyboardState(keys_, next)) {
|
||||
if (change.is_down && !ok) continue;
|
||||
ok = Key({change.key_code, change.scan_code, change.extended}, change.is_down) && ok;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool Input::Release() {
|
||||
for (const auto& change : ReconcileKeyboardState(keys_, {}))
|
||||
Key({change.key_code, change.scan_code, change.extended}, false);
|
||||
for (int button = 0; button < 3; ++button)
|
||||
if (buttons_ & (1u << button)) Pointer(2 + button * 2, x_, y_, 0);
|
||||
// Failed releases remain pending so a later cleanup can retry them.
|
||||
have_key_sequence_ = false;
|
||||
return keys_.empty() && buttons_ == 0;
|
||||
}
|
||||
bool Input::LockWorkstation() {
|
||||
// The public Harmony shortcut is Meta+L. Never carry a prior remote drag or
|
||||
// modifier into it, and retain failed releases for the normal cleanup path.
|
||||
if (!Release()) return false;
|
||||
const KeyboardKey meta{0x5B, 0x5B, true}, letter{0x4C, 0, false};
|
||||
bool ok = Key(meta, true);
|
||||
if (ok) ok = Key(letter, true);
|
||||
if (keys_.count(letter.key_code)) ok = Key(letter, false) && ok;
|
||||
if (keys_.count(meta.key_code)) ok = Key(meta, false) && ok;
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-14
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _INPUT_H_
|
||||
#define _INPUT_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
#include <keyboard_state.h>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
class Input {
|
||||
public:
|
||||
bool Configure(int width, int height, int display);
|
||||
bool Apply(const RemoteAction& action);
|
||||
bool Release();
|
||||
bool LockWorkstation();
|
||||
private:
|
||||
bool Key(const KeyboardKey& key, bool down);
|
||||
bool Pointer(int flag, float x, float y, int scroll);
|
||||
int width_ = 0, height_ = 0, display_ = 0;
|
||||
float x_ = 0.5f, y_ = 0.5f;
|
||||
PressedKeys keys_;
|
||||
int last_key_result_ = -1, last_mouse_result_ = -1;
|
||||
unsigned buttons_ = 0;
|
||||
uint32_t key_sequence_ = 0;
|
||||
bool have_key_sequence_ = false;
|
||||
};
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-14
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _KEY_MAPPING_H_
|
||||
#define _KEY_MAPPING_H_
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
// CrossDesk's keyboard wire format uses Windows virtual-key values. HarmonyOS
|
||||
// key codes are shared by ArkUI KeyEvent and the native Input Kit.
|
||||
inline constexpr std::pair<int, int> kSpecialKeys[] = {
|
||||
{2045, 0xA4}, {2046, 0xA5}, {2047, 0xA0}, {2048, 0xA1},
|
||||
{2049, 0x09}, {2050, 0x20}, {2054, 0x0D}, {2055, 0x08},
|
||||
{2056, 0xC0}, {2057, 0xBD}, {2058, 0xBB}, {2059, 0xDB},
|
||||
{2060, 0xDD}, {2061, 0xDC}, {2062, 0xBA}, {2063, 0xDE},
|
||||
{2064, 0xBF}, {2043, 0xBC}, {2044, 0xBE},
|
||||
{2070, 0x1B}, {2071, 0x2E}, {2072, 0xA2}, {2073, 0xA3},
|
||||
{2074, 0x14}, {2075, 0x91}, {2076, 0x5B}, {2077, 0x5C},
|
||||
{2079, 0x2C}, {2080, 0x13}, {2081, 0x24}, {2082, 0x23},
|
||||
{2083, 0x2D}, {2068, 0x21}, {2069, 0x22},
|
||||
{2012, 0x26}, {2013, 0x28}, {2014, 0x25}, {2015, 0x27},
|
||||
{2102, 0x90}, {2113, 0x6F}, {2114, 0x6A}, {2115, 0x6D},
|
||||
{2116, 0x6B}, {2117, 0x6E}, {2067, 0x5D}};
|
||||
|
||||
inline int ToVirtualKey(int key) {
|
||||
if (key >= 2017 && key <= 2042) return key - 2017 + 0x41;
|
||||
if (key >= 2000 && key <= 2009) return key - 2000 + 0x30;
|
||||
if (key >= 2090 && key <= 2101) return key - 2090 + 0x70;
|
||||
if (key >= 2816 && key <= 2827) return key - 2816 + 0x7C;
|
||||
if (key >= 2103 && key <= 2112) return key - 2103 + 0x60;
|
||||
if (key == 2119) return 0x0D;
|
||||
for (auto [native, wire] : kSpecialKeys) if (key == native) return wire;
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline int FromVirtualKey(int key, unsigned scan = 0, bool extended = false) {
|
||||
scan &= 0xFF;
|
||||
if (key == 0x10) return scan == 0x36 ? 2048 : 2047;
|
||||
if (key == 0x11) return extended ? 2073 : 2072;
|
||||
if (key == 0x12) return extended ? 2046 : 2045;
|
||||
if (key == 0x0D && extended) return 2119;
|
||||
// Windows reports navigation VKs for the number pad with NumLock off.
|
||||
// Preserve the physical key; the host's own NumLock state selects its action.
|
||||
if (!extended) {
|
||||
constexpr std::pair<int, unsigned> navigation[] = {
|
||||
{0x2D, 0x52}, {0x23, 0x4F}, {0x28, 0x50}, {0x22, 0x51}, {0x25, 0x4B},
|
||||
{0x0C, 0x4C}, {0x27, 0x4D}, {0x24, 0x47}, {0x26, 0x48}, {0x21, 0x49}};
|
||||
for (int i = 0; i < 10; ++i)
|
||||
if (key == navigation[i].first && scan == navigation[i].second) return 2103 + i;
|
||||
if (key == 0x2E && scan == 0x53) return 2117;
|
||||
}
|
||||
if (key >= 0x41 && key <= 0x5A) return key - 0x41 + 2017;
|
||||
if (key >= 0x30 && key <= 0x39) return key - 0x30 + 2000;
|
||||
if (key >= 0x70 && key <= 0x7B) return key - 0x70 + 2090;
|
||||
if (key >= 0x7C && key <= 0x87) return key - 0x7C + 2816;
|
||||
if (key >= 0x60 && key <= 0x69) return key - 0x60 + 2103;
|
||||
for (auto [native, wire] : kSpecialKeys) if (key == wire) return native;
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk::harmony
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "media.h"
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <native_window/external_window.h>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <hilog/log.h>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
Renderer::Renderer(Presented presented) : presented_(std::move(presented)), thread_([this] { Run(); }) {}
|
||||
Renderer::~Renderer() {
|
||||
{ std::lock_guard lock(mutex_); stop_ = true; ready_.notify_one(); }
|
||||
thread_.join();
|
||||
}
|
||||
void Renderer::Surface(uint64_t id) {
|
||||
std::lock_guard lock(mutex_);
|
||||
surface_ = id; changed_ = true; latest_.clear(); ready_.notify_one();
|
||||
rendered_ = 0;
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "Video surface attached=%{public}d", id != 0);
|
||||
}
|
||||
void Renderer::Clear() {
|
||||
std::lock_guard lock(mutex_);
|
||||
latest_.clear(); rendered_ = 0;
|
||||
}
|
||||
void Renderer::Video(const MiniRtcVideoFrame& frame, uint64_t generation) {
|
||||
if (!frame.data || !frame.width || !frame.height || (frame.width & 1) || (frame.height & 1) ||
|
||||
frame.width > 8192 || frame.height > 8192 ||
|
||||
frame.size < size_t(frame.width) * frame.height * 3 / 2) return;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!surface_ || stop_) return;
|
||||
width_ = frame.width; height_ = frame.height;
|
||||
captured_ = frame.captured_timestamp; received_ = frame.received_timestamp; generation_ = generation;
|
||||
latest_.assign(frame.data, frame.data + size_t(width_) * height_ * 3 / 2);
|
||||
ready_.notify_one();
|
||||
}
|
||||
void Renderer::Run() {
|
||||
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
EGLContext context = EGL_NO_CONTEXT;
|
||||
EGLSurface surface = EGL_NO_SURFACE;
|
||||
OHNativeWindow* window = nullptr;
|
||||
GLuint program = 0, textures[2]{}, vertex_array = 0, vertex_buffer = 0;
|
||||
EGLConfig config{};
|
||||
auto destroy = [&] {
|
||||
if (display != EGL_NO_DISPLAY) {
|
||||
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface);
|
||||
}
|
||||
surface = EGL_NO_SURFACE;
|
||||
if (window) OH_NativeWindow_DestroyNativeWindow(window);
|
||||
window = nullptr;
|
||||
};
|
||||
if (display != EGL_NO_DISPLAY && eglInitialize(display, nullptr, nullptr)) {
|
||||
eglBindAPI(EGL_OPENGL_ES_API);
|
||||
// Use one ES3 dispatch library/context/shader pipeline. The emulator's
|
||||
// GLESv2 entry points can return zero handles without a GL error.
|
||||
const EGLint attrs[] = {EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
|
||||
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE};
|
||||
EGLint count = 0;
|
||||
eglChooseConfig(display, attrs, &config, 1, &count);
|
||||
const EGLint ctx[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
|
||||
if (count) context = eglCreateContext(display, config, EGL_NO_CONTEXT, ctx);
|
||||
}
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "EGL initialized: context=%{public}d error=%{public}x",
|
||||
context != EGL_NO_CONTEXT, eglGetError());
|
||||
bool reported_error = false;
|
||||
bool shader_failed = false;
|
||||
for (;;) {
|
||||
std::vector<uint8_t> pixels;
|
||||
int width, height; uint64_t id, captured, received, generation; bool changed;
|
||||
{
|
||||
std::unique_lock lock(mutex_);
|
||||
ready_.wait(lock, [&] { return stop_ || changed_ || !latest_.empty(); });
|
||||
if (stop_) break;
|
||||
pixels.swap(latest_); width = width_; height = height_;
|
||||
id = surface_; changed = changed_; changed_ = false;
|
||||
captured = captured_; received = received_; generation = generation_;
|
||||
}
|
||||
if (changed) {
|
||||
destroy();
|
||||
reported_error = false;
|
||||
shader_failed = false;
|
||||
int window_result = id ? OH_NativeWindow_CreateNativeWindowFromSurfaceId(id, &window) : -1;
|
||||
if (id && context != EGL_NO_CONTEXT && window_result == 0) {
|
||||
surface = eglCreateWindowSurface(display, config, reinterpret_cast<EGLNativeWindowType>(window), nullptr);
|
||||
}
|
||||
if (id) OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "EGL surface: window=%{public}d surface=%{public}d error=%{public}x",
|
||||
window_result, surface != EGL_NO_SURFACE, eglGetError());
|
||||
}
|
||||
if (pixels.empty()) continue;
|
||||
if (surface == EGL_NO_SURFACE || !eglMakeCurrent(display, surface, surface, context)) {
|
||||
if (!reported_error) OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "CrossDesk", "Cannot bind video surface: %{public}x", eglGetError());
|
||||
reported_error = true; continue;
|
||||
}
|
||||
if (shader_failed) continue;
|
||||
if (!program) {
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "GL version: %{public}s", glGetString(GL_VERSION));
|
||||
const char* vs = "#version 300 es\nlayout(location=0) in vec2 position; layout(location=1) in vec2 uv; out vec2 coord; void main(){coord=uv;gl_Position=vec4(position,0.,1.);}";
|
||||
const char* fs = "#version 300 es\nprecision mediump float; in vec2 coord; uniform sampler2D yTex; uniform sampler2D uvTex; out vec4 color; void main(){float y=1.164383*(texture(yTex,coord).r-0.062745);vec2 c=texture(uvTex,coord).rg-vec2(0.5);color=vec4(y+1.596027*c.y,y-0.391762*c.x-0.812968*c.y,y+2.017232*c.x,1.);}";
|
||||
auto shader = [](GLenum type, const char* src) {
|
||||
GLuint s = glCreateShader(type); glShaderSource(s, 1, &src, nullptr); glCompileShader(s);
|
||||
GLint compiled = GL_FALSE; glGetShaderiv(s, GL_COMPILE_STATUS, &compiled);
|
||||
if (!s || !compiled) {
|
||||
char info[1024]{}; glGetShaderInfoLog(s, sizeof(info), nullptr, info);
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "CrossDesk", "Video shader compilation failed: id=%{public}u %{public}s", s, info);
|
||||
glDeleteShader(s); return GLuint(0);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
GLuint v = shader(GL_VERTEX_SHADER, vs), f = shader(GL_FRAGMENT_SHADER, fs);
|
||||
if (!v || !f) {
|
||||
glDeleteShader(v); glDeleteShader(f); shader_failed = true; continue;
|
||||
}
|
||||
program = glCreateProgram(); glAttachShader(program, v); glAttachShader(program, f);
|
||||
glBindAttribLocation(program, 0, "position"); glBindAttribLocation(program, 1, "uv");
|
||||
glLinkProgram(program); glDeleteShader(v); glDeleteShader(f);
|
||||
GLint linked = GL_FALSE; glGetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
if (!program || !linked) {
|
||||
char info[1024]{}; glGetProgramInfoLog(program, sizeof(info), nullptr, info);
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "CrossDesk", "Video shader link failed: %{public}s", info);
|
||||
glDeleteProgram(program); program = 0; shader_failed = true; continue;
|
||||
}
|
||||
const GLfloat vertices[] = {-1,-1,0,1, 1,-1,1,1, -1,1,0,0, 1,1,1,0};
|
||||
glGenVertexArrays(1, &vertex_array); glBindVertexArray(vertex_array);
|
||||
glGenBuffers(1, &vertex_buffer); glBindBuffer(GL_ARRAY_BUFFER, vertex_buffer);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), nullptr);
|
||||
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), reinterpret_cast<const void*>(2 * sizeof(float)));
|
||||
glEnableVertexAttribArray(0); glEnableVertexAttribArray(1);
|
||||
glGenTextures(2, textures);
|
||||
for (GLuint texture : textures) {
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
}
|
||||
EGLint sw = 0, sh = 0;
|
||||
eglQuerySurface(display, surface, EGL_WIDTH, &sw); eglQuerySurface(display, surface, EGL_HEIGHT, &sh);
|
||||
glViewport(0, 0, sw, sh); glClearColor(0.035f, 0.05f, 0.08f, 1); glClear(GL_COLOR_BUFFER_BIT);
|
||||
const float scale = std::min(float(sw) / width, float(sh) / height);
|
||||
const int rw = int(width * scale), rh = int(height * scale);
|
||||
glViewport((sw - rw) / 2, (sh - rh) / 2, rw, rh);
|
||||
glUseProgram(program); glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
glActiveTexture(GL_TEXTURE0 + i); glBindTexture(GL_TEXTURE_2D, textures[i]);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, i ? GL_RG8 : GL_R8,
|
||||
i ? width / 2 : width, i ? height / 2 : height, 0,
|
||||
i ? GL_RG : GL_RED, GL_UNSIGNED_BYTE,
|
||||
pixels.data() + (i ? size_t(width) * height : 0));
|
||||
}
|
||||
glUniform1i(glGetUniformLocation(program, "yTex"), 0);
|
||||
glUniform1i(glGetUniformLocation(program, "uvTex"), 1);
|
||||
glBindVertexArray(vertex_array);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
const GLenum draw_error = glGetError();
|
||||
const bool swapped = eglSwapBuffers(display, surface);
|
||||
if (draw_error == GL_NO_ERROR && swapped && sw > 0 && sh > 0) {
|
||||
if (presented_) presented_(pixels.data(), width, height, captured, received, generation);
|
||||
if (rendered_.fetch_add(1) == 0)
|
||||
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "CrossDesk", "First video frame presented: %{public}dx%{public}d on %{public}dx%{public}d",
|
||||
width, height, sw, sh);
|
||||
} else if (!reported_error) {
|
||||
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "CrossDesk", "Video presentation failed: gl=%{public}x egl=%{public}x size=%{public}dx%{public}d",
|
||||
draw_error, eglGetError(), sw, sh);
|
||||
reported_error = true;
|
||||
}
|
||||
}
|
||||
if (context != EGL_NO_CONTEXT && surface != EGL_NO_SURFACE) {
|
||||
eglMakeCurrent(display, surface, surface, context); glDeleteTextures(2, textures); glDeleteProgram(program);
|
||||
glDeleteBuffers(1, &vertex_buffer); glDeleteVertexArrays(1, &vertex_array);
|
||||
}
|
||||
destroy();
|
||||
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||
// Keep the process-wide display alive for capture and other UI contexts.
|
||||
eglReleaseThread();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-14
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _MEDIA_H_
|
||||
#define _MEDIA_H_
|
||||
|
||||
#include <minirtc.h>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
class Renderer {
|
||||
public:
|
||||
using Presented = std::function<void(const uint8_t*, int, int, uint64_t, uint64_t, uint64_t)>;
|
||||
explicit Renderer(Presented presented = {});
|
||||
~Renderer();
|
||||
void Surface(uint64_t id);
|
||||
void Video(const MiniRtcVideoFrame& frame, uint64_t generation = 0);
|
||||
void Clear();
|
||||
private:
|
||||
void Run();
|
||||
std::mutex mutex_;
|
||||
std::condition_variable ready_;
|
||||
uint64_t surface_ = 0;
|
||||
bool changed_ = false, stop_ = false;
|
||||
int width_ = 0, height_ = 0;
|
||||
std::vector<uint8_t> latest_;
|
||||
uint64_t captured_ = 0, received_ = 0, generation_ = 0;
|
||||
Presented presented_;
|
||||
std::atomic<uint64_t> rendered_{0};
|
||||
// Start only after all state used by Run() has been initialized.
|
||||
std::thread thread_;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,413 @@
|
||||
#include "mobile_session.h"
|
||||
#include "media.h"
|
||||
#include "audio.h"
|
||||
#include "key_mapping.h"
|
||||
#include <app_version.h>
|
||||
#include <mobile_controller_protocol.h>
|
||||
#include <mobile_file_transfer_session.h>
|
||||
#include <mobile_video_timing.h>
|
||||
#include <display_stream_id.h>
|
||||
#include <log.h>
|
||||
#include <libyuv.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
namespace {
|
||||
using json = nlohmann::json;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
// A separate controller-only entry point. No Capture, Input, AddVideoStream,
|
||||
// host input handler or screen-recording authorization exists in this session.
|
||||
class MobileSession {
|
||||
public:
|
||||
MobileSession() : renderer_([this](const uint8_t* pixels, int w, int h, uint64_t captured,
|
||||
uint64_t received, uint64_t generation) {
|
||||
const auto session = session_.load(), connection = connection_.load();
|
||||
if (!connected_ || generation != video_generation_) return;
|
||||
const auto now = std::chrono::duration_cast<std::chrono::microseconds>(Clock::now().time_since_epoch()).count();
|
||||
// MiniRTC uses its own monotonic clock; the offset is sampled on its worker.
|
||||
const double latency = mobile_controller::VideoLatencyMilliseconds(captured, received, now + clock_offset_);
|
||||
Event({{"type", "frame"}, {"width", w}, {"height", h}, {"latency", latency}, {"frame", ++frame_id_},
|
||||
{"videoGeneration", generation}, {"session", session}, {"connection", connection}}, true);
|
||||
std::string path;
|
||||
uint64_t preview_epoch;
|
||||
{ std::lock_guard lock(preview_mutex_); path.swap(preview_path_); preview_epoch = preview_epoch_; }
|
||||
if (path.empty()) return;
|
||||
// Only the decoded video is sampled, never the device screen or controls.
|
||||
// Downscale YUV first: a 4K/8K desktop must not allocate a full-size RGBA copy on a phone.
|
||||
std::vector<uint8_t> scaled(640 * 360 * 3 / 2), preview(640 * 360 * 4);
|
||||
const int cw = std::min(w, (h * 16 / 9) & ~1), ch = std::min(h, (w * 9 / 16) & ~1);
|
||||
const int left = ((w-cw)/2)&~1, top = ((h-ch)/2)&~1;
|
||||
bool ok = cw > 0 && ch > 0 && libyuv::NV12Scale(pixels+size_t(top)*w+left,w,
|
||||
pixels+size_t(w)*h+size_t(top/2)*w+left,w,cw,ch,scaled.data(),640,
|
||||
scaled.data()+640*360,640,640,360,libyuv::kFilterBilinear) == 0;
|
||||
if (ok) ok = libyuv::NV12ToABGR(scaled.data(),640,scaled.data()+640*360,640,preview.data(),640*4,640,360) == 0;
|
||||
std::lock_guard lock(preview_mutex_);
|
||||
if (ok && connected_ && generation == video_generation_ && preview_epoch == preview_epoch_) {
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out.write(reinterpret_cast<const char*>(preview.data()), preview.size()); out.close(); ok = bool(out);
|
||||
} else ok = false;
|
||||
if (!ok) std::remove(path.c_str());
|
||||
Event({{"type", "preview"}, {"path", path}, {"ok", ok}, {"videoGeneration", generation},
|
||||
{"session", session}, {"connection", connection}}, true);
|
||||
}), worker_([this] { Run(); }) {}
|
||||
~MobileSession() {
|
||||
{ std::lock_guard lock(queue_mutex_); stopping_ = true; pending_.clear(); ready_.notify_one(); }
|
||||
worker_.join();
|
||||
}
|
||||
void Queue(std::function<void()> fn) {
|
||||
std::lock_guard lock(queue_mutex_);
|
||||
if (!stopping_ && pending_.size() < 8192) { pending_.push_back(std::move(fn)); ready_.notify_one(); }
|
||||
}
|
||||
void Command(std::string text) { Queue([this, text = std::move(text)] { Execute(json::parse(text)); }); }
|
||||
std::string Poll() {
|
||||
std::lock_guard lock(events_mutex_);
|
||||
auto value = events_.dump(-1, ' ', false, json::error_handler_t::replace); events_ = json::array(); return value;
|
||||
}
|
||||
private:
|
||||
struct Context { MobileSession* owner; bool controller; uint64_t generation; };
|
||||
void Event(json value, bool controller = false) {
|
||||
if (!value.contains("session")) value["session"] = session_.load();
|
||||
if (controller && !value.contains("connection")) value["connection"] = connection_.load();
|
||||
std::lock_guard lock(events_mutex_);
|
||||
// Coalesce high-frequency samples, never displace identity/password or terminal events.
|
||||
if (value["type"] == "frame") value["latencyCount"] = value["latency"].get<double>() >= 0 ? 1 : 0;
|
||||
if (value["type"] == "frame" || value["type"] == "network") {
|
||||
for (auto it = events_.begin(); it != events_.end();) {
|
||||
if ((*it)["type"] == value["type"]) {
|
||||
if (value["type"] == "frame" && (*it)["session"] == value["session"] &&
|
||||
(*it)["connection"] == value["connection"] && (*it)["videoGeneration"] == value["videoGeneration"]) {
|
||||
const int previous = it->value("latencyCount", 0), count = value["latencyCount"];
|
||||
if (previous + count > 0) value["latency"] =
|
||||
(it->value("latency", 0.) * previous + value["latency"].get<double>() * count) / (previous + count);
|
||||
value["latencyCount"] = previous + count;
|
||||
}
|
||||
it = events_.erase(it);
|
||||
} else ++it;
|
||||
}
|
||||
}
|
||||
if (events_.size() < 1024) events_.push_back(std::move(value));
|
||||
}
|
||||
bool Current(bool controller, uint64_t generation) const {
|
||||
return generation == (controller ? connection_.load() : session_.load());
|
||||
}
|
||||
template<typename F> static void Dispatch(void* user, F fn) {
|
||||
auto* c = static_cast<Context*>(user); auto* self = c->owner;
|
||||
self->Queue([self, controller = c->controller, generation = c->generation, fn] {
|
||||
if (self->Current(controller, generation)) fn(self, controller);
|
||||
});
|
||||
}
|
||||
static void Signal(SignalStatus status, const char*, size_t, void* user) {
|
||||
Dispatch(user, [status](MobileSession* s, bool controller) {
|
||||
if (!controller) {
|
||||
s->online_ = status == SignalConnected;
|
||||
if (s->signal_status_ != status) {
|
||||
++s->signal_generation_;
|
||||
if (s->signal_status_ == SignalConnected) s->signal_started_ = Clock::now();
|
||||
}
|
||||
s->signal_status_ = status;
|
||||
if (s->online_) {
|
||||
constexpr char info[] = R"({"type":"client_info","version":"harmony-mobile","platform":"harmonyos"})";
|
||||
SendSignalMessage(s->identity_, info, sizeof(info)-1);
|
||||
}
|
||||
}
|
||||
s->Event({{"type", "signal"}, {"controller", controller}, {"status", status},
|
||||
{"generation", s->signal_generation_}, {"id", s->login_.substr(0, s->login_.find('@'))}});
|
||||
if (controller && status >= SignalFailed && status != SignalReconnecting) s->Disconnect("远程会话信令连接失败");
|
||||
});
|
||||
}
|
||||
static void SignalMessage(const char* data, size_t size, void* user) {
|
||||
if (!data || size > 1024*1024) return;
|
||||
const auto value = json::parse(data, data+size, nullptr, false);
|
||||
if (!value.is_object()) return;
|
||||
Dispatch(user, [value](MobileSession* s, bool controller) {
|
||||
if (controller || !s->online_) return;
|
||||
const auto type = value.value("type", "");
|
||||
if (type == "announcements" || type == "announcements_changed" || type == "presence" || type == "presence_update")
|
||||
s->Event({{"type", "signalMessage"}, {"message", value}, {"generation", s->signal_generation_}});
|
||||
});
|
||||
}
|
||||
static void Connection(ConnectionStatus status, const char* id, size_t size, void* user) {
|
||||
const std::string remote(id ? id : "", id ? size : 0);
|
||||
Dispatch(user, [status, remote](MobileSession* s, bool controller) {
|
||||
if (!controller) {
|
||||
// Defense in depth if a server tries to attach a guest to our identity.
|
||||
if (status == Connected && s->identity_)
|
||||
DisconnectPeerConnection(s->identity_, s->login_.substr(0,s->login_.find('@')).c_str(), remote.c_str());
|
||||
return;
|
||||
}
|
||||
s->Event({{"type", "connection"}, {"status", status}}, true);
|
||||
if (status == Connected && !s->connected_) {
|
||||
s->connecting_ = false; s->connected_ = true;
|
||||
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, false, s->version_, HostPlatform::HarmonyOS);
|
||||
s->Send(info); FreeRemoteAction(info);
|
||||
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
|
||||
s->audio_.Start();
|
||||
} else if (status >= Disconnected) s->Disconnect("");
|
||||
});
|
||||
}
|
||||
static void Network(const char* id, size_t size, TraversalMode mode, const MiniRtcNetTrafficStats* stats,
|
||||
const char*, size_t, void* user) {
|
||||
if (mode == UnknownMode && id && size) {
|
||||
const std::string login(id, size);
|
||||
Dispatch(user, [login](MobileSession* s, bool controller) {
|
||||
if (!controller) {
|
||||
// Reconnect reports may contain only the public ID. Never replace a
|
||||
// usable ID@password with that public-only report (same as Android).
|
||||
if (login.find('@') != std::string::npos || s->login_.rfind(login + "@", 0) != 0) s->login_ = login;
|
||||
s->Event({{"type", "identity"}, {"identity", s->login_}});
|
||||
}
|
||||
});
|
||||
} else if (stats) {
|
||||
auto report = mobile_controller::NetworkReport(*stats, mode);
|
||||
Dispatch(user, [report](MobileSession* s, bool controller) {
|
||||
if (controller && s->connected_) s->Event({{"type", "network"}, {"report", report}}, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
static void Data(const char* data, size_t size, const char*, size_t, const char* stream, size_t length, void* user) {
|
||||
if (!data || !stream || size > 1024*1024) return;
|
||||
const std::string bytes(data,size), name(stream,length);
|
||||
Dispatch(user, [bytes,name](MobileSession* s, bool controller) {
|
||||
// The data channel can deliver host information/file chunks before the
|
||||
// local ICE Connected notification. Keep those authenticated packets.
|
||||
if (!controller || (!s->connected_ && !s->connecting_)) return;
|
||||
if (name == kFileStream && s->files_) s->files_->Receive(bytes.data(), bytes.size());
|
||||
else if (name == kFileFeedbackStream && s->files_) s->files_->Ack(bytes.data(), bytes.size());
|
||||
else if (name == kClipboardStream && bytes.size() <= kMaxClipboardBytes)
|
||||
s->Event({{"type", "clipboard"}, {"text", bytes}}, true);
|
||||
else if (name == kControlStream) {
|
||||
RemoteAction action{};
|
||||
if (action.from_json(bytes)) {
|
||||
const auto value = json::parse(action.to_json()); FreeRemoteAction(action);
|
||||
s->Event({{"type", "data"}, {"message", value}}, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
static void Video(const MiniRtcVideoFrame* frame, const char*, size_t, const char* stream, size_t size, void* user) {
|
||||
auto* c = static_cast<Context*>(user); auto* s = c->owner;
|
||||
if (c->controller && c->generation == s->connection_ && s->connected_ && !s->suspended_ && frame && stream &&
|
||||
std::string(stream,size) == MakeDisplayStreamId(s->display_)) s->renderer_.Video(*frame, s->video_generation_);
|
||||
}
|
||||
static void Audio(const char* data, size_t size, const char*, size_t, const char*, size_t, void* user) {
|
||||
auto* c = static_cast<Context*>(user); auto* s = c->owner;
|
||||
if (c->controller && c->generation == s->connection_ && s->connected_ && !s->muted_ && !s->suspended_) s->audio_.Push(data,size);
|
||||
}
|
||||
Params Parameters(bool controller) {
|
||||
Params p{};
|
||||
std::snprintf(p.signal_server_ip,sizeof(p.signal_server_ip),"%s",server_.c_str());
|
||||
std::snprintf(p.log_path,sizeof(p.log_path),"%s",logs_.c_str());
|
||||
p.signal_server_port = port_; p.enable_srtp = true; p.turn_mode = TurnAutoUdpTcp;
|
||||
p.video_content_type = VideoContentType::ScreenContent; p.video_quality = QualityMedium; p.video_frame_rate = 30;
|
||||
p.video_degradation_preference = VideoDegradationPreference::MaintainResolution;
|
||||
p.hardware_acceleration = true;
|
||||
p.user_id = controller ? controller_login_.c_str() : login_.c_str();
|
||||
p.user_data = controller ? control_context_.get() : identity_context_.get();
|
||||
p.on_signal_status = Signal; p.on_signal_message = SignalMessage; p.on_connection_status = Connection;
|
||||
p.on_net_status_report = Network; p.on_receive_data_buffer = Data; p.on_receive_video_frame = Video; p.on_receive_audio_buffer = Audio;
|
||||
return p;
|
||||
}
|
||||
void Send(const RemoteAction& action, const char* stream = kControlStream) {
|
||||
if (!controller_) return;
|
||||
const auto bytes = action.to_json(); SendReliableDataFrame(controller_,bytes.data(),bytes.size(),stream);
|
||||
}
|
||||
void Release() {
|
||||
for (int key : keys_) { RemoteAction a{}; a.type=keyboard; a.k={size_t(key),0,false,key_up}; Send(a,kKeyboardStream); }
|
||||
for (int i=0;i<3;i++) if (buttons_ & (1u<<i)) {
|
||||
RemoteAction a{}; a.type=mouse; a.m={x_,y_,0,static_cast<MouseFlag>(2+i*2)}; Send(a,kMouseStream);
|
||||
}
|
||||
keys_.clear(); buttons_=0;
|
||||
}
|
||||
void CancelPreview() {
|
||||
std::lock_guard lock(preview_mutex_); preview_path_.clear(); ++preview_epoch_;
|
||||
std::error_code error;
|
||||
if (preview_directory_.empty()) return;
|
||||
for (const auto& file : std::filesystem::directory_iterator(preview_directory_, error)) {
|
||||
const auto name = file.path().filename().string();
|
||||
if (name.rfind("preview-", 0) == 0 && file.path().extension() == ".rgba") std::filesystem::remove(file.path(), error);
|
||||
}
|
||||
}
|
||||
void Disconnect(const std::string& reason) {
|
||||
const bool active = controller_ != nullptr;
|
||||
Release(); connected_=false; connecting_=false; ++video_generation_;
|
||||
CancelPreview();
|
||||
if (controller_) { LeaveConnection(controller_,remote_.c_str()); DestroyPeer(&controller_); }
|
||||
files_.reset(); renderer_.Clear(); audio_.Stop();
|
||||
if (active) Event({{"type","ended"},{"reason",reason}},true);
|
||||
++connection_; display_=0; frame_id_=0;
|
||||
}
|
||||
void Stop() {
|
||||
Disconnect(""); online_=false;
|
||||
if (identity_) DestroyPeer(&identity_);
|
||||
++session_; login_.clear(); signal_status_=-1;
|
||||
}
|
||||
void Execute(const json& v) {
|
||||
const auto op=v.at("op").get<std::string>();
|
||||
if (op=="start") {
|
||||
Stop(); session_=v.at("session").get<uint64_t>();
|
||||
server_=v.at("server").get<std::string>(); port_=v.at("port").get<int>();
|
||||
logs_=v.at("logs").get<std::string>(); login_=v.value("identity",""); version_=v.value("version","0.1.0");
|
||||
const auto roots=v.at("certificates").get<std::string>(); directory_=v.at("directory").get<std::string>();
|
||||
preview_directory_=v.at("previewDirectory").get<std::string>();
|
||||
if (server_.empty()||server_.size()>=256||server_.find('@')!=std::string::npos||port_<1||port_>65535||
|
||||
logs_.size()>=256||roots.empty()||directory_.empty()) throw std::runtime_error("Invalid configuration");
|
||||
setenv("SSL_CERT_DIR",roots.c_str(),1); minirtc::InitLogger(logs_); minirtc::get_logger()->set_level(spdlog::level::off);
|
||||
identity_context_=std::make_unique<Context>(Context{this,false,session_});
|
||||
auto p=Parameters(false); identity_=CreatePeer(&p); signal_started_=Clock::now();
|
||||
if (!identity_||Init(identity_)!=0) { throw std::runtime_error("Identity initialization failed"); }
|
||||
} else if (op=="stop") Stop();
|
||||
else if (op=="version") {
|
||||
const auto body=v.at("body").get<std::string>(), current=v.value("version","");
|
||||
const bool local=v.value("local",false);
|
||||
std::optional<std::string> result;
|
||||
if (local) result=CheckPlatformAppUpdate(current,body,"harmony-arm64");
|
||||
else if (const auto release=ParseVersionInfoJSON(body)) result=AvailableAppUpdate(current,*release).value_or("");
|
||||
Event({{"type","version"},{"request",v.at("request")},{"ok",result.has_value()},{"latest",result.value_or("")}});
|
||||
} else if (op=="surface") renderer_.Surface(std::stoull(v.value("id","0")));
|
||||
else if (op=="connect") {
|
||||
if (!online_||login_.empty()) throw std::runtime_error("Not signed in");
|
||||
Disconnect(""); connection_=v.at("connection").get<uint64_t>();
|
||||
remote_=v.at("remote").get<std::string>(); const auto password=v.at("password").get<std::string>();
|
||||
if (remote_.size()!=9||!std::all_of(remote_.begin(),remote_.end(),::isdigit)||password.size()>128||password.find('@')!=std::string::npos)
|
||||
throw std::runtime_error("Invalid remote credentials");
|
||||
muted_=false; suspended_=false; controller_login_="C-"+login_.substr(0,login_.find('@'));
|
||||
control_context_=std::make_unique<Context>(Context{this,true,connection_});
|
||||
auto p=Parameters(true); controller_=CreatePeer(&p); connecting_=true; connection_started_=Clock::now();
|
||||
if (!controller_) throw std::runtime_error("Peer creation failed");
|
||||
AddAudioStream(controller_,kAudioStream); AddDataStream(controller_,kDataStream,false);
|
||||
for (auto stream : {kMouseStream,kKeyboardStream,kControlStream,kClipboardStream,kFileStream,kFileFeedbackStream})
|
||||
AddDataStream(controller_,stream,true);
|
||||
files_=std::make_unique<mobile_controller::FileTransferSession>(
|
||||
[this](const char* stream,const char* data,size_t size) { return controller_?SendReliableDataFrame(controller_,data,size,stream):-1; },
|
||||
[this](const std::string& name,double progress,bool sending,const std::string& path) {
|
||||
Event({{"type","file"},{"name",name},{"progress",progress},{"sending",sending},{"path",path}},true);
|
||||
});
|
||||
files_->SetDirectory(directory_+"/received");
|
||||
if (Init(controller_)!=0||JoinConnection(controller_,(remote_+"@"+password).c_str())!=0) Disconnect("无法发起连接");
|
||||
} else if (op=="disconnect") Disconnect("");
|
||||
else if (op=="release") Release();
|
||||
else if (op=="signal" && online_ && identity_) {
|
||||
auto request=v.at("message"); const auto type=request.value("type","");
|
||||
if (type!="announcements_list"&&type!="recent_connections_presence") return;
|
||||
request["user_id"]=login_.substr(0,login_.find('@'));
|
||||
const auto bytes=request.dump(); const bool ok=SendSignalMessage(identity_,bytes.data(),bytes.size())==0;
|
||||
if (!ok) Event({{"type","requestFailed"},{"request",request.value("request_id","")}});
|
||||
} else if (connected_) {
|
||||
if (op=="pointer") {
|
||||
const int flag=v.at("flag").get<int>(); const float x=v.at("x").get<float>(),y=v.at("y").get<float>();
|
||||
if (flag<0||flag>8||!std::isfinite(x)||!std::isfinite(y)) return;
|
||||
x_=std::clamp(x,0.f,1.f); y_=std::clamp(y,0.f,1.f);
|
||||
if (flag>=1&&flag<=6) { unsigned bit=1u<<((flag-1)/2); if (flag&1) buttons_|=bit; else buttons_&=~bit; }
|
||||
RemoteAction a{}; a.type=mouse; a.m={x_,y_,std::clamp(v.value("scroll",0),-120,120),static_cast<MouseFlag>(flag)}; Send(a,kMouseStream);
|
||||
} else if (op=="key"||op=="virtualKey") {
|
||||
const int key=op=="key"?ToVirtualKey(v.at("code").get<int>()):v.at("code").get<int>();
|
||||
if (key<=0||key>255) return;
|
||||
const bool down=v.at("down").get<bool>(); if (down) keys_.insert(key); else keys_.erase(key);
|
||||
RemoteAction a{}; a.type=keyboard; a.k={size_t(key),0,false,down?key_down:key_up}; Send(a,kKeyboardStream);
|
||||
} else if (op=="display") {
|
||||
const int index=v.at("index").get<int>(); if(index<0||index>=32)return;
|
||||
Release(); display_=index; ++video_generation_; renderer_.Clear();
|
||||
RemoteAction a{}; a.type=display_id; a.d=index; Send(a);
|
||||
} else if (op=="visible") {
|
||||
suspended_=!v.at("visible").get<bool>();
|
||||
if (suspended_) { renderer_.Clear(); audio_.Stop(); } else if (!muted_) audio_.Start();
|
||||
} else if (op=="mute") {
|
||||
muted_=v.at("muted").get<bool>(); if (muted_||suspended_) audio_.Stop(); else audio_.Start();
|
||||
RemoteAction a{}; a.type=audio_capture; a.a=!muted_; Send(a);
|
||||
} else if (op=="clipboard") {
|
||||
const auto text=v.at("text").get<std::string>();
|
||||
if(!text.empty()&&text.size()<=kMaxClipboardBytes) SendReliableDataFrame(controller_,text.data(),text.size(),kClipboardStream);
|
||||
} else if (op=="videoSettings") {
|
||||
const int q=v.at("quality"),fps=v.at("frameRate"),pref=v.at("preference");
|
||||
if(q<0||q>2||(fps!=30&&fps!=60)||pref<0||pref>2)return;
|
||||
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
|
||||
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
|
||||
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
|
||||
} else if (op=="sas") {
|
||||
const auto bytes=mobile_controller::SecureAttentionMessage();
|
||||
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
|
||||
Event({{"type","feedback"},{"text",ok?"已发送 Ctrl+Alt+Del":"指令发送失败,请重试"}},true);
|
||||
} else if (op=="sendFile") {
|
||||
const auto path=v.at("path").get<std::string>(),name=v.at("name").get<std::string>();
|
||||
// Only disposable staged files owned by this session may be deleted by the transfer engine.
|
||||
const auto parent=std::filesystem::weakly_canonical(std::filesystem::path(path).parent_path());
|
||||
const bool staged=parent==std::filesystem::weakly_canonical(directory_+"/outgoing");
|
||||
if(!staged||!files_||!files_->Start(path,name)) {
|
||||
if (staged) { std::error_code error; std::filesystem::remove(path, error); }
|
||||
Event({{"type","file"},{"name",name},{"progress",-1},{"sending",true},{"path",""}},true);
|
||||
}
|
||||
} else if (op=="preview") {
|
||||
std::lock_guard lock(preview_mutex_);
|
||||
preview_path_=preview_directory_+"/preview-"+std::to_string(video_generation_)+"-"+std::to_string(++preview_epoch_)+".rgba";
|
||||
} else if (op=="cancelPreview") CancelPreview();
|
||||
}
|
||||
}
|
||||
void Tick() {
|
||||
const auto now=Clock::now();
|
||||
if(identity_&&!online_&&now-signal_started_>std::chrono::seconds(40)) {
|
||||
Event({{"type","error"},{"text","服务器连接超时,请检查地址和网络"}}); Stop();
|
||||
}
|
||||
if(connecting_&&now-connection_started_>std::chrono::seconds(40)) Disconnect("连接超时,请确认设备在线");
|
||||
if(!connected_)return;
|
||||
if(files_)files_->Pump();
|
||||
if(now-keyboard_sent_>std::chrono::milliseconds(200)) {
|
||||
RemoteAction a{}; a.type=keyboard_state; a.ks.seq=++keyboard_sequence_;
|
||||
for(int key:keys_) { if(a.ks.pressed_count>=kMaxKeyboardStateKeys)break; a.ks.pressed_keys[a.ks.pressed_count++]={size_t(key),0,false}; }
|
||||
Send(a,kKeyboardStream); keyboard_sent_=now;
|
||||
const auto local=std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
|
||||
const auto rtc=GetSystemTimeMicros(controller_); if(rtc>0)clock_offset_=rtc-local;
|
||||
}
|
||||
}
|
||||
void Run() {
|
||||
for(;;) {
|
||||
std::function<void()> fn;
|
||||
{ std::unique_lock lock(queue_mutex_); ready_.wait_for(lock,std::chrono::milliseconds(10),[this]{return stopping_||!pending_.empty();});
|
||||
if(stopping_)break; if(!pending_.empty()){fn=std::move(pending_.front());pending_.pop_front();} }
|
||||
try { if(fn)fn(); Tick(); }
|
||||
catch(const std::exception&) { Event({{"type","error"},{"text","操作失败,请检查连接参数或重新连接"}}); }
|
||||
}
|
||||
Stop();
|
||||
}
|
||||
std::mutex queue_mutex_,events_mutex_,preview_mutex_;
|
||||
uint64_t preview_epoch_ = 0;
|
||||
std::condition_variable ready_;
|
||||
std::deque<std::function<void()>> pending_;
|
||||
json events_=json::array();
|
||||
bool stopping_=false,online_=false,connecting_=false;
|
||||
std::atomic<bool> connected_{false},muted_{false},suspended_{false};
|
||||
std::atomic<uint64_t> session_{0},connection_{0},video_generation_{1},frame_id_{0};
|
||||
std::atomic<int64_t> clock_offset_{0};
|
||||
std::atomic<int> display_{0};
|
||||
PeerPtr* identity_=nullptr;
|
||||
PeerPtr* controller_=nullptr;
|
||||
std::unique_ptr<Context> identity_context_,control_context_;
|
||||
std::unique_ptr<mobile_controller::FileTransferSession> files_;
|
||||
std::string server_,logs_,login_,controller_login_,remote_,directory_,preview_directory_,preview_path_,version_;
|
||||
int port_=9099,signal_status_=-1;
|
||||
uint64_t signal_generation_=0;
|
||||
std::set<int> keys_;
|
||||
unsigned buttons_=0;
|
||||
uint32_t keyboard_sequence_=0;
|
||||
float x_=.5f,y_=.5f;
|
||||
Clock::time_point signal_started_,connection_started_,keyboard_sent_;
|
||||
AudioPlayer audio_;
|
||||
Renderer renderer_;
|
||||
std::thread worker_;
|
||||
};
|
||||
std::mutex mobile_mutex;
|
||||
std::unique_ptr<MobileSession> mobile;
|
||||
}
|
||||
void MobileCommand(const std::string& text) {
|
||||
if(text.size()>1024*1024)return;
|
||||
std::lock_guard lock(mobile_mutex);
|
||||
if(!mobile)mobile=std::make_unique<MobileSession>();
|
||||
mobile->Command(text);
|
||||
}
|
||||
std::string MobilePoll() { std::lock_guard lock(mobile_mutex); return mobile?mobile->Poll():"[]"; }
|
||||
void MobileShutdown() { std::lock_guard lock(mobile_mutex); mobile.reset(); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _MOBILE_SESSION_H_
|
||||
#define _MOBILE_SESSION_H_
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::harmony {
|
||||
// Commands execute on one worker. MiniRTC callbacks copy their borrowed data
|
||||
// before queuing it. Poll is the only bridge to the ArkTS thread.
|
||||
// Mobile uses an identity-only signaling peer and can never accept a guest.
|
||||
__attribute__((visibility("default"))) void MobileCommand(const std::string& json);
|
||||
__attribute__((visibility("default"))) std::string MobilePoll();
|
||||
__attribute__((visibility("default"))) void MobileShutdown();
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,38 @@
|
||||
set_project("crossdesk_harmony")
|
||||
set_xmakever("3.1.1")
|
||||
set_languages("c++17")
|
||||
add_rules("mode.release", "mode.debug")
|
||||
set_policy("package.precompiled", false)
|
||||
add_cxflags("-fPIC")
|
||||
includes("../../../deps/submodules/minirtc")
|
||||
|
||||
target("crossdesk_harmony_mobile")
|
||||
set_kind("shared")
|
||||
set_default(false)
|
||||
add_deps("minirtc")
|
||||
add_packages("nlohmann_json", "libyuv", "spdlog")
|
||||
add_files("mobile_session.cpp", "media.cpp", "audio.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/file_transfer.cpp", "../../../libs/wire/src/app_version.cpp")
|
||||
-- Export only the bridge API; bundled static dependencies are internal.
|
||||
add_cxflags("-fvisibility=hidden", "-fvisibility-inlines-hidden")
|
||||
add_shflags("-Wl,--exclude-libs,ALL", {force = true})
|
||||
set_targetdir("../.native/mobile-libs/arm64-v8a")
|
||||
set_filename("libcrossdesk_harmony_core.so")
|
||||
add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include")
|
||||
add_includedirs("../../desktop/src/platform/common/input")
|
||||
add_includedirs("../../../libs/controller/include")
|
||||
add_shflags("-Wl,--no-undefined", {force = true})
|
||||
-- CMake imports this file by absolute path. An ELF SONAME keeps that build
|
||||
-- machine path out of libcrossdesk.so's runtime DT_NEEDED entries.
|
||||
add_shflags("-Wl,-soname,libcrossdesk_harmony_core.so", {force = true})
|
||||
add_syslinks("EGL", "GLESv3", "native_window", "ohaudio", "native_media_core", "hilog_ndk.z")
|
||||
target_end()
|
||||
|
||||
-- Opt-in integration fixture: synthetic NV12/PCM only, without screen capture/input APIs.
|
||||
target("crossdesk_harmony_mobile_fixture")
|
||||
set_default(false)
|
||||
set_kind("binary")
|
||||
add_deps("minirtc")
|
||||
add_packages("nlohmann_json", "spdlog")
|
||||
add_files("../tests/mobile_synthetic_host.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/file_transfer.cpp")
|
||||
add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include", "../../../libs/controller/include")
|
||||
set_targetdir("../.native")
|
||||
Reference in New Issue
Block a user