From 02c3952be00bd706364f4cab61b84fa5f762c1a4 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 18 Oct 2026 14:16:38 +0800 Subject: [PATCH] [feat] activate existing window on duplicate launches, refs #119 --- apps/desktop/src/app/main.cpp | 24 +- .../src/gui/application/gui_application.cpp | 116 ++++++-- .../src/gui/application/gui_application.h | 1 + .../common/posix_instance_activation.cpp | 248 ++++++++++++++++++ .../src/platform/instance_activation.h | 59 +++++ .../platform/linux/gui/wayland_activation.cpp | 162 ++++++++++++ .../platform/linux/gui/wayland_activation.h | 21 ++ .../src/platform/macos/gui/tray/mac_tray.h | 1 + .../src/platform/macos/gui/tray/mac_tray.mm | 44 ++++ .../windows/gui/instance_activation.cpp | 161 ++++++++++++ .../src/platform/windows/single_instance.cpp | 4 +- apps/desktop/xmake/targets.lua | 6 +- 12 files changed, 819 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/platform/common/posix_instance_activation.cpp create mode 100644 apps/desktop/src/platform/instance_activation.h create mode 100644 apps/desktop/src/platform/linux/gui/wayland_activation.cpp create mode 100644 apps/desktop/src/platform/linux/gui/wayland_activation.h create mode 100644 apps/desktop/src/platform/windows/gui/instance_activation.cpp diff --git a/apps/desktop/src/app/main.cpp b/apps/desktop/src/app/main.cpp index 164c322b..498201d8 100644 --- a/apps/desktop/src/app/main.cpp +++ b/apps/desktop/src/app/main.cpp @@ -26,6 +26,7 @@ #include "config_center.h" #include "daemon.h" #include "path_manager.h" +#include "platform/instance_activation.h" #include "platform/single_instance.h" #include "rd_log.h" #include "render.h" @@ -307,6 +308,25 @@ static crossdesk::platform::InstanceResult AcquireInstance( return result; } +static int ExitForExistingInstance( + crossdesk::platform::InstanceResult result, bool interactive_launch) { + if (interactive_launch && + result == crossdesk::platform::InstanceResult::kAlreadyRunning) { + std::string error; + if (!crossdesk::platform::RequestInstanceActivation(error)) { + std::cerr << error << '\n'; +#ifdef _WIN32 + MessageBoxW(nullptr, + L"CrossDesk is already running, but its window could not be " + L"activated.\n\nIt may still be starting or be running in " + L"another Windows session. Please check the system tray.", + L"CrossDesk", MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND); +#endif + } + } + return result == crossdesk::platform::InstanceResult::kAlreadyRunning ? 0 : 1; +} + static int RunApplication( int argc, char* argv[], crossdesk::platform::SingleInstanceGuard& client_instance) { @@ -458,11 +478,11 @@ int main(int argc, char* argv[]) { if (!IsDaemonChild(argc, argv)) { const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher); if (result != InstanceResult::kAcquired) - return result == InstanceResult::kAlreadyRunning ? 0 : 1; + return ExitForExistingInstance(result, true); } const auto result = AcquireInstance(client_instance, InstanceRole::kClient); if (result != InstanceResult::kAcquired) - return result == InstanceResult::kAlreadyRunning ? 0 : 1; + return ExitForExistingInstance(result, !IsDaemonChild(argc, argv)); #ifdef __linux__ const int exit_code = crossdesk::RunWithLinuxDisplay( diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 2022e8f6..f0c158c3 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -40,6 +40,9 @@ #include "features/gallery/preview_bandwidth.h" #include "localization.h" #include "platform.h" +#ifndef __OHOS__ +#include "platform/instance_activation.h" +#endif #include "platform/video_renderer.h" #include "rendering/slint_video_presenter.h" #include "rendering/video_geometry.h" @@ -66,6 +69,7 @@ #include "platform/linux/gui/cuda_graphics.h" #include "platform/linux/gui/tray/linux_tray.h" +#include "platform/linux/gui/wayland_activation.h" #include "platform/linux/headless/headless_console.h" // Xlib defines Status as a macro, which collides with UpdateChecker::Status. #undef Status @@ -248,8 +252,9 @@ bool X11WindowMatches(Display* display, ::Window x11_window, kGeometryTolerance; } -void X11ActivateGalleryWindow(Display* display) { - if (!display) return; +bool X11ActivateWindow(Display* display, const slint::Window& target, + bool gallery_window) { + if (!display) return false; Atom type = None; int format = 0; unsigned long count = 0, remaining = 0; @@ -259,13 +264,15 @@ void X11ActivateGalleryWindow(Display* display) { 0, 4096, False, XA_WINDOW, &type, &format, &count, &remaining, &data) != Success || !data) { if (data) XFree(data); - return; + return false; } + bool activated = false; if (type == XA_WINDOW && format == 32) { const auto* windows = reinterpret_cast(data); for (unsigned long i = 0; i < count; ++i) { if (X11GetWindowProcessId(display, windows[i]) != static_cast(getpid())) continue; + if (!gallery_window && !X11WindowMatches(display, windows[i], target)) continue; unsigned char* name = nullptr; unsigned long length = 0; Atom name_type = None; @@ -273,8 +280,11 @@ void X11ActivateGalleryWindow(Display* display) { XGetWindowProperty(display, windows[i], XInternAtom(display, "_NET_WM_NAME", False), 0, 256, False, XInternAtom(display, "UTF8_STRING", False), &name_type, &name_format, &length, &remaining, &name); - const bool matches = name && name_format == 8 && - std::string_view(reinterpret_cast(name), length).starts_with("CrossDesk "); + const std::string_view title = name && name_format == 8 + ? std::string_view(reinterpret_cast(name), length) + : std::string_view{}; + const bool matches = gallery_window ? title.starts_with("CrossDesk ") + : title == "CrossDesk"; if (name) XFree(name); if (!matches) continue; XEvent event{}; @@ -284,12 +294,15 @@ void X11ActivateGalleryWindow(Display* display) { event.xclient.format = 32; event.xclient.data.l[0] = 1; event.xclient.data.l[1] = CurrentTime; - XSendEvent(display, root, False, SubstructureRedirectMask | SubstructureNotifyMask, &event); + activated = XSendEvent(display, root, False, + SubstructureRedirectMask | SubstructureNotifyMask, + &event) != 0; XFlush(display); break; } } XFree(data); + return activated; } #endif @@ -1019,6 +1032,9 @@ struct GuiApplication::SlintUi { int localized_language = -1; std::chrono::steady_clock::time_point last_clipboard_poll{}; slint::Timer video_timer; +#ifndef __OHOS__ + std::unique_ptr activation_receiver; +#endif #if _WIN32 std::unique_ptr tray; #elif defined(__APPLE__) @@ -1028,6 +1044,8 @@ struct GuiApplication::SlintUi { #elif (defined(__linux__) && !defined(__OHOS__)) std::unique_ptr tray; Display* x11_focus_display = nullptr; + int main_activation_attempts = 0; + std::string main_activation_token; std::chrono::steady_clock::time_point last_x11_activation_poll{}; #endif @@ -1044,6 +1062,14 @@ GuiApplication::GuiApplication() = default; GuiApplication::~GuiApplication() = default; int GuiApplication::Run() { +#ifndef __OHOS__ + // Publish the GUI endpoint before configuration/media initialization, so a + // second launch can queue activation even while this window is starting. + auto activation_receiver = + std::make_unique(); + std::string activation_error; + activation_receiver->Start(activation_error); +#endif path_manager_ = std::make_unique("CrossDesk"); if (!path_manager_) { return -1; @@ -1051,6 +1077,9 @@ int GuiApplication::Run() { exec_log_path_ = path_manager_->GetLogPath().string(); dll_log_path_ = exec_log_path_; InitializeLogger(); +#ifndef __OHOS__ + if (!activation_error.empty()) LOG_WARN("{}", activation_error); +#endif cache_path_ = path_manager_->GetCachePath().string(); announcements_.SetStorageDirectory(path_manager_->GetCachePath() / "announcement_reads"); @@ -1091,6 +1120,9 @@ int GuiApplication::Run() { InitializeModules(); update_checker_ = std::make_unique(CROSSDESK_VERSION); InitializeUi(); +#ifndef __OHOS__ + ui_->activation_receiver = std::move(activation_receiver); +#endif // Configure the initial geometry before the native window becomes visible. // Subsequent ticks only acknowledge that placement and its resolved DPI. RestoreWindowGeometry(true); @@ -1303,28 +1335,41 @@ void GuiApplication::InitializeUi() { SyncMainWindow(); } +void GuiApplication::ShowMainWindow(const std::string& activation_token) { +#if !defined(__linux__) || defined(__OHOS__) + (void)activation_token; +#endif + if (!ui_) { + return; + } + ui_->main->show(); + ui_->main->window().set_minimized(false); +#if _WIN32 + const HWND window = ui_->main->window().win32_hwnd(); + ConfigureWindowsWindowIcons(window); + ShowWindow(window, IsIconic(window) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(window); +#endif +#if defined(__APPLE__) + ConfigureMainWindowChrome(ui_->main->window().appkit_view()); + MacActivateWindow(ui_->main->window().appkit_view()); + // A window restored from the tray may only obtain its NSWindow after the + // callback returns, so let the regular UI tick configure it. + ui_->main_native_titlebar_attempts = 30; +#elif defined(__linux__) && !defined(__OHOS__) + // Mapping/unminimizing through Slint is asynchronous. Retry native lookup + // after the window manager has updated its client list and geometry. + ui_->main_activation_attempts = 30; + ui_->main_activation_token = activation_token; +#endif +} + void GuiApplication::InitializeSystemTray() { #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) if (!ui_) { return; } - auto show_window = [this] { - if (!ui_) { - return; - } - ui_->main->show(); -#if _WIN32 - ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); -#endif - ui_->main->window().set_minimized(false); -#if defined(__APPLE__) - ConfigureMainWindowChrome(ui_->main->window().appkit_view()); - MacActivateWindow(ui_->main->window().appkit_view()); - // A window restored from the tray may only obtain its NSWindow after the - // callback returns, so let the regular UI tick configure it. - ui_->main_native_titlebar_attempts = 30; -#endif - }; + auto show_window = [this] { ShowMainWindow(); }; auto hide_window = [this] { if (ui_) { ui_->main->hide(); @@ -2163,7 +2208,30 @@ void GuiApplication::Tick() { } } clipboard_.ApplyPendingRemoteText(); +#ifndef __OHOS__ + std::string activation_token; + bool activation_requested = + ui_->activation_receiver && ui_->activation_receiver->TakeRequest(&activation_token); +#ifdef __APPLE__ + if (ui_->tray && ui_->tray->TakeReopenRequest()) activation_requested = true; +#endif + if (activation_requested) ShowMainWindow(activation_token); +#endif #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) + if (ui_->main_activation_attempts > 0) { + --ui_->main_activation_attempts; + const auto& window = ui_->main->window(); + if (window.wayland_surface()) { + if (platform::ActivateWaylandWindow(window.wayland_display(), + window.wayland_surface(), ui_->main_activation_token)) + ui_->main_activation_attempts = 0; + } else { + if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr); + if (X11ActivateWindow(ui_->x11_focus_display, window, false)) + ui_->main_activation_attempts = 0; + } + if (ui_->main_activation_attempts == 0) ui_->main_activation_token.clear(); + } if (ui_->tray) { ui_->tray->ProcessEvents(); } @@ -4514,7 +4582,7 @@ void GuiApplication::ShowGalleryWindow() { SetForegroundWindow(window.win32_hwnd()); #elif defined(__linux__) && !defined(__OHOS__) if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr); - X11ActivateGalleryWindow(ui_->x11_focus_display); + X11ActivateWindow(ui_->x11_focus_display, window, true); #endif ui_->gallery_synced = {}; SyncGallery(); diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index 74e597dc..d83708a1 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -49,6 +49,7 @@ private: void InitializeModules(); void InitializeUi(); void InitializeSystemTray(); + void ShowMainWindow(const std::string& activation_token = {}); bool MinimizeMainWindowToTray(); void BindMainCallbacks(bool gallery_window = false); void BindStreamCallbacks(); diff --git a/apps/desktop/src/platform/common/posix_instance_activation.cpp b/apps/desktop/src/platform/common/posix_instance_activation.cpp new file mode 100644 index 00000000..e6eadd13 --- /dev/null +++ b/apps/desktop/src/platform/common/posix_instance_activation.cpp @@ -0,0 +1,248 @@ +#include "platform/instance_activation.h" +#include "platform/single_instance.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk::platform { +namespace { + +constexpr std::string_view kRequest = "CrossDesk.Activate.v1"; +constexpr std::size_t kMaxActivationToken = 4096; + +#if defined(__linux__) +std::string Environment(const char* name) { + const char* value = std::getenv(name); + return value ? value : ""; +} +#endif + +std::string ActivationDirectory() { + // Match the single-instance guard's user scope, independent of TMPDIR/HOME. + return "/tmp/crossdesk-activation-" + std::to_string(geteuid()); +} + +bool PrivateDirectory(bool create, std::string& error) { + const auto directory = ActivationDirectory(); + if (create && mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST) { + error = "Cannot create activation directory: " + std::string(strerror(errno)); + return false; + } + struct stat info{}; + if (lstat(directory.c_str(), &info) != 0) { + if (errno != ENOENT) + error = "Cannot inspect activation directory: " + std::string(strerror(errno)); + return false; + } + if (!S_ISDIR(info.st_mode) || info.st_uid != geteuid() || + (info.st_mode & 0777) != 0700) { + error = "Activation directory must be private and owned by this user"; + return false; + } + return true; +} + +std::string ActivationPath(const std::string& application, std::string& error) { + if (!ValidInstanceApplication(application)) { + error = "Invalid activation application name"; + return {}; + } + std::string session = "macos"; +#if defined(__linux__) + if (!Environment("CROSSDESK_HEADLESS_ACTIVE").empty()) { + error = "Window activation is unavailable in a headless session"; + return {}; + } + const auto wayland = Environment("WAYLAND_DISPLAY"); + auto display = Environment("DISPLAY"); + if (!wayland.empty()) { + session = "wayland:" + Environment("XDG_RUNTIME_DIR") + "/" + wayland; + } else if (!display.empty()) { + // :0 and :0.0 address the same server; a screen suffix is not a session. + const auto colon = display.rfind(':'); + const auto screen = display.find('.', colon == std::string::npos ? 0 : colon); + if (screen != std::string::npos) display.resize(screen); + if (display.rfind("unix:", 0) == 0) display.erase(0, 4); + session = "x11:" + display; + } else { + error = "No graphical session is available to activate the existing window"; + return {}; + } +#endif + // A stable bounded name fits both Darwin and Linux sockaddr_un limits. + std::uint64_t hash = 14695981039346656037ull; + for (unsigned char byte : application + '\0' + session) { + hash ^= byte; + hash *= 1099511628211ull; + } + return ActivationDirectory() + "/" + std::to_string(hash) + ".sock"; +} + +int CreateSocket(std::string& error) { + const int fd = socket(AF_UNIX, SOCK_DGRAM, 0); + if (fd < 0) { + error = "Cannot create activation socket: " + std::string(strerror(errno)); + return -1; + } + if (fcntl(fd, F_SETFD, FD_CLOEXEC) < 0 || + fcntl(fd, F_SETFL, O_NONBLOCK) < 0) { + error = "Cannot configure activation socket: " + std::string(strerror(errno)); + close(fd); + return -1; + } + return fd; +} + +sockaddr_un Address(const std::string& path) { + sockaddr_un address{}; + address.sun_family = AF_UNIX; + // ActivationPath always produces a name shorter than sun_path on both OSes. + std::memcpy(address.sun_path, path.c_str(), path.size() + 1); + return address; +} + +socklen_t AddressSize(const std::string& path) { + return static_cast(offsetof(sockaddr_un, sun_path) + path.size() + 1); +} + +} // namespace + +InstanceActivationReceiver::InstanceActivationReceiver(std::string application) + : application_(std::move(application)) {} + +InstanceActivationReceiver::~InstanceActivationReceiver() { + if (socket_ < 0) return; + struct stat info{}; + if (lstat(socket_path_.c_str(), &info) == 0 && + static_cast(info.st_dev) == socket_device_ && + static_cast(info.st_ino) == socket_inode_) { + unlink(socket_path_.c_str()); + } + close(socket_); +} + +bool InstanceActivationReceiver::Start(std::string& error) { + error.clear(); + if (socket_ >= 0) return true; + const auto path = ActivationPath(application_, error); + if (path.empty() || !PrivateDirectory(true, error)) return false; + // The caller holds the client lease. Any socket left at this path is from a + // crashed GUI, and may be removed without disrupting another live instance. + struct stat info{}; + if (lstat(path.c_str(), &info) == 0) { + if (!S_ISSOCK(info.st_mode) || info.st_uid != geteuid()) { + error = "Activation endpoint is not a socket owned by this user"; + return false; + } + if (unlink(path.c_str()) != 0) { + error = "Cannot remove stale activation socket: " + std::string(strerror(errno)); + return false; + } + } else if (errno != ENOENT) { + error = "Cannot inspect activation socket: " + std::string(strerror(errno)); + return false; + } + const int fd = CreateSocket(error); + if (fd < 0) return false; + const auto address = Address(path); + if (bind(fd, reinterpret_cast(&address), AddressSize(path)) != 0) { + error = "Cannot bind activation socket: " + std::string(strerror(errno)); + close(fd); + return false; + } + if (lstat(path.c_str(), &info) != 0) { + error = "Cannot inspect bound activation socket: " + std::string(strerror(errno)); + unlink(path.c_str()); + close(fd); + return false; + } + socket_ = fd; + socket_path_ = path; + socket_device_ = info.st_dev; + socket_inode_ = info.st_ino; + return true; +} + +bool InstanceActivationReceiver::TakeRequest(std::string* activation_token) { + if (activation_token) activation_token->clear(); + if (socket_ < 0) return false; + bool requested = false; + // Bound each UI tick's work even if many duplicate launches arrive together. + for (int i = 0; i < 32; ++i) { + char buffer[kRequest.size() + 1 + kMaxActivationToken + 1]; + const auto size = recv(socket_, buffer, sizeof(buffer), 0); + if (size < 0) { + if (errno == EINTR) continue; + break; + } + const std::string_view message(buffer, size); + if (message == kRequest) { + requested = true; + } else if (message.size() > kRequest.size() && + message.size() <= kRequest.size() + 1 + kMaxActivationToken && + message.substr(0, kRequest.size()) == kRequest && + message[kRequest.size()] == '\0') { + const auto token = message.substr(kRequest.size() + 1); + if (token.find('\0') == std::string_view::npos) { + requested = true; + if (activation_token) *activation_token = token; + } + } + } + return requested; +} + +bool RequestInstanceActivation(std::string& error, const std::string& application) { + error.clear(); + const auto path = ActivationPath(application, error); + if (path.empty()) return false; + const int fd = CreateSocket(error); + if (fd < 0) return false; + const auto address = Address(path); + std::string request(kRequest); +#if defined(__linux__) + const auto token = Environment("XDG_ACTIVATION_TOKEN"); + if (!token.empty() && token.size() <= kMaxActivationToken) { + request += '\0'; + request += token; + } +#endif + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + int last_error = ENOENT; + do { + if (PrivateDirectory(false, error)) { + if (sendto(fd, request.data(), request.size(), 0, + reinterpret_cast(&address), AddressSize(path)) == + static_cast(request.size())) { + close(fd); + return true; + } + last_error = errno; + if (last_error != ENOENT && last_error != ECONNREFUSED && + last_error != EAGAIN && last_error != EINTR) break; + } else if (!error.empty()) { + close(fd); + return false; + } + if (std::chrono::steady_clock::now() >= deadline) break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } while (true); + close(fd); + error = "Cannot notify the existing CrossDesk window in this graphical session: " + + std::string(strerror(last_error)); + return false; +} + +} // namespace crossdesk::platform diff --git a/apps/desktop/src/platform/instance_activation.h b/apps/desktop/src/platform/instance_activation.h new file mode 100644 index 00000000..f9ee00ac --- /dev/null +++ b/apps/desktop/src/platform/instance_activation.h @@ -0,0 +1,59 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-17 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _INSTANCE_ACTIVATION_H_ +#define _INSTANCE_ACTIVATION_H_ + +#ifdef _WIN32 +#include +#else +#include +#endif + +#include + +namespace crossdesk::platform { + +// Created only by the interactive GUI, on its event-loop thread. Requests can +// arrive during startup and remain pending until the UI is ready to show +// itself. +class InstanceActivationReceiver { + public: + explicit InstanceActivationReceiver(std::string application = "CrossDesk"); + ~InstanceActivationReceiver(); + InstanceActivationReceiver(const InstanceActivationReceiver&) = delete; + InstanceActivationReceiver& operator=(const InstanceActivationReceiver&) = + delete; + + // The caller must hold this application's client single-instance lease. + bool Start(std::string& error); + bool TakeRequest(std::string* activation_token = nullptr); + + private: + std::string application_; +#ifdef _WIN32 + static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, + LPARAM lparam); + + HWND window_ = nullptr; + UINT activation_message_ = 0; + bool requested_ = false; +#else + int socket_ = -1; + std::string socket_path_; + std::uint64_t socket_device_ = 0; + std::uint64_t socket_inode_ = 0; +#endif +}; + +// Targets the current user's GUI on the current desktop, including a daemon +// child. Waits briefly for startup/restart, without waiting on its UI thread. +bool RequestInstanceActivation(std::string& error, + const std::string& application = "CrossDesk"); + +} // namespace crossdesk::platform + +#endif diff --git a/apps/desktop/src/platform/linux/gui/wayland_activation.cpp b/apps/desktop/src/platform/linux/gui/wayland_activation.cpp new file mode 100644 index 00000000..c14a8672 --- /dev/null +++ b/apps/desktop/src/platform/linux/gui/wayland_activation.cpp @@ -0,0 +1,162 @@ +#include "wayland_activation.h" + +#include + +#include +#include + +namespace crossdesk::platform { +namespace { + +// The public libwayland-client wire ABI. Load it at runtime so X11-only builds +// do not acquire a new Wayland build or runtime dependency. +struct Interface; +struct Message { + const char* name; + const char* signature; + const Interface** types; +}; +struct Interface { + const char* name; + int version; + int method_count; + const Message* methods; + int event_count; + const Message* events; +}; + +struct WaylandApi { + void* library = dlopen("libwayland-client.so.0", RTLD_NOW | RTLD_LOCAL); + void* (*create_queue)(void*) = nullptr; + void (*destroy_queue)(void*) = nullptr; + void* (*create_wrapper)(void*) = nullptr; + void (*destroy_wrapper)(void*) = nullptr; + void (*set_queue)(void*, void*) = nullptr; + void (*destroy_proxy)(void*) = nullptr; + void* (*marshal)(void*, uint32_t, const Interface*, uint32_t, uint32_t, ...) = nullptr; + int (*add_listener)(void*, void (**)(void), void*) = nullptr; + int (*roundtrip)(void*, void*) = nullptr; + int (*flush)(void*) = nullptr; + const Interface* registry_interface = nullptr; + const Interface* surface_interface = nullptr; + bool available = false; + + template + bool Load(T& target, const char* name) { + target = reinterpret_cast(dlsym(library, name)); + return target != nullptr; + } + + WaylandApi() { + available = library && + Load(create_queue, "wl_display_create_queue") && + Load(destroy_queue, "wl_event_queue_destroy") && + Load(create_wrapper, "wl_proxy_create_wrapper") && + Load(destroy_wrapper, "wl_proxy_wrapper_destroy") && + Load(set_queue, "wl_proxy_set_queue") && + Load(destroy_proxy, "wl_proxy_destroy") && + Load(marshal, "wl_proxy_marshal_flags") && + Load(add_listener, "wl_proxy_add_listener") && + Load(roundtrip, "wl_display_roundtrip_queue") && + Load(flush, "wl_display_flush") && + Load(registry_interface, "wl_registry_interface") && + Load(surface_interface, "wl_surface_interface"); + } + ~WaylandApi() { if (library) dlclose(library); } +}; + +struct Activation { + WaylandApi& api; + void* queue = nullptr; + void* wrapper = nullptr; + void* registry = nullptr; + void* activation = nullptr; + void* token = nullptr; + std::string token_value; + + const Interface* surface_types[1]; + const Interface* activate_types[2]; + const Message token_methods[5]; + const Message token_events[1] = {{"done", "s", nullptr}}; + const Interface token_interface; + const Interface* new_token_types[1]; + const Message activation_methods[3]; + const Interface activation_interface; + + explicit Activation(WaylandApi& value) + : api(value), surface_types{api.surface_interface}, + activate_types{nullptr, api.surface_interface}, + token_methods{{"set_serial", "uo", nullptr}, {"set_app_id", "s", nullptr}, + {"set_surface", "o", surface_types}, {"commit", "", nullptr}, + {"destroy", "", nullptr}}, + token_interface{"xdg_activation_token_v1", 1, 5, token_methods, 1, token_events}, + new_token_types{&token_interface}, + activation_methods{{"destroy", "", nullptr}, + {"get_activation_token", "n", new_token_types}, + {"activate", "so", activate_types}}, + activation_interface{"xdg_activation_v1", 1, 3, activation_methods, 0, nullptr} {} + + ~Activation() { + // Destroy protocol objects as well as client-side proxies on every path. + constexpr uint32_t destroy = 1; // WL_MARSHAL_FLAG_DESTROY + if (token) api.marshal(token, 4, nullptr, 1, destroy); + if (activation) api.marshal(activation, 0, nullptr, 1, destroy); + if (registry) api.destroy_proxy(registry); + if (wrapper) api.destroy_wrapper(wrapper); + if (queue) api.destroy_queue(queue); + } + + static void Global(void* data, void* registry, uint32_t name, + const char* interface, uint32_t version) { + auto& self = *static_cast(data); + if (self.activation || version < 1 || std::strcmp(interface, "xdg_activation_v1")) return; + self.activation = self.api.marshal(registry, 0, &self.activation_interface, 1, 0, + name, self.activation_interface.name, uint32_t{1}, nullptr); + } + static void GlobalRemove(void*, void*, uint32_t) {} + static void Done(void* data, void*, const char* value) { + static_cast(data)->token_value = value; + } +}; + +} // namespace + +bool ActivateWaylandWindow(void* display, void* surface, + const std::string& activation_token) { + if (!display || !surface) return false; + static WaylandApi api; + if (!api.available) return false; + Activation request(api); + // Use a separate queue and a display wrapper: roundtrips must never dispatch + // Slint/winit's own events from inside its timer callback. + request.queue = api.create_queue(display); + request.wrapper = api.create_wrapper(display); + if (!request.queue || !request.wrapper) return false; + api.set_queue(request.wrapper, request.queue); + request.registry = api.marshal(request.wrapper, 1, api.registry_interface, 1, 0, nullptr); + if (!request.registry) return false; + void (*registry_listener[])(void) = { + reinterpret_cast(&Activation::Global), + reinterpret_cast(&Activation::GlobalRemove)}; + if (api.add_listener(request.registry, registry_listener, &request) != 0 || + api.roundtrip(display, request.queue) < 0 || !request.activation) return false; + + request.token_value = activation_token; + if (request.token_value.empty()) { + // Terminal/tray launches may carry no startup token. Ask the compositor + // for one; it remains free to refuse focus without a recent input serial. + request.token = api.marshal(request.activation, 1, &request.token_interface, 1, 0, nullptr); + if (!request.token) return false; + void (*token_listener[])(void) = {reinterpret_cast(&Activation::Done)}; + if (api.add_listener(request.token, token_listener, &request) != 0) return false; + api.marshal(request.token, 1, nullptr, 1, 0, "crossdesk"); + api.marshal(request.token, 2, nullptr, 1, 0, surface); + api.marshal(request.token, 3, nullptr, 1, 0); + if (api.roundtrip(display, request.queue) < 0 || request.token_value.empty()) return false; + } + api.marshal(request.activation, 2, nullptr, 1, 0, request.token_value.c_str(), surface); + api.flush(display); + return true; +} + +} // namespace crossdesk::platform diff --git a/apps/desktop/src/platform/linux/gui/wayland_activation.h b/apps/desktop/src/platform/linux/gui/wayland_activation.h new file mode 100644 index 00000000..fad3e576 --- /dev/null +++ b/apps/desktop/src/platform/linux/gui/wayland_activation.h @@ -0,0 +1,21 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-17 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _WAYLAND_ACTIVATION_H_ +#define _WAYLAND_ACTIVATION_H_ + +#include + +namespace crossdesk::platform { + +// Uses Slint's native handles and xdg-activation-v1 when the compositor offers +// it. A successful request still leaves the focus decision to the compositor. +bool ActivateWaylandWindow(void* display, void* surface, + const std::string& activation_token); + +} // namespace crossdesk::platform + +#endif diff --git a/apps/desktop/src/platform/macos/gui/tray/mac_tray.h b/apps/desktop/src/platform/macos/gui/tray/mac_tray.h index fee6f1d6..2840f4b6 100644 --- a/apps/desktop/src/platform/macos/gui/tray/mac_tray.h +++ b/apps/desktop/src/platform/macos/gui/tray/mac_tray.h @@ -33,6 +33,7 @@ class MacTray { void MinimizeToTray(); void RemoveTrayIcon(); + bool TakeReopenRequest(); private: std::unique_ptr impl_; diff --git a/apps/desktop/src/platform/macos/gui/tray/mac_tray.mm b/apps/desktop/src/platform/macos/gui/tray/mac_tray.mm index 2f430f86..aced47d7 100644 --- a/apps/desktop/src/platform/macos/gui/tray/mac_tray.mm +++ b/apps/desktop/src/platform/macos/gui/tray/mac_tray.mm @@ -5,6 +5,7 @@ #include #import +#import #include "localization.h" @@ -16,6 +17,8 @@ - (void)showMainWindow:(id)sender; - (void)openSettings:(id)sender; - (void)exitApplication:(id)sender; +- (void)reopenApplication:(NSAppleEventDescriptor *)event + withReply:(NSAppleEventDescriptor *)reply; @end namespace crossdesk { @@ -28,6 +31,7 @@ struct MacTrayImpl { language_index(language_index_value), target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) { EnsureStatusItem(); + InstallReopenHandler(); } explicit MacTrayImpl(std::function show_window_callback, @@ -43,9 +47,13 @@ struct MacTrayImpl { language_index(language_index_value), target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) { EnsureStatusItem(); + InstallReopenHandler(); } ~MacTrayImpl() { + [[NSAppleEventManager sharedAppleEventManager] + removeEventHandlerForEventClass:kCoreEventClass + andEventID:kAEReopenApplication]; RemoveTrayIcon(); target = nil; } @@ -156,7 +164,28 @@ struct MacTrayImpl { [NSApp activateIgnoringOtherApps:YES]; } + void RequestReopen() { reopen_requested = true; } + bool TakeReopenRequest() { + if (!reopen_handler_ready) { + // AppKit may replace handlers even after its did-finish-launching + // notification. The first GUI tick runs after finishLaunching returns. + InstallReopenHandler(); + reopen_handler_ready = true; + } + return std::exchange(reopen_requested, false); + } + private: + void InstallReopenHandler() { + // Finder/Dock normally sends a reopen Apple event instead of launching a + // second process. Queue it for the UI tick, just like the IPC notification. + [[NSAppleEventManager sharedAppleEventManager] + setEventHandler:target + andSelector:@selector(reopenApplication:withReply:) + forEventClass:kCoreEventClass + andEventID:kAEReopenApplication]; + } + void EnsureStatusItem() { if (status_item) { return; @@ -265,6 +294,8 @@ struct MacTrayImpl { int language_index = 0; NSStatusItem *status_item = nil; CrossDeskMacTrayTarget *target = nil; + bool reopen_requested = false; + bool reopen_handler_ready = false; }; MacTray::MacTray(::SDL_Window *app_window, const std::string &tooltip, @@ -285,8 +316,12 @@ MacTray::MacTray(std::function show_window, MacTray::~MacTray() = default; void MacActivateWindow(void *appkit_view) { + // IPC/Finder activation bypasses the tray's ShowWindow(), including when + // MinimizeToTray() previously removed the app from the Dock. + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; NSView *view = (__bridge NSView *)appkit_view; NSWindow *window = [view window]; + [window deminiaturize:nil]; [NSApp activateIgnoringOtherApps:YES]; [window makeKeyAndOrderFront:nil]; } @@ -295,6 +330,8 @@ void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); } void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); } +bool MacTray::TakeReopenRequest() { return impl_->TakeReopenRequest(); } + } // namespace crossdesk @implementation CrossDeskMacTrayTarget { @@ -317,6 +354,13 @@ void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); } owner_->ShowMenu(); } +- (void)reopenApplication:(NSAppleEventDescriptor *)event + withReply:(NSAppleEventDescriptor *)reply { + (void)event; + (void)reply; + if (owner_) owner_->RequestReopen(); +} + - (void)exitApplication:(id)sender { (void)sender; if (owner_) { diff --git a/apps/desktop/src/platform/windows/gui/instance_activation.cpp b/apps/desktop/src/platform/windows/gui/instance_activation.cpp new file mode 100644 index 00000000..c7c4e7b0 --- /dev/null +++ b/apps/desktop/src/platform/windows/gui/instance_activation.cpp @@ -0,0 +1,161 @@ +#include "platform/instance_activation.h" +#include "platform/single_instance.h" + +#include + +#include +#include + +namespace crossdesk::platform { +namespace { + +constexpr wchar_t kWindowClass[] = L"CrossDesk.InstanceActivationWindow"; +constexpr wchar_t kActivationMessage[] = L"CrossDesk.ActivateMainWindow.v1"; +constexpr ULONGLONG kStartupWaitMs = 5000; + +std::wstring ActivationWindowName(const std::string& application, + std::string& error) { + if (!ValidInstanceApplication(application)) { + error = "Invalid activation application name"; + return {}; + } + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + error = + "Cannot read activation user token: " + std::to_string(GetLastError()); + return {}; + } + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector buffer(size); + if (!GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) { + const DWORD code = GetLastError(); + CloseHandle(token); + error = "Cannot read activation user SID: " + std::to_string(code); + return {}; + } + CloseHandle(token); + wchar_t* sid = nullptr; + if (!ConvertSidToStringSidW( + reinterpret_cast(buffer.data())->User.Sid, &sid)) { + error = + "Cannot format activation user SID: " + std::to_string(GetLastError()); + return {}; + } + const std::wstring name = + std::wstring(application.begin(), application.end()) + L".Activation." + sid; + LocalFree(sid); + return name; +} + +} // namespace + +InstanceActivationReceiver::InstanceActivationReceiver(std::string application) + : application_(std::move(application)) {} + +InstanceActivationReceiver::~InstanceActivationReceiver() { + if (window_) DestroyWindow(window_); +} + +bool InstanceActivationReceiver::Start(std::string& error) { + error.clear(); + if (window_) return true; + const auto name = ActivationWindowName(application_, error); + if (name.empty()) return false; + activation_message_ = RegisterWindowMessageW(kActivationMessage); + if (!activation_message_) { + error = + "Cannot register activation message: " + std::to_string(GetLastError()); + return false; + } + WNDCLASSW window_class{}; + window_class.lpfnWndProc = WindowProc; + window_class.hInstance = GetModuleHandleW(nullptr); + window_class.lpszClassName = kWindowClass; + if (!RegisterClassW(&window_class) && + GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + error = + "Cannot register activation window: " + std::to_string(GetLastError()); + return false; + } + window_ = + CreateWindowExW(0, kWindowClass, name.c_str(), 0, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, window_class.hInstance, this); + if (!window_) { + error = + "Cannot create activation window: " + std::to_string(GetLastError()); + return false; + } + // An unelevated portable launch may need to activate an elevated installed + // client. Allow only this payload-free notification through UIPI. + if (!ChangeWindowMessageFilterEx(window_, activation_message_, MSGFLT_ALLOW, + nullptr)) { + error = + "Cannot allow activation message: " + std::to_string(GetLastError()); + DestroyWindow(window_); + window_ = nullptr; + return false; + } + return true; +} + +bool InstanceActivationReceiver::TakeRequest(std::string* activation_token) { + if (activation_token) activation_token->clear(); + return std::exchange(requested_, false); +} + +LRESULT CALLBACK InstanceActivationReceiver::WindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam) { + if (message == WM_NCCREATE) { + const auto* create = reinterpret_cast(lparam); + SetWindowLongPtrW(window, GWLP_USERDATA, + reinterpret_cast(create->lpCreateParams)); + } + auto* receiver = reinterpret_cast( + GetWindowLongPtrW(window, GWLP_USERDATA)); + if (receiver && message == receiver->activation_message_) { + // Let the regular UI tick restore the window, rather than entering Slint + // from a native message callback (which may run inside a nested event + // loop). + receiver->requested_ = true; + return 0; + } + return DefWindowProcW(window, message, wparam, lparam); +} + +bool RequestInstanceActivation(std::string& error, const std::string& application) { + error.clear(); + const auto name = ActivationWindowName(application, error); + if (name.empty()) return false; + const UINT message = RegisterWindowMessageW(kActivationMessage); + if (!message) { + error = + "Cannot register activation message: " + std::to_string(GetLastError()); + return false; + } + const ULONGLONG deadline = GetTickCount64() + kStartupWaitMs; + DWORD last_error = ERROR_NOT_FOUND; + do { + if (const HWND window = + FindWindowExW(HWND_MESSAGE, nullptr, kWindowClass, name.c_str())) { + DWORD process_id = 0; + if (GetWindowThreadProcessId(window, &process_id) && process_id != 0) { + // Transfer the new launch's foreground permission to the GUI process, + // not its supervisor, before posting the request. Windows may still + // deny foreground focus; the receiver will restore visibility anyway. + AllowSetForegroundWindow(process_id); + if (PostMessageW(window, message, 0, 0)) return true; + last_error = GetLastError(); + } + } + if (GetTickCount64() >= deadline) break; + Sleep(50); + } while (true); + error = "Cannot notify the existing CrossDesk window on this desktop: " + + std::to_string(last_error); + return false; +} + +} // namespace crossdesk::platform diff --git a/apps/desktop/src/platform/windows/single_instance.cpp b/apps/desktop/src/platform/windows/single_instance.cpp index 4399d938..32ab0767 100644 --- a/apps/desktop/src/platform/windows/single_instance.cpp +++ b/apps/desktop/src/platform/windows/single_instance.cpp @@ -48,7 +48,9 @@ InstanceResult SingleInstanceGuard::TryAcquire(InstanceRole role, std::wstring(role_name.begin(), role_name.end()); LocalFree(sid); // Object lifetime is the lease. No thread-owned mutex to transfer to a daemon. - HANDLE mutex = CreateMutexW(nullptr, FALSE, name.c_str()); + // CreateMutexW requests full access to an existing mutex, which can prevent + // an unelevated launch from recognizing the same user's elevated instance. + HANDLE mutex = CreateMutexExW(nullptr, name.c_str(), 0, SYNCHRONIZE); const DWORD code = GetLastError(); if (!mutex) { error = "Cannot create single-instance mutex: " + std::to_string(code); diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index a0870619..502d3c84 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -154,9 +154,11 @@ function setup_targets() add_includedirs("apps/desktop/src", {public = true}) if is_os("windows") then add_files("apps/desktop/src/platform/windows/single_instance.cpp") - add_syslinks("Advapi32") + add_files("apps/desktop/src/platform/windows/gui/instance_activation.cpp") + add_syslinks("Advapi32", "User32") else add_files("apps/desktop/src/platform/common/posix_single_instance.cpp") + add_files("apps/desktop/src/platform/common/posix_instance_activation.cpp") end target("repository_structure_test") @@ -528,6 +530,7 @@ function setup_targets() add_defines("CROSSDESK_PORTABLE=1", {public = true}) end add_deps("rd_log", "desktop_common", "assets", "config_center", "minirtc", + "single_instance", "path_manager", "screen_capturer", "speaker_capturer", "device_controller", "thumbnail", "version_checker", "tools", "crossdesk_wire", "privacy", "crossdesk_ui") @@ -576,6 +579,7 @@ function setup_targets() add_links("GL") add_deps("headless_console") add_files("apps/desktop/src/platform/linux/gui/tray/linux_tray.cpp", + "apps/desktop/src/platform/linux/gui/wayland_activation.cpp", "apps/desktop/src/platform/linux/gui/cuda_graphics.cpp", "apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp", "apps/desktop/src/platform/common/gui/cuda_driver.cpp",