diff --git a/src/app/daemon.cpp b/src/app/daemon.cpp index 6653a06..389b490 100644 --- a/src/app/daemon.cpp +++ b/src/app/daemon.cpp @@ -128,7 +128,11 @@ bool Daemon::start(MainLoopFunc loop) { if (pid > 0) _exit(0); umask(0); - chdir("/"); + if (chdir("/") != 0) { + std::cerr << "Failed to change daemon working directory to /: " + << std::strerror(errno) << std::endl; + return false; + } // redirect file descriptors: keep stdout/stderr if from terminal, else // redirect to /dev/null diff --git a/src/gui/render.cpp b/src/gui/render.cpp index 2711553..d9e353c 100644 --- a/src/gui/render.cpp +++ b/src/gui/render.cpp @@ -1374,6 +1374,10 @@ int Render::CreateMainWindow() { #elif defined(__APPLE__) tray_ = std::make_unique(main_window_, "CrossDesk", localization_language_index_); +#elif defined(__linux__) && !defined(__APPLE__) + tray_ = std::make_unique(main_window_, "CrossDesk", + localization_language_index_, + APP_EXIT_EVENT); #endif ImGui_ImplSDL3_InitForSDLRenderer(main_window_, main_renderer_); @@ -1673,20 +1677,47 @@ int Render::SetupFontAndStyle(ImFont** system_chinese_font_out) { } int Render::DestroyMainWindowContext() { + if (!main_ctx_) { + return 0; + } + ImGui::SetCurrentContext(main_ctx_); ImGui_ImplSDLRenderer3_Shutdown(); ImGui_ImplSDL3_Shutdown(); ImGui::DestroyContext(main_ctx_); + main_ctx_ = nullptr; return 0; } int Render::DestroyStreamWindowContext() { + if (!stream_ctx_) { + stream_window_inited_ = false; + return 0; + } + stream_window_inited_ = false; ImGui::SetCurrentContext(stream_ctx_); ImGui_ImplSDLRenderer3_Shutdown(); ImGui_ImplSDL3_Shutdown(); ImGui::DestroyContext(stream_ctx_); + stream_ctx_ = nullptr; + + return 0; +} + +int Render::DestroyServerWindowContext() { + if (!server_ctx_) { + server_window_inited_ = false; + return 0; + } + + server_window_inited_ = false; + ImGui::SetCurrentContext(server_ctx_); + ImGui_ImplSDLRenderer3_Shutdown(); + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(server_ctx_); + server_ctx_ = nullptr; return 0; } @@ -1955,9 +1986,12 @@ void Render::InitializeSDL() { screen_height_ = dm->h; } - STREAM_REFRESH_EVENT = SDL_RegisterEvents(1); - if (STREAM_REFRESH_EVENT == (uint32_t)-1) { - LOG_ERROR("Failed to register custom SDL event"); + const uint32_t custom_event_base = SDL_RegisterEvents(2); + if (custom_event_base == static_cast(-1)) { + LOG_ERROR("Failed to register custom SDL events"); + } else { + STREAM_REFRESH_EVENT = custom_event_base; + APP_EXIT_EVENT = custom_event_base + 1; } LOG_INFO("Screen resolution: [{}x{}]", screen_width_, screen_height_); @@ -2031,6 +2065,10 @@ void Render::MainLoop() { TranslateMessage(&msg); DispatchMessage(&msg); } +#elif defined(__linux__) && !defined(__APPLE__) + if (tray_) { + tray_->ProcessEvents(); + } #endif UpdateLabels(); @@ -2041,7 +2079,11 @@ void Render::MainLoop() { HandleServerWindow(); HandleWindowsServiceIntegration(); - DrawMainWindow(); + const bool main_window_visible = + main_window_ && !(SDL_GetWindowFlags(main_window_) & SDL_WINDOW_HIDDEN); + if (main_window_visible) { + DrawMainWindow(); + } if (stream_window_inited_) { DrawStreamWindow(); } @@ -2066,6 +2108,12 @@ bool Render::MinimizeMainWindowToTray() { tray_->MinimizeToTray(); return true; +#elif defined(__linux__) && !defined(__APPLE__) + if (!tray_) { + return false; + } + + return tray_->MinimizeToTray(); #else return false; #endif @@ -2407,6 +2455,7 @@ void Render::HandleServerWindow() { if (need_to_destroy_server_window_) { DestroyServerWindow(); + DestroyServerWindowContext(); need_to_destroy_server_window_ = false; } } @@ -2447,6 +2496,27 @@ void Render::Cleanup() { WaitForThumbnailSaveTasks(); AudioDeviceDestroy(); +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) + tray_.reset(); +#endif + + if (stream_window_created_) { + if (stream_window_) { + SDL_SetWindowMouseGrab(stream_window_, false); + } + DestroyStreamWindow(); + } + if (stream_ctx_) { + DestroyStreamWindowContext(); + } + + if (server_window_created_) { + DestroyServerWindow(); + } + if (server_ctx_) { + DestroyServerWindowContext(); + } + DestroyMainWindowContext(); DestroyMainWindow(); SDL_Quit(); @@ -2860,6 +2930,20 @@ void Render::ProcessSdlEvent(const SDL_Event& event) { } } + if (APP_EXIT_EVENT != 0 && event.type == APP_EXIT_EVENT) { + LOG_INFO("Quit program from system tray"); + if (stream_window_) { + SDL_SetWindowMouseGrab(stream_window_, false); + } +#if defined(__linux__) && !defined(__APPLE__) + if (tray_) { + tray_->RemoveTrayIcon(); + } +#endif + exit_ = true; + return; + } + switch (event.type) { case SDL_EVENT_QUIT: if (stream_window_inited_) { diff --git a/src/gui/render.h b/src/gui/render.h index 1eb8083..46aaa2c 100644 --- a/src/gui/render.h +++ b/src/gui/render.h @@ -41,6 +41,8 @@ #include "win_tray.h" #elif defined(__APPLE__) #include "mac_tray.h" +#elif defined(__linux__) +#include "linux_tray.h" #endif namespace crossdesk { @@ -515,6 +517,8 @@ class Render { std::unique_ptr tray_; #elif defined(__APPLE__) std::unique_ptr tray_; +#elif defined(__linux__) + std::unique_ptr tray_; #endif // main window properties @@ -596,6 +600,7 @@ class Render { SDL_Event last_mouse_event{}; SDL_AudioStream* output_stream_ = nullptr; uint32_t STREAM_REFRESH_EVENT = 0; + uint32_t APP_EXIT_EVENT = 0; #if _WIN32 std::atomic pending_windows_service_sas_{false}; bool local_service_status_received_ = false; diff --git a/src/gui/toolbars/title_bar.cpp b/src/gui/toolbars/title_bar.cpp index 9205793..c986345 100644 --- a/src/gui/toolbars/title_bar.cpp +++ b/src/gui/toolbars/title_bar.cpp @@ -300,13 +300,12 @@ int Render::TitleBar(bool main_window) { } if (close_button_clicked) { - if (main_window && MinimizeMainWindowToTray()) { - return 0; + const bool minimized_to_tray = main_window && MinimizeMainWindowToTray(); + if (!minimized_to_tray) { + SDL_Event event; + event.type = SDL_EVENT_QUIT; + SDL_PushEvent(&event); } - - SDL_Event event; - event.type = SDL_EVENT_QUIT; - SDL_PushEvent(&event); } draw_list->AddLine(ImVec2(xmark_pos_x - xmark_size / 2 - 0.25f, diff --git a/src/gui/tray/linux_tray.cpp b/src/gui/tray/linux_tray.cpp new file mode 100644 index 0000000..e734462 --- /dev/null +++ b/src/gui/tray/linux_tray.cpp @@ -0,0 +1,1018 @@ +#include "linux_tray.h" + +#if defined(__linux__) && !defined(__APPLE__) + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "localization.h" +#include "rd_log.h" +#include "stb_image.h" + +namespace crossdesk { + +namespace { + +constexpr int kTrayIconSize = 24; +constexpr int kMenuHeight = 28; +constexpr int kMenuHorizontalPadding = 12; +constexpr int kDockTimeoutMs = 800; +constexpr int kMaxTrayEventsPerTick = 32; +constexpr int kMaxTrayEventsDuringEmbed = 16; +constexpr long kSystemTrayRequestDock = 0; +constexpr long kXEmbedMapped = 1; +constexpr long kXEmbedEmbeddedNotify = 0; + +bool IsAsciiPrintable(const std::string& text) { + for (unsigned char ch : text) { + if (ch < 0x20 || ch > 0x7e) { + return false; + } + } + return true; +} + +std::string GetMenuLabel(int language_index) { + const int normalized_index = localization::detail::ClampLanguageIndex( + language_index); + const std::string& label = localization::exit_program[normalized_index]; + return label.empty() ? "Exit" : label; +} + +unsigned long AllocateColor(Display* display, int screen, const char* name, + unsigned long fallback) { + if (!display || !name) { + return fallback; + } + + XColor parsed{}; + XColor exact{}; + Colormap colormap = DefaultColormap(display, screen); + if (XAllocNamedColor(display, colormap, name, &parsed, &exact)) { + return parsed.pixel; + } + + return fallback; +} + +std::vector BuildIconCandidatePaths() { + std::vector paths = { + "icons/linux/crossdesk_32x32.png", + "icons/linux/crossdesk_24x24.png", + "icons/linux/crossdesk_48x48.png", + "icons/linux/crossdesk_64x64.png", + "icons/linux/crossdesk_96x96.png", + "icons/linux/crossdesk_128x128.png", + "icons/linux/crossdesk_256x256.png", + "icons/linux/crossdesk_512x512.png", + "/usr/share/icons/hicolor/32x32/apps/crossdesk.png", + "/usr/share/icons/hicolor/24x24/apps/crossdesk.png", + "/usr/share/icons/hicolor/48x48/apps/crossdesk.png", + "/usr/share/icons/hicolor/64x64/apps/crossdesk.png", + "/usr/share/icons/hicolor/96x96/apps/crossdesk.png", + "/usr/share/icons/hicolor/128x128/apps/crossdesk.png", + "/usr/share/icons/hicolor/512x512/apps/crossdesk.png", + "/usr/share/icons/hicolor/256x256/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/32x32/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/24x24/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/48x48/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/64x64/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/96x96/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/128x128/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/512x512/apps/crossdesk.png", + "/usr/local/share/icons/hicolor/256x256/apps/crossdesk.png"}; + + const char* base_path = SDL_GetBasePath(); + if (base_path && base_path[0] != '\0') { + const std::filesystem::path base_dir(base_path); + for (const char* size : + {"32", "24", "48", "64", "96", "128", "256", "512", "16"}) { + paths.push_back(base_dir / + ("icons/linux/crossdesk_" + std::string(size) + "x" + + size + ".png")); + paths.push_back(base_dir / + ("../share/icons/hicolor/" + std::string(size) + "x" + + size + "/apps/crossdesk.png")); + } + } + + return paths; +} + +int CountMaskBits(unsigned long mask) { + int count = 0; + while (mask) { + count += static_cast(mask & 1UL); + mask >>= 1; + } + return count; +} + +int CountMaskShift(unsigned long mask) { + int shift = 0; + while (mask && (mask & 1UL) == 0) { + ++shift; + mask >>= 1; + } + return shift; +} + +unsigned long ScaleColorToMask(unsigned char value, unsigned long mask) { + const int bits = CountMaskBits(mask); + if (bits <= 0) { + return 0; + } + + const unsigned long max_value = (1UL << bits) - 1; + const unsigned long scaled = + (static_cast(value) * max_value + 127UL) / 255UL; + return (scaled << CountMaskShift(mask)) & mask; +} + +unsigned char SampleBilinearChannel(const std::vector& rgba, + int width, int height, double src_x, + double src_y, int channel) { + src_x = std::clamp(src_x, 0.0, static_cast(width - 1)); + src_y = std::clamp(src_y, 0.0, static_cast(height - 1)); + + const int x0 = static_cast(std::floor(src_x)); + const int y0 = static_cast(std::floor(src_y)); + const int x1 = std::min(x0 + 1, width - 1); + const int y1 = std::min(y0 + 1, height - 1); + const double fx = src_x - x0; + const double fy = src_y - y0; + + auto channel_at = [&](int x, int y) -> double { + const size_t offset = + (static_cast(y) * static_cast(width) + + static_cast(x)) * + 4 + + static_cast(channel); + return rgba[offset]; + }; + + const double top = + channel_at(x0, y0) * (1.0 - fx) + channel_at(x1, y0) * fx; + const double bottom = + channel_at(x0, y1) * (1.0 - fx) + channel_at(x1, y1) * fx; + const double value = top * (1.0 - fy) + bottom * fy; + return static_cast(std::clamp(value, 0.0, 255.0) + 0.5); +} + +} // namespace + +struct LinuxTrayImpl { + explicit LinuxTrayImpl(::SDL_Window* window, std::string tray_tooltip, + int language_index_value, + uint32_t tray_exit_event_type) + : app_window(window), + tooltip(std::move(tray_tooltip)), + language_index(language_index_value), + exit_event_type(tray_exit_event_type), + menu_label(GetMenuLabel(language_index_value)), + menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {} + + ~LinuxTrayImpl() { RemoveTrayIcon(); } + + bool MinimizeToTray() { + if (!EnsureTrayIcon()) { + return false; + } + + if (app_window) { + SDL_HideWindow(app_window); + } + return true; + } + + void RemoveTrayIcon() { + HideMenu(); + + if (display && icon_window) { + XUnmapWindow(display, icon_window); + XDestroyWindow(display, icon_window); + icon_window = 0; + } + + if (icon_colormap_owned && display && icon_colormap) { + XFreeColormap(display, icon_colormap); + icon_colormap = 0; + icon_colormap_owned = false; + } + + if (font_set) { + XFreeFontSet(display, font_set); + font_set = nullptr; + } + + if (fallback_font) { + XFreeFont(display, fallback_font); + fallback_font = nullptr; + } + + if (menu_font) { + XftFontClose(display, menu_font); + menu_font = nullptr; + } + + if (menu_text_color_allocated) { + XftColorFree(display, DefaultVisual(display, screen), + DefaultColormap(display, screen), &menu_text_color); + menu_text_color_allocated = false; + } + + if (display) { + XCloseDisplay(display); + display = nullptr; + } + + docked = false; + embedded = false; + icon_needs_redraw = false; + tray_manager_window = 0; + } + + void ProcessEvents() { ProcessPendingEvents(kMaxTrayEventsPerTick); } + + private: + int ProcessPendingEvents(int max_events) { + if (!display) { + return 0; + } + + const int event_limit = std::max(1, max_events); + int processed = 0; + while (processed < event_limit && XPending(display) > 0) { + XEvent event{}; + XNextEvent(display, &event); + HandleEvent(event); + ++processed; + } + + if (icon_needs_redraw && display && icon_window) { + icon_needs_redraw = false; + DrawIcon(); + } + + return processed; + } + + void MarkIconDirty() { icon_needs_redraw = true; } + + bool EnsureDisplay() { + if (display) { + return true; + } + + display = XOpenDisplay(nullptr); + if (!display) { + LOG_WARN("Linux tray unavailable: failed to open X11 display"); + return false; + } + + std::setlocale(LC_CTYPE, ""); + + screen = DefaultScreen(display); + root_window = RootWindow(display, screen); + black_pixel = BlackPixel(display, screen); + white_pixel = WhitePixel(display, screen); + icon_visual = DefaultVisual(display, screen); + icon_depth = DefaultDepth(display, screen); + icon_colormap = DefaultColormap(display, screen); + SelectArgbVisual(); + brand_pixel = AllocateColor(display, screen, "#2563eb", black_pixel); + hover_pixel = AllocateColor(display, screen, "#e5e7eb", white_pixel); + int shape_event_base = 0; + int shape_error_base = 0; + shape_available = + XShapeQueryExtension(display, &shape_event_base, &shape_error_base); + + char selection_name[64] = {}; + std::snprintf(selection_name, sizeof(selection_name), + "_NET_SYSTEM_TRAY_S%d", screen); + selection_atom = XInternAtom(display, selection_name, False); + tray_opcode_atom = XInternAtom(display, "_NET_SYSTEM_TRAY_OPCODE", False); + xembed_atom = XInternAtom(display, "_XEMBED", False); + xembed_info_atom = XInternAtom(display, "_XEMBED_INFO", False); + utf8_string_atom = XInternAtom(display, "UTF8_STRING", False); + net_wm_name_atom = XInternAtom(display, "_NET_WM_NAME", False); + + char** missing_charset_list = nullptr; + int missing_charset_count = 0; + char* default_string = nullptr; + font_set = XCreateFontSet(display, + "-*-*-medium-r-normal--14-*-*-*-*-*-*-*", + &missing_charset_list, &missing_charset_count, + &default_string); + if (missing_charset_list) { + XFreeStringList(missing_charset_list); + } + fallback_font = XLoadQueryFont(display, "fixed"); + menu_font = XftFontOpenName(display, screen, "Sans-10"); + menu_text_color_allocated = + XftColorAllocName(display, DefaultVisual(display, screen), + DefaultColormap(display, screen), "#111827", + &menu_text_color); + return true; + } + + void SelectArgbVisual() { + XVisualInfo visual_template{}; + visual_template.screen = screen; + int visual_count = 0; + XVisualInfo* visual_info = + XGetVisualInfo(display, VisualScreenMask, &visual_template, + &visual_count); + if (!visual_info) { + return; + } + + for (int i = 0; i < visual_count; ++i) { + XRenderPictFormat* format = + XRenderFindVisualFormat(display, visual_info[i].visual); + if (!format || format->type != PictTypeDirect || + format->direct.alphaMask == 0) { + continue; + } + + Colormap colormap = + XCreateColormap(display, root_window, visual_info[i].visual, + AllocNone); + if (!colormap) { + continue; + } + + icon_visual = visual_info[i].visual; + icon_depth = visual_info[i].depth; + icon_format = format; + icon_colormap = colormap; + icon_colormap_owned = true; + break; + } + + XFree(visual_info); + } + + bool EnsureTrayIcon() { + if (docked && icon_window) { + return true; + } + + if (!EnsureDisplay() || !EnsureIconWindow()) { + return false; + } + + tray_manager_window = XGetSelectionOwner(display, selection_atom); + if (!tray_manager_window) { + LOG_WARN("Linux tray unavailable: no X11 system tray manager"); + return false; + } + + XEvent event{}; + event.xclient.type = ClientMessage; + event.xclient.window = tray_manager_window; + event.xclient.message_type = tray_opcode_atom; + event.xclient.format = 32; + event.xclient.data.l[0] = CurrentTime; + event.xclient.data.l[1] = kSystemTrayRequestDock; + event.xclient.data.l[2] = icon_window; + event.xclient.data.l[3] = 0; + event.xclient.data.l[4] = 0; + + embedded = false; + XSendEvent(display, tray_manager_window, False, NoEventMask, &event); + XFlush(display); + + if (!WaitForEmbed()) { + LOG_WARN("Linux tray unavailable: tray manager did not embed icon"); + XUnmapWindow(display, icon_window); + XDestroyWindow(display, icon_window); + icon_window = 0; + tray_manager_window = 0; + return false; + } + + docked = true; + LOG_INFO("Linux tray icon embedded"); + XMapRaised(display, icon_window); + icon_needs_redraw = false; + DrawIcon(); + return true; + } + + bool EnsureIconWindow() { + if (icon_window) { + return true; + } + + XSetWindowAttributes attrs{}; + attrs.background_pixel = 0; + attrs.border_pixel = 0; + attrs.colormap = icon_colormap; + attrs.override_redirect = True; + attrs.event_mask = + ExposureMask | ButtonPressMask | ButtonReleaseMask | StructureNotifyMask; + icon_window = XCreateWindow( + display, root_window, 0, 0, kTrayIconSize, kTrayIconSize, 0, + icon_depth, InputOutput, icon_visual, + CWBackPixel | CWBorderPixel | CWColormap | CWOverrideRedirect | + CWEventMask, + &attrs); + if (!icon_window) { + LOG_WARN("Linux tray unavailable: failed to create icon window"); + return false; + } + + XSizeHints size_hints{}; + size_hints.flags = PMinSize | PMaxSize | PBaseSize; + size_hints.min_width = kTrayIconSize; + size_hints.min_height = kTrayIconSize; + size_hints.max_width = kTrayIconSize; + size_hints.max_height = kTrayIconSize; + size_hints.base_width = kTrayIconSize; + size_hints.base_height = kTrayIconSize; + XSetWMNormalHints(display, icon_window, &size_hints); + + XClassHint class_hint{}; + class_hint.res_name = const_cast("crossdesk"); + class_hint.res_class = const_cast("CrossDesk"); + XSetClassHint(display, icon_window, &class_hint); + + XStoreName(display, icon_window, tooltip.c_str()); + if (utf8_string_atom && net_wm_name_atom) { + XChangeProperty(display, icon_window, net_wm_name_atom, utf8_string_atom, + 8, PropModeReplace, + reinterpret_cast(tooltip.c_str()), + static_cast(tooltip.size())); + } + + long xembed_info[2] = {0, kXEmbedMapped}; + XChangeProperty(display, icon_window, xembed_info_atom, xembed_info_atom, 32, + PropModeReplace, + reinterpret_cast(xembed_info), 2); + + XWMHints wm_hints{}; + wm_hints.flags = InputHint; + wm_hints.input = True; + XSetWMHints(display, icon_window, &wm_hints); + return true; + } + + void HandleEvent(const XEvent& event) { + switch (event.type) { + case Expose: + if (event.xexpose.window == icon_window) { + MarkIconDirty(); + } else if (event.xexpose.window == menu_window) { + DrawMenu(); + } + break; + + case ConfigureNotify: + if (event.xconfigure.window == icon_window) { + MarkIconDirty(); + } + break; + + case DestroyNotify: + if (event.xdestroywindow.window == icon_window) { + icon_window = 0; + docked = false; + embedded = false; + icon_needs_redraw = false; + } else if (event.xdestroywindow.window == menu_window) { + menu_window = 0; + menu_visible = false; + } + break; + + case ReparentNotify: + if (event.xreparent.window == icon_window && + event.xreparent.parent == tray_manager_window) { + embedded = true; + MarkIconDirty(); + } + break; + + case ClientMessage: + if (event.xclient.window == icon_window && + event.xclient.format == 32 && + event.xclient.message_type == xembed_atom && + event.xclient.data.l[1] == kXEmbedEmbeddedNotify) { + embedded = true; + MarkIconDirty(); + } + break; + + case ButtonRelease: + HandleButtonRelease(event.xbutton); + break; + + case LeaveNotify: + if (event.xcrossing.window == menu_window) { + HideMenu(); + } + break; + + default: + break; + } + } + + void HandleButtonRelease(const XButtonEvent& event) { + if (menu_window && event.window == menu_window) { + const bool inside_menu = + event.x >= 0 && event.y >= 0 && event.x < menu_width && + event.y < kMenuHeight; + const bool activate_exit = inside_menu && event.button == Button1; + HideMenu(); + if (activate_exit) { + RequestExit(); + } + return; + } + + if (event.window != icon_window) { + if (menu_visible) { + HideMenu(); + } + return; + } + + if (event.button == Button1) { + ShowWindow(); + } else if (event.button == Button3) { + ShowMenu(event.x_root, event.y_root); + } + } + + bool WaitForEmbed() { + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(kDockTimeoutMs); + while (std::chrono::steady_clock::now() < deadline) { + ProcessPendingEvents(kMaxTrayEventsDuringEmbed); + if (embedded) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return embedded; + } + + void DrawIcon() { + if (!display || !icon_window) { + return; + } + + XWindowAttributes attributes{}; + if (!XGetWindowAttributes(display, icon_window, &attributes)) { + return; + } + + const int width = std::max(1, attributes.width); + const int height = std::max(1, attributes.height); + + if (DrawProgramIcon(width, height)) { + return; + } + + const int side = std::min(width, height); + const int shape_x = (width - side) / 2; + const int shape_y = (height - side) / 2; + ApplyIconShape(shape_x, shape_y, side); + const int pad = std::max(2, side / 8); + const int box = std::max(1, side - pad * 2); + + GC gc = XCreateGC(display, icon_window, 0, nullptr); + XSetForeground(display, gc, white_pixel); + XFillRectangle(display, icon_window, gc, 0, 0, width, height); + XSetForeground(display, gc, brand_pixel); + XFillArc(display, icon_window, gc, (width - box) / 2, (height - box) / 2, + box, box, 0, 360 * 64); + + const char* mark = "C"; + XSetForeground(display, gc, white_pixel); + if (fallback_font) { + XSetFont(display, gc, fallback_font->fid); + const int text_width = XTextWidth(fallback_font, mark, 1); + const int x = (width - text_width) / 2; + const int y = (height + fallback_font->ascent - fallback_font->descent) / + 2; + XDrawString(display, icon_window, gc, x, y, mark, 1); + } else { + XDrawString(display, icon_window, gc, width / 2 - 3, height / 2 + 4, + mark, 1); + } + + XFreeGC(display, gc); + XFlush(display); + } + + bool DrawProgramIcon(int width, int height) { + if (!LoadProgramIcon()) { + return false; + } + + const int image_size = std::min(width, height); + if (image_size <= 0) { + return false; + } + + const int image_x = (width - image_size) / 2; + const int image_y = (height - image_size) / 2; + ApplyIconShape(image_x, image_y, image_size); + XImage* image = XCreateImage(display, icon_visual, icon_depth, ZPixmap, 0, + nullptr, width, height, 32, 0); + if (!image) { + return false; + } + + if (image->red_mask == 0 || image->green_mask == 0 || + image->blue_mask == 0) { + XDestroyImage(image); + return false; + } + + image->data = static_cast( + std::calloc(static_cast(image->bytes_per_line), + static_cast(height))); + if (!image->data) { + XDestroyImage(image); + return false; + } + + const unsigned long background_pixel = RgbaToPixel(image, 0, 0, 0, 0); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + XPutPixel(image, x, y, background_pixel); + } + } + + Pixmap shape_mask = 0; + GC shape_gc = nullptr; + if (shape_available) { + shape_mask = XCreatePixmap(display, icon_window, width, height, 1); + if (shape_mask) { + shape_gc = XCreateGC(display, shape_mask, 0, nullptr); + XSetForeground(display, shape_gc, 0); + XFillRectangle(display, shape_mask, shape_gc, 0, 0, width, height); + XSetForeground(display, shape_gc, 1); + } + } + + for (int y = 0; y < height; ++y) { + if (y < image_y || y >= image_y + image_size) { + continue; + } + const double src_y = + ((static_cast(y - image_y) + 0.5) * icon_height / + image_size) - + 0.5; + for (int x = 0; x < width; ++x) { + if (x < image_x || x >= image_x + image_size) { + continue; + } + const double src_x = + ((static_cast(x - image_x) + 0.5) * icon_width / + image_size) - + 0.5; + const unsigned char src_r = + SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x, + src_y, 0); + const unsigned char src_g = + SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x, + src_y, 1); + const unsigned char src_b = + SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x, + src_y, 2); + const unsigned char src_a = + SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x, + src_y, 3); + const unsigned long pixel = + RgbaToPixel(image, src_r, src_g, src_b, src_a); + XPutPixel(image, x, y, pixel); + if (shape_gc && src_a > 24) { + XDrawPoint(display, shape_mask, shape_gc, x, y); + } + } + } + + if (shape_mask) { + XShapeCombineMask(display, icon_window, ShapeBounding, 0, 0, shape_mask, + ShapeSet); + XShapeCombineMask(display, icon_window, ShapeInput, 0, 0, shape_mask, + ShapeSet); + if (shape_gc) { + XFreeGC(display, shape_gc); + } + XFreePixmap(display, shape_mask); + } + + GC gc = XCreateGC(display, icon_window, 0, nullptr); + XPutImage(display, icon_window, gc, image, 0, 0, 0, 0, width, height); + XFreeGC(display, gc); + XDestroyImage(image); + XFlush(display); + return true; + } + + void ApplyIconShape(int x, int y, int size) { + if (!shape_available || !display || !icon_window || size <= 0) { + return; + } + + XRectangle rect{}; + rect.x = static_cast(x); + rect.y = static_cast(y); + rect.width = static_cast(size); + rect.height = static_cast(size); + XShapeCombineRectangles(display, icon_window, ShapeBounding, 0, 0, &rect, 1, + ShapeSet, Unsorted); + XShapeCombineRectangles(display, icon_window, ShapeInput, 0, 0, &rect, 1, + ShapeSet, Unsorted); + } + + unsigned long RgbaToPixel(XImage* image, unsigned char r, unsigned char g, + unsigned char b, unsigned char a) const { + unsigned char out_r = r; + unsigned char out_g = g; + unsigned char out_b = b; + + unsigned long alpha_mask = 0; + if (icon_format && icon_format->type == PictTypeDirect && + icon_format->direct.alphaMask > 0) { + alpha_mask = ((1UL << icon_format->direct.alphaMask) - 1UL) + << icon_format->direct.alpha; + out_r = static_cast( + (static_cast(r) * a + 127U) / 255U); + out_g = static_cast( + (static_cast(g) * a + 127U) / 255U); + out_b = static_cast( + (static_cast(b) * a + 127U) / 255U); + } + + return ScaleColorToMask(out_r, image->red_mask) | + ScaleColorToMask(out_g, image->green_mask) | + ScaleColorToMask(out_b, image->blue_mask) | + ScaleColorToMask(a, alpha_mask); + } + + bool LoadProgramIcon() { + if (!icon_rgba.empty()) { + return true; + } + + for (const auto& candidate : BuildIconCandidatePaths()) { + if (!std::filesystem::exists(candidate)) { + continue; + } + + int width = 0; + int height = 0; + int channels = 0; + unsigned char* pixels = + stbi_load(candidate.string().c_str(), &width, &height, &channels, 4); + if (!pixels || width <= 0 || height <= 0) { + if (pixels) { + stbi_image_free(pixels); + } + continue; + } + + icon_width = width; + icon_height = height; + icon_rgba.assign(pixels, pixels + static_cast(width) * + static_cast(height) * 4); + stbi_image_free(pixels); + LOG_INFO("Loaded Linux tray icon: {}", candidate.string()); + return true; + } + + LOG_WARN("Linux tray icon not found, using fallback icon"); + return false; + } + + int MenuTextWidth() const { + if (menu_font) { + XGlyphInfo extents{}; + XftTextExtentsUtf8(display, menu_font, + reinterpret_cast(menu_label.c_str()), + static_cast(menu_label.size()), &extents); + return extents.xOff; + } + + if (font_set) { + XRectangle ink{}; + XRectangle logical{}; + Xutf8TextExtents(font_set, menu_label.c_str(), + static_cast(menu_label.size()), &ink, &logical); + return logical.width; + } + + if (fallback_font) { + return XTextWidth(fallback_font, menu_ascii_label.c_str(), + static_cast(menu_ascii_label.size())); + } + + return static_cast(menu_ascii_label.size()) * 8; + } + + void ShowMenu(int root_x, int root_y) { + if (!display) { + return; + } + + HideMenu(); + + menu_width = std::max(72, MenuTextWidth() + kMenuHorizontalPadding * 2); + const int display_width = DisplayWidth(display, screen); + const int display_height = DisplayHeight(display, screen); + const int x = std::clamp(root_x, 0, std::max(0, display_width - menu_width)); + const int y = std::clamp(root_y, 0, + std::max(0, display_height - kMenuHeight)); + + XSetWindowAttributes attrs{}; + attrs.override_redirect = True; + attrs.background_pixel = white_pixel; + attrs.border_pixel = black_pixel; + attrs.event_mask = ExposureMask | ButtonPressMask | ButtonReleaseMask | + LeaveWindowMask; + menu_window = XCreateWindow( + display, root_window, x, y, menu_width, kMenuHeight, 1, CopyFromParent, + InputOutput, CopyFromParent, + CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &attrs); + if (!menu_window) { + return; + } + + menu_visible = true; + XMapRaised(display, menu_window); + XGrabPointer(display, menu_window, False, + ButtonPressMask | ButtonReleaseMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime); + DrawMenu(); + XFlush(display); + } + + void HideMenu() { + if (!display || !menu_window) { + menu_visible = false; + return; + } + + XUngrabPointer(display, CurrentTime); + XDestroyWindow(display, menu_window); + menu_window = 0; + menu_visible = false; + XFlush(display); + } + + void DrawMenu() { + if (!display || !menu_window) { + return; + } + + GC gc = XCreateGC(display, menu_window, 0, nullptr); + XSetForeground(display, gc, white_pixel); + XFillRectangle(display, menu_window, gc, 0, 0, menu_width, kMenuHeight); + XSetForeground(display, gc, hover_pixel); + XFillRectangle(display, menu_window, gc, 1, 1, menu_width - 2, + kMenuHeight - 2); + XSetForeground(display, gc, black_pixel); + + int baseline = kMenuHeight / 2 + 5; + if (menu_font && menu_text_color_allocated) { + XftDraw* draw = XftDrawCreate(display, menu_window, + DefaultVisual(display, screen), + DefaultColormap(display, screen)); + if (draw) { + baseline = (kMenuHeight - (menu_font->ascent + menu_font->descent)) / + 2 + + menu_font->ascent; + XftDrawStringUtf8( + draw, &menu_text_color, menu_font, kMenuHorizontalPadding, baseline, + reinterpret_cast(menu_label.c_str()), + static_cast(menu_label.size())); + XftDrawDestroy(draw); + } + } else if (font_set) { + XFontSetExtents* extents = XExtentsOfFontSet(font_set); + if (extents) { + baseline = (kMenuHeight - extents->max_logical_extent.height) / 2 - + extents->max_logical_extent.y; + } + Xutf8DrawString(display, menu_window, font_set, gc, + kMenuHorizontalPadding, baseline, menu_label.c_str(), + static_cast(menu_label.size())); + } else { + if (fallback_font) { + XSetFont(display, gc, fallback_font->fid); + baseline = (kMenuHeight + fallback_font->ascent - + fallback_font->descent) / + 2; + } + XDrawString(display, menu_window, gc, kMenuHorizontalPadding, baseline, + menu_ascii_label.c_str(), + static_cast(menu_ascii_label.size())); + } + + XFreeGC(display, gc); + XFlush(display); + } + + void ShowWindow() { + if (!app_window) { + return; + } + + SDL_ShowWindow(app_window); + SDL_RestoreWindow(app_window); + SDL_RaiseWindow(app_window); + } + + void RequestExit() { + SDL_Event event{}; + event.type = + exit_event_type == 0 || exit_event_type == static_cast(-1) + ? SDL_EVENT_QUIT + : exit_event_type; + SDL_PushEvent(&event); + } + + ::SDL_Window* app_window = nullptr; + std::string tooltip; + int language_index = 0; + uint32_t exit_event_type = 0; + std::string menu_label; + std::string menu_ascii_label; + Display* display = nullptr; + int screen = 0; + ::Window root_window = 0; + ::Window tray_manager_window = 0; + ::Window icon_window = 0; + ::Window menu_window = 0; + Atom selection_atom = None; + Atom tray_opcode_atom = None; + Atom xembed_atom = None; + Atom xembed_info_atom = None; + Atom utf8_string_atom = None; + Atom net_wm_name_atom = None; + unsigned long black_pixel = 0; + unsigned long white_pixel = 0; + unsigned long brand_pixel = 0; + unsigned long hover_pixel = 0; + Visual* icon_visual = nullptr; + int icon_depth = 0; + Colormap icon_colormap = 0; + bool icon_colormap_owned = false; + XRenderPictFormat* icon_format = nullptr; + XFontSet font_set = nullptr; + XFontStruct* fallback_font = nullptr; + XftFont* menu_font = nullptr; + XftColor menu_text_color{}; + bool menu_text_color_allocated = false; + std::vector icon_rgba; + int icon_width = 0; + int icon_height = 0; + int menu_width = 72; + bool docked = false; + bool embedded = false; + bool menu_visible = false; + bool icon_needs_redraw = false; + bool shape_available = false; +}; + +LinuxTray::LinuxTray(::SDL_Window* app_window, const std::string& tooltip, + int language_index, uint32_t exit_event_type) + : impl_(std::make_unique(app_window, tooltip, language_index, + exit_event_type)) {} + +LinuxTray::~LinuxTray() = default; + +bool LinuxTray::MinimizeToTray() { return impl_->MinimizeToTray(); } + +void LinuxTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); } + +void LinuxTray::ProcessEvents() { impl_->ProcessEvents(); } + +} // namespace crossdesk + +#endif // defined(__linux__) && !defined(__APPLE__) diff --git a/src/gui/tray/linux_tray.h b/src/gui/tray/linux_tray.h new file mode 100644 index 0000000..fcf421c --- /dev/null +++ b/src/gui/tray/linux_tray.h @@ -0,0 +1,40 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-06-23 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LINUX_TRAY_H_ +#define _LINUX_TRAY_H_ + +#if defined(__linux__) && !defined(__APPLE__) + +#include +#include +#include + +struct SDL_Window; + +namespace crossdesk { + +struct LinuxTrayImpl; + +class LinuxTray { + public: + LinuxTray(::SDL_Window* app_window, const std::string& tooltip, + int language_index, uint32_t exit_event_type); + ~LinuxTray(); + + bool MinimizeToTray(); + void RemoveTrayIcon(); + void ProcessEvents(); + + private: + std::unique_ptr impl_; +}; + +} // namespace crossdesk + +#endif // defined(__linux__) && !defined(__APPLE__) + +#endif // _LINUX_TRAY_H_ diff --git a/src/gui/windows/main_settings_window.cpp b/src/gui/windows/main_settings_window.cpp index e638191..58b1e15 100644 --- a/src/gui/windows/main_settings_window.cpp +++ b/src/gui/windows/main_settings_window.cpp @@ -356,10 +356,6 @@ int Render::SettingWindow() { ImGui::Separator(); { -#if !defined(_WIN32) && !defined(__APPLE__) - ImGui::BeginDisabled(); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.5f, 1.0f)); -#endif settings_items_offset += settings_items_padding; ImGui::SetCursorPosY(settings_items_offset); ImGui::AlignTextToFramePadding(); @@ -375,10 +371,6 @@ int Render::SettingWindow() { ImGui::Checkbox("##enable_minimize_to_tray_", &enable_minimize_to_tray_); -#if !defined(_WIN32) && !defined(__APPLE__) - ImGui::PopStyleColor(); - ImGui::EndDisabled(); -#endif } ImGui::Separator(); @@ -626,14 +618,12 @@ int Render::SettingWindow() { } enable_daemon_last_ = enable_daemon_; -#if defined(_WIN32) || defined(__APPLE__) if (enable_minimize_to_tray_) { config_center_->SetMinimizeToTray(true); } else { config_center_->SetMinimizeToTray(false); } enable_minimize_to_tray_last_ = enable_minimize_to_tray_; -#endif // File transfer save path config_center_->SetFileTransferSavePath(file_transfer_save_path_buf_); diff --git a/xmake/platform.lua b/xmake/platform.lua index 58e4c05..de72d87 100644 --- a/xmake/platform.lua +++ b/xmake/platform.lua @@ -35,7 +35,12 @@ function setup_platform_settings() add_links("pulse-simple", "pulse") add_requires("libyuv") add_syslinks("pthread", "dl") - add_links("SDL3", "asound", "X11", "Xtst", "Xrandr", "Xfixes") + add_links("SDL3", "asound", "X11", "Xext", "Xrender", "Xft", "Xtst", + "Xrandr", "Xfixes") + add_existing_include_dirs({ + "/usr/include/freetype2", + "/usr/local/include/freetype2" + }, {system = true}) if is_config("USE_DRM", true) then add_links("drm") diff --git a/xmake/targets.lua b/xmake/targets.lua index dbe53f7..35319b3 100644 --- a/xmake/targets.lua +++ b/xmake/targets.lua @@ -214,12 +214,15 @@ function setup_targets() add_includedirs("src/gui", "src/gui/panels", "src/gui/toolbars", "src/gui/windows", {public = true}) if is_os("windows") then - add_files("src/gui/tray/*.cpp") + add_files("src/gui/tray/win_tray.cpp") add_includedirs("src/gui/tray", "src/service/windows", {public = true}) elseif is_os("macosx") then add_files("src/gui/windows/*.mm", "src/gui/tray/*.mm") add_includedirs("src/gui/tray", {public = true}) + elseif is_os("linux") then + add_files("src/gui/tray/linux_tray.cpp") + add_includedirs("src/gui/tray", {public = true}) end if is_os("windows") then