From e026491b9f39ca1982cdc6bdd74ceba6225c435f Mon Sep 17 00:00:00 2001 From: dijunkun Date: Tue, 23 Jun 2026 00:39:44 +0800 Subject: [PATCH] [feat] support minimizing to the system tray on macOS when closing, refs #87 --- src/gui/render.cpp | 33 ++- src/gui/render.h | 5 + src/gui/toolbars/title_bar.cpp | 16 +- src/gui/tray/mac_tray.h | 34 ++++ src/gui/tray/mac_tray.mm | 249 +++++++++++++++++++++++ src/gui/windows/main_settings_window.cpp | 6 +- xmake/platform.lua | 7 +- xmake/targets.lua | 3 +- 8 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 src/gui/tray/mac_tray.h create mode 100644 src/gui/tray/mac_tray.mm diff --git a/src/gui/render.cpp b/src/gui/render.cpp index a8d84d9..2711553 100644 --- a/src/gui/render.cpp +++ b/src/gui/render.cpp @@ -1371,6 +1371,9 @@ int Render::CreateMainWindow() { HICON tray_icon = LoadTrayIcon(); tray_ = std::make_unique(main_hwnd, tray_icon, L"CrossDesk", localization_language_index_); +#elif defined(__APPLE__) + tray_ = std::make_unique(main_window_, "CrossDesk", + localization_language_index_); #endif ImGui_ImplSDL3_InitForSDLRenderer(main_window_, main_renderer_); @@ -2051,6 +2054,23 @@ void Render::MainLoop() { } } +bool Render::MinimizeMainWindowToTray() { + if (!enable_minimize_to_tray_ || !main_window_) { + return false; + } + +#if defined(_WIN32) || defined(__APPLE__) + if (!tray_) { + return false; + } + + tray_->MinimizeToTray(); + return true; +#else + return false; +#endif +} + void Render::UpdateLabels() { if (!label_inited_ || localization_language_index_last_ != localization_language_index_) { @@ -2910,9 +2930,18 @@ void Render::ProcessSdlEvent(const SDL_Event& event) { break; case SDL_EVENT_WINDOW_CLOSE_REQUESTED: - if (event.window.windowID != SDL_GetWindowID(stream_window_)) { - exit_ = true; + if (stream_window_ && + event.window.windowID == SDL_GetWindowID(stream_window_)) { + break; } + + if (main_window_ && + event.window.windowID == SDL_GetWindowID(main_window_) && + MinimizeMainWindowToTray()) { + break; + } + + exit_ = true; break; case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: diff --git a/src/gui/render.h b/src/gui/render.h index 036f5ab..1eb8083 100644 --- a/src/gui/render.h +++ b/src/gui/render.h @@ -39,6 +39,8 @@ #if _WIN32 #include "win_tray.h" +#elif defined(__APPLE__) +#include "mac_tray.h" #endif namespace crossdesk { @@ -414,6 +416,7 @@ class Render { int AudioDeviceInit(); int AudioDeviceDestroy(); void HandleWindowsServiceIntegration(); + bool MinimizeMainWindowToTray(); #if _WIN32 void ResetLocalWindowsServiceState(bool clear_pending_sas); #if CROSSDESK_PORTABLE @@ -510,6 +513,8 @@ class Render { const int sdl_refresh_ms_ = 16; // ~60 FPS #if _WIN32 std::unique_ptr tray_; +#elif defined(__APPLE__) + std::unique_ptr tray_; #endif // main window properties diff --git a/src/gui/toolbars/title_bar.cpp b/src/gui/toolbars/title_bar.cpp index 7653dea..9205793 100644 --- a/src/gui/toolbars/title_bar.cpp +++ b/src/gui/toolbars/title_bar.cpp @@ -300,17 +300,13 @@ int Render::TitleBar(bool main_window) { } if (close_button_clicked) { -#if _WIN32 - if (enable_minimize_to_tray_) { - tray_->MinimizeToTray(); - } else { -#endif - SDL_Event event; - event.type = SDL_EVENT_QUIT; - SDL_PushEvent(&event); -#if _WIN32 + if (main_window && MinimizeMainWindowToTray()) { + return 0; } -#endif + + 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/mac_tray.h b/src/gui/tray/mac_tray.h new file mode 100644 index 0000000..172102a --- /dev/null +++ b/src/gui/tray/mac_tray.h @@ -0,0 +1,34 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-06-23 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _MAC_TRAY_H_ +#define _MAC_TRAY_H_ + +#include +#include + +struct SDL_Window; + +namespace crossdesk { + +struct MacTrayImpl; + +class MacTray { + public: + MacTray(::SDL_Window* app_window, const std::string& tooltip, + int language_index); + ~MacTray(); + + void MinimizeToTray(); + void RemoveTrayIcon(); + + private: + std::unique_ptr impl_; +}; + +} // namespace crossdesk + +#endif // _MAC_TRAY_H_ diff --git a/src/gui/tray/mac_tray.mm b/src/gui/tray/mac_tray.mm new file mode 100644 index 0000000..e6c11c7 --- /dev/null +++ b/src/gui/tray/mac_tray.mm @@ -0,0 +1,249 @@ +#include "mac_tray.h" + +#if defined(__APPLE__) + +#include + +#import + +#include "localization.h" + +#include + +@interface CrossDeskMacTrayTarget : NSObject +- (instancetype)initWithOwner:(crossdesk::MacTrayImpl *)owner; +- (void)statusItemClicked:(id)sender; +- (void)exitApplication:(id)sender; +@end + +namespace crossdesk { + +struct MacTrayImpl { + explicit MacTrayImpl(::SDL_Window *window, std::string tray_tooltip, + int language_index_value) + : app_window(window), + tooltip(std::move(tray_tooltip)), + language_index(language_index_value), + target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) {} + + ~MacTrayImpl() { + RemoveTrayIcon(); + target = nil; + } + + void MinimizeToTray() { + EnsureStatusItem(); + if (app_window) { + SDL_HideWindow(app_window); + } + } + + void RemoveTrayIcon() { + if (!status_item) { + return; + } + + [[NSStatusBar systemStatusBar] removeStatusItem:status_item]; + status_item = nil; + } + + void ShowWindow() { + if (!app_window) { + return; + } + + SDL_ShowWindow(app_window); + SDL_RaiseWindow(app_window); + [NSApp activateIgnoringOtherApps:YES]; + } + + void ShowMenu() { + EnsureStatusItem(); + if (!status_item) { + return; + } + + NSMenu *menu = [[NSMenu alloc] initWithTitle:@"CrossDesk"]; + NSString *exit_title = + NSStringFromUtf8(localization::exit_program + [localization::detail::ClampLanguageIndex( + language_index)]); + NSMenuItem *exit_item = [[NSMenuItem alloc] initWithTitle:exit_title + action:@selector(exitApplication:) + keyEquivalent:@""]; + [exit_item setTarget:target]; + [menu addItem:exit_item]; + + NSStatusBarButton *button = [status_item button]; + if (!button) { + return; + } + + const NSRect bounds = [button bounds]; + [menu popUpMenuPositioningItem:nil + atLocation:NSMakePoint(NSMinX(bounds), NSMinY(bounds)) + inView:button]; + } + + void RequestExit() { + SDL_Event event; + event.type = SDL_EVENT_QUIT; + SDL_PushEvent(&event); + } + + private: + void EnsureStatusItem() { + if (status_item) { + return; + } + + status_item = [[NSStatusBar systemStatusBar] + statusItemWithLength:NSSquareStatusItemLength]; + NSStatusBarButton *button = [status_item button]; + if (!button) { + return; + } + + [button setToolTip:NSStringFromUtf8(tooltip)]; + + NSImage *crossdesk_icon = LoadCrossDeskIcon(); + if (crossdesk_icon) { + NSImage *status_icon = [crossdesk_icon copy]; + [status_icon setSize:NSMakeSize(18.0, 18.0)]; + [status_icon setTemplate:NO]; + [button setImage:status_icon]; + [button setImagePosition:NSImageOnly]; + } else { + [button setTitle:@"CD"]; + } + + [button setTarget:target]; + [button setAction:@selector(statusItemClicked:)]; + [button sendActionOn:NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp]; + } + + NSString *NSStringFromUtf8(const std::string &text) { + return [NSString stringWithUTF8String:text.c_str()]; + } + + NSImage *LoadCrossDeskIcon() { + NSImage *icon = LoadIconFromBundleResource(@"crossdesk"); + if (!icon) { + icon = LoadIconFromBundleResource(@"crossedesk"); + } + if (!icon) { + icon = LoadIconFromDevelopmentPath(); + } + if (!icon) { + icon = [NSApp applicationIconImage]; + } + return icon; + } + + NSImage *LoadIconFromBundleResource(NSString *resource_name) { + NSString *icon_path = + [[NSBundle mainBundle] pathForResource:resource_name ofType:@"icns"]; + return LoadIconFromPath(icon_path); + } + + NSImage *LoadIconFromDevelopmentPath() { + NSMutableArray *candidate_paths = [NSMutableArray array]; + + NSString *current_directory = + [[NSFileManager defaultManager] currentDirectoryPath]; + [candidate_paths + addObject:[current_directory + stringByAppendingPathComponent: + @"icons/macos/crossdesk.icns"]]; + + const char *base_path = SDL_GetBasePath(); + if (base_path && base_path[0] != '\0') { + NSString *base_directory = NSStringFromUtf8(base_path); + [candidate_paths + addObject:[base_directory + stringByAppendingPathComponent: + @"icons/macos/crossdesk.icns"]]; + [candidate_paths + addObject:[base_directory + stringByAppendingPathComponent: + @"../../../../icons/macos/crossdesk.icns"]]; + } + + for (NSString *candidate_path in candidate_paths) { + NSImage *icon = LoadIconFromPath( + [candidate_path stringByStandardizingPath]); + if (icon) { + return icon; + } + } + + return nil; + } + + NSImage *LoadIconFromPath(NSString *icon_path) { + if (![icon_path length]) { + return nil; + } + if (![[NSFileManager defaultManager] fileExistsAtPath:icon_path]) { + return nil; + } + return [[NSImage alloc] initWithContentsOfFile:icon_path]; + } + + ::SDL_Window *app_window = nullptr; + std::string tooltip; + int language_index = 0; + NSStatusItem *status_item = nil; + CrossDeskMacTrayTarget *target = nil; +}; + +MacTray::MacTray(::SDL_Window *app_window, const std::string &tooltip, + int language_index) + : impl_( + std::make_unique(app_window, tooltip, language_index)) {} + +MacTray::~MacTray() = default; + +void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); } + +void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); } + +} // namespace crossdesk + +@implementation CrossDeskMacTrayTarget { + crossdesk::MacTrayImpl *owner_; +} + +- (instancetype)initWithOwner:(crossdesk::MacTrayImpl *)owner { + self = [super init]; + if (self) { + owner_ = owner; + } + return self; +} + +- (void)statusItemClicked:(id)sender { + (void)sender; + if (!owner_) { + return; + } + + NSEvent *event = [NSApp currentEvent]; + if (event && [event type] == NSEventTypeRightMouseUp) { + owner_->ShowMenu(); + return; + } + + owner_->ShowWindow(); +} + +- (void)exitApplication:(id)sender { + (void)sender; + if (owner_) { + owner_->RequestExit(); + } +} + +@end + +#endif // __APPLE__ diff --git a/src/gui/windows/main_settings_window.cpp b/src/gui/windows/main_settings_window.cpp index bfbd331..e638191 100644 --- a/src/gui/windows/main_settings_window.cpp +++ b/src/gui/windows/main_settings_window.cpp @@ -356,7 +356,7 @@ int Render::SettingWindow() { ImGui::Separator(); { -#ifndef _WIN32 +#if !defined(_WIN32) && !defined(__APPLE__) ImGui::BeginDisabled(); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.5f, 1.0f)); #endif @@ -375,7 +375,7 @@ int Render::SettingWindow() { ImGui::Checkbox("##enable_minimize_to_tray_", &enable_minimize_to_tray_); -#ifndef _WIN32 +#if !defined(_WIN32) && !defined(__APPLE__) ImGui::PopStyleColor(); ImGui::EndDisabled(); #endif @@ -626,7 +626,7 @@ int Render::SettingWindow() { } enable_daemon_last_ = enable_daemon_; -#if _WIN32 +#if defined(_WIN32) || defined(__APPLE__) if (enable_minimize_to_tray_) { config_center_->SetMinimizeToTray(true); } else { diff --git a/xmake/platform.lua b/xmake/platform.lua index 4c320a0..58e4c05 100644 --- a/xmake/platform.lua +++ b/xmake/platform.lua @@ -75,7 +75,8 @@ function setup_platform_settings() add_links("SDL3") add_ldflags("-Wl,-ld_classic") add_cxflags("-Wno-unused-variable") - add_frameworks("OpenGL", "IOSurface", "ScreenCaptureKit", "AVFoundation", - "CoreMedia", "CoreVideo", "CoreAudio", "AudioToolbox") + add_frameworks("Cocoa", "OpenGL", "IOSurface", "ScreenCaptureKit", + "AVFoundation", "CoreMedia", "CoreVideo", "CoreAudio", + "AudioToolbox") end -end \ No newline at end of file +end diff --git a/xmake/targets.lua b/xmake/targets.lua index ee122e0..dbe53f7 100644 --- a/xmake/targets.lua +++ b/xmake/targets.lua @@ -218,7 +218,8 @@ function setup_targets() add_includedirs("src/gui/tray", "src/service/windows", {public = true}) elseif is_os("macosx") then - add_files("src/gui/windows/*.mm") + add_files("src/gui/windows/*.mm", "src/gui/tray/*.mm") + add_includedirs("src/gui/tray", {public = true}) end if is_os("windows") then