[feat] unify persistent file transfer notifications

This commit is contained in:
dijunkun
2026-10-19 10:53:55 +08:00
parent 7662b80995
commit b2d3834944
14 changed files with 735 additions and 230 deletions
@@ -3,6 +3,9 @@
#import <AppKit/AppKit.h>
#include <cmath>
#include <cstdint>
#include <memory>
#include <utility>
@interface CrossDeskStreamFullscreenTarget : NSObject
- (void)toggleStreamFullscreen:(id)sender;
@@ -40,6 +43,24 @@ BOOL stream_restore_zoom_hidden = NO;
NSApplicationPresentationOptions stream_restore_presentation_options =
NSApplicationPresentationDefault;
struct FileDialogRequest {
std::string title;
std::function<void(std::string)> completion;
};
std::shared_ptr<FileDialogRequest> file_dialog_request;
NSOpenPanel *file_dialog = nil;
NSOpenPanel *prepared_file_dialog = nil;
uint64_t file_dialog_prepare_generation = 0;
NSOpenPanel *CreateFileDialog() {
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = NO;
panel.resolvesAliases = YES;
return panel;
}
void InstallStreamFullscreenButton(NSWindow *window) {
if (window == nil) {
return;
@@ -358,41 +379,109 @@ bool IsStreamWindowFullscreen() {
return stream_fullscreen;
}
std::string OpenNativeFileDialog(const std::string &title) {
void ShowFileTransferNotification(void* slint_view,
const std::function<void()>& show) {
@autoreleasepool {
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = NO;
panel.resolvesAliases = YES;
NSWindow *previous_key = NSApp.keyWindow;
NSView *view = (__bridge NSView *)slint_view;
NSWindow *notification = view.window;
notification.excludedFromWindowsMenu = YES;
notification.hidesOnDeactivate = NO;
notification.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorIgnoresCycle;
show();
// Winit's show path calls makeKeyAndOrderFront even for frameless windows.
// Restore the input owner synchronously before the next event-loop turn.
if (notification.isKeyWindow) {
if (previous_key && previous_key != notification) [previous_key makeKeyWindow];
else [notification resignKeyWindow];
}
}
}
void PrepareNativeFileDialog() {
const auto generation = ++file_dialog_prepare_generation;
// Creating NSOpenPanel initializes the system file browser and can take
// hundreds of milliseconds. Do that once after the main window is visible,
// without showing a panel, changing focus, or blocking the initial frame.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC),
dispatch_get_main_queue(), ^{
if (generation != file_dialog_prepare_generation ||
file_dialog_request || prepared_file_dialog) return;
NSOpenPanel *panel = CreateFileDialog();
if (generation == file_dialog_prepare_generation &&
!file_dialog_request && !prepared_file_dialog)
prepared_file_dialog = panel;
});
}
void OpenNativeFileDialog(const std::string& title,
std::function<void(std::string)> completion) {
if (file_dialog_request) {
completion({});
return;
}
const auto request = std::make_shared<FileDialogRequest>(
FileDialogRequest{title, std::move(completion)});
file_dialog_request = request;
// Leave the current Slint callback before AppKit creates or shows the panel.
// runModal would let display-link callbacks reenter Slint's timer dispatch.
dispatch_async(dispatch_get_main_queue(), ^{
if (file_dialog_request != request) return;
NSOpenPanel *panel = prepared_file_dialog ?: CreateFileDialog();
prepared_file_dialog = nil;
file_dialog = panel;
NSString *prompt =
[[NSString alloc] initWithBytes:title.data()
length:title.size()
[[NSString alloc] initWithBytes:request->title.data()
length:request->title.size()
encoding:NSUTF8StringEncoding];
if (prompt != nil && prompt.length > 0) {
panel.title = prompt;
panel.message = prompt;
}
panel.title = prompt ?: @"";
panel.message = prompt ?: @"";
// tinyfiledialogs uses an external osascript process on macOS, so its
// chooser can appear without becoming the active window. Keep the picker
// inside CrossDesk and explicitly activate both the app and panel.
[NSApp activateIgnoringOtherApps:YES];
NSWindow *owner = stream_window ?: NSApp.keyWindow ?: NSApp.mainWindow;
NSWindow *owner = NSApp.keyWindow ?: NSApp.mainWindow;
if (owner != nil) {
[owner makeKeyAndOrderFront:nil];
}
NSScreen *target_screen = owner.screen ?: NSScreen.mainScreen;
CenterWindowOnScreen(panel, target_screen);
[panel makeKeyAndOrderFront:nil];
__weak NSOpenPanel *weak_panel = panel;
[panel beginWithCompletionHandler:^(NSModalResponse result) {
// Cancellation can complete a request before AppKit calls this block.
if (file_dialog_request != request) return;
NSOpenPanel *finished_panel = weak_panel;
const char *selected = result == NSModalResponseOK
? finished_panel.URL.fileSystemRepresentation : nullptr;
const std::string path = selected ? selected : "";
[finished_panel orderOut:nil];
file_dialog = nil;
// AppKit has returned the panel to its configuration phase. Keep the
// browser alive for the next selection instead of rebuilding it.
prepared_file_dialog = finished_panel;
file_dialog_request.reset();
auto finished = std::move(request->completion);
finished(path);
}];
if (file_dialog_request == request) [panel makeKeyAndOrderFront:nil];
});
}
if ([panel runModal] != NSModalResponseOK || panel.URL == nil) {
return {};
}
const char *path = panel.URL.fileSystemRepresentation;
return path != nullptr ? std::string(path) : std::string{};
}
void CancelNativeFileDialog() {
++file_dialog_prepare_generation;
prepared_file_dialog = nil;
const auto request = std::move(file_dialog_request);
if (!request) return;
NSOpenPanel *panel = file_dialog;
file_dialog = nil;
[panel cancel:nil];
[panel orderOut:nil];
auto finished = std::move(request->completion);
finished({});
}
} // namespace crossdesk
+16 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include <functional>
#include <string>
namespace crossdesk {
@@ -42,8 +43,20 @@ bool IsStreamWindowActive();
bool SetStreamWindowFullscreen(bool fullscreen);
bool IsStreamWindowFullscreen();
// Opens an application-modal macOS file picker. The panel is made key before
// entering its modal loop so it receives keyboard input immediately.
std::string OpenNativeFileDialog(const std::string &title);
// Shows a desktop transfer notification without replacing the current key
// window, including while the stream is fullscreen.
// |slint_view| must be the notification's existing AppKit NSView*.
void ShowFileTransferNotification(void* slint_view,
const std::function<void()>& show);
// Schedule hidden initialization after the main window's initial display.
void PrepareNativeFileDialog();
// Opens a macOS file picker asynchronously on the main thread, without a
// nested modal loop. Completion receives an empty path on cancel or failure.
void OpenNativeFileDialog(const std::string& title,
std::function<void(std::string)> completion);
// Cancels an open or pending picker, completes it once with an empty path, and
// releases the cached panel and pending initialization during application exit.
void CancelNativeFileDialog();
} // namespace crossdesk