Compare commits

...
10 Commits
18 changed files with 1130 additions and 169 deletions
@@ -659,6 +659,41 @@ struct WindowDragState {
float global_to_physical_scale = 1;
slint::PhysicalPosition window_start;
slint::PhysicalPosition last_target;
#if defined(__linux__) && !defined(__APPLE__)
std::unique_ptr<Display, decltype(&XCloseDisplay)> x11_display{nullptr,
XCloseDisplay};
slint::Timer pointer_timer;
#endif
bool ReadGlobalPointer(float* x, float* y) {
#if defined(__linux__) && !defined(__APPLE__)
const char* driver = SDL_GetCurrentVideoDriver();
if (driver && std::strcmp(driver, "x11") == 0) {
// Slint can dispatch motion before SDL pumps its XInput2 events, and
// pointer warps do not emit raw motion at all. Query X11 directly so
// dragging never depends on SDL's cached global pointer position.
if (!x11_display) {
x11_display.reset(XOpenDisplay(nullptr));
}
if (!x11_display) {
return false;
}
::Window root, child;
int root_x, root_y, local_x, local_y;
unsigned int buttons;
if (!XQueryPointer(x11_display.get(), DefaultRootWindow(x11_display.get()),
&root, &child, &root_x, &root_y, &local_x, &local_y,
&buttons)) {
return false;
}
*x = static_cast<float>(root_x);
*y = static_cast<float>(root_y);
return true;
}
#endif
SDL_GetGlobalMouseState(x, y);
return true;
}
};
bool CanUseGlobalPointerPosition() {
@@ -851,9 +886,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
state.last_local_y = mouse_y;
state.window_start = window.position();
state.last_target = state.window_start;
state.use_global_pointer = CanUseGlobalPointerPosition();
state.use_global_pointer =
CanUseGlobalPointerPosition() &&
state.ReadGlobalPointer(&state.pointer_start_x, &state.pointer_start_y);
if (state.use_global_pointer) {
SDL_GetGlobalMouseState(&state.pointer_start_x, &state.pointer_start_y);
#if defined(__APPLE__)
// Cocoa reports global pointer positions in logical screen points,
// while Slint/winit accepts physical window positions.
@@ -862,9 +898,32 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
state.global_to_physical_scale = 1.0f;
#endif
}
#if defined(__linux__) && !defined(__APPLE__)
if (state.use_global_pointer && state.x11_display) {
// Moving the window can leave consecutive pointer events at identical
// local coordinates, so Slint need not emit another TouchArea::moved.
// Follow the global pointer throughout the drag, including those moves.
state.pointer_timer.start(
slint::TimerMode::Repeated, std::chrono::milliseconds(16),
[weak = slint::ComponentWeakHandle(component), &state] {
if (auto window = weak.lock()) {
DragWindow(*window, 2, 0, 0, state);
} else {
state.active = false;
state.pointer_timer.stop();
}
});
}
#endif
return;
}
if (phase == 0) {
#if defined(__linux__) && !defined(__APPLE__)
state.pointer_timer.stop();
if (state.use_global_pointer && state.x11_display) {
DragWindow(component, 2, mouse_x, mouse_y, state);
}
#endif
state.active = false;
return;
}
@@ -876,7 +935,9 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
if (state.use_global_pointer) {
float pointer_x = 0;
float pointer_y = 0;
SDL_GetGlobalMouseState(&pointer_x, &pointer_y);
if (!state.ReadGlobalPointer(&pointer_x, &pointer_y)) {
return;
}
const int delta_x = static_cast<int>(std::lround(
(pointer_x - state.pointer_start_x) * state.global_to_physical_scale));
const int delta_y = static_cast<int>(std::lround(
@@ -1033,6 +1094,8 @@ int GuiApplication::Run() {
ui_->main->show();
#if _WIN32
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
#elif defined(__APPLE__)
ConfigureMainWindowChrome(ui_->main->window().appkit_view());
#endif
slint::run_event_loop(slint::EventLoopMode::RunUntilQuit);
Cleanup();
@@ -1181,6 +1244,7 @@ void GuiApplication::InitializeSystemTray() {
#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.
@@ -2407,7 +2471,7 @@ void GuiApplication::SyncMainWindow() {
#endif
#if defined(__APPLE__)
if (ui_->main_native_titlebar_attempts > 0) {
if (HideDisabledMainWindowZoomButton()) {
if (ConfigureMainWindowChrome(ui_->main->window().appkit_view())) {
ui_->main_native_titlebar_attempts = 0;
} else {
--ui_->main_native_titlebar_attempts;
@@ -91,7 +91,7 @@ struct TranslationRow {
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
X(save_remote_previews, u8"保存远程画面预览", "Save remote screen previews", u8"Сохранять превью экрана") \
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью экрана:") \
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
+131 -71
View File
@@ -52,7 +52,7 @@ export global UiStrings {
in-out property <string> self-hosted: "Self-hosted server";
in-out property <string> autostart: "Start with system";
in-out property <string> daemon: "Run in background";
in-out property <string> save-remote-previews: "Save remote screen previews";
in-out property <string> save-remote-previews: "Save remote screen previews:";
in-out property <string> unknown-platform: "Unknown platform";
in-out property <string> privacy-on-connect: "Privacy screen on connect";
in-out property <string> file-save-path: "File save path";
@@ -148,19 +148,50 @@ component DialogPromptText inherits Rectangle {
}
}
// Shared online-state artwork. Both the recent-connection card and the
// server status bar use the same pure green center and proportional glow.
// Explicit device labels make state readable without interpreting a dot.
// Opaque surfaces keep the badge legible over both dark and light previews.
component ConnectionStatusBadge inherits Rectangle {
in property <string> label;
in property <bool> online: false;
preferred-width: badge-label.preferred-width + 16px;
width: self.preferred-width;
height: 20px;
border-radius: self.height / 2;
background: root.online ? #eaf8f0 : #f2f4f7;
border-width: 1px;
border-color: root.online ? #bce3cd : #d5dbe3;
badge-label := Text {
x: 8px;
width: max(0px, parent.width - 16px);
height: parent.height;
text: root.label;
color: root.online ? #16a34a : #596575;
font-size: ImGuiFontStyle.small;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
// Keep the green center and fade the surrounding glow into the status bar.
component OnlineStatusIndicator inherits Rectangle {
in property <length> dot-diameter: 8px;
width: root.dot-diameter * 2.2;
height: self.width;
border-radius: self.width / 2;
// Slint's default radial radius is half the bounding-box diagonal.
// Fade out at the circle's edge (1 / sqrt(2)), before rounded clipping.
background: @radial-gradient(
circle,
#00ff0030 0%,
#00ff0018 48%,
transparent 100%
#00ff0040 0%,
#00ff0038 32%,
#00ff0018 50%,
#00ff0007 62%,
#00ff0000 70.7%
);
Rectangle {
@@ -701,11 +732,27 @@ export component MainWindow inherits Window {
accessible-role: button;
accessible-label: UiStrings.update-available;
accessible-action-default => { root.menu-open = !root.menu-open; }
Text {
text: "!"; color: white;
font-size: 12px; font-weight: 700;
horizontal-alignment: center; vertical-alignment: center;
// Center the visible mark independently of font baseline metrics.
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 2px;
height: 10px;
accessible-role: none;
Rectangle {
y: 0px;
width: parent.width;
height: 6px;
border-radius: 1px;
background: white;
}
Rectangle {
y: 8px;
width: parent.width;
height: 2px;
border-radius: 1px;
background: white;
}
}
TouchArea {
mouse-cursor: pointer;
@@ -967,71 +1014,84 @@ export component MainWindow inherits Window {
x: 7px;
y: 7px;
width: connection-card.content-width;
height: 84px;
background: connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) :
connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) :
connection.platform == "linux" ? @linear-gradient(135deg, #a14f14, #ba631f) :
connection.platform == "ios" ? @linear-gradient(135deg, #146e66, #1f8278) :
@linear-gradient(135deg, #596675, #737d8c);
if connection.has-thumbnail: Image {
height: 109px;
border-radius: 4px;
clip: true;
Rectangle {
x: 0px;
y: 0px;
width: parent.width;
height: parent.height;
source: connection.thumbnail;
image-fit: cover;
height: 84px;
background: connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) :
connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) :
connection.platform == "linux" ? @linear-gradient(135deg, #a14f14, #ba631f) :
connection.platform == "ios" ? @linear-gradient(135deg, #146e66, #1f8278) :
@linear-gradient(135deg, #596675, #737d8c);
if connection.has-thumbnail: Image {
width: parent.width;
height: parent.height;
source: connection.thumbnail;
image-fit: cover;
}
if !connection.has-thumbnail: Text {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text: connection.platform == "windows" ? "Windows" :
connection.platform == "macos" ? "macOS" :
connection.platform == "linux" ? "Linux" :
connection.platform == "ios" ? "iOS" : UiStrings.unknown-platform;
color: white;
font-size: 20px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
if !connection.has-thumbnail: Text {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text: connection.platform == "windows" ? "Windows" :
connection.platform == "macos" ? "macOS" :
connection.platform == "linux" ? "Linux" :
connection.platform == "ios" ? "iOS" : UiStrings.unknown-platform;
color: white;
font-size: 20px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
Rectangle {
x: 0px;
y: 84px;
width: parent.width;
height: 25px;
background: #00000018;
Text {
x: 4px;
y: 1px;
height: parent.height - self.y;
width: max(0px, parent.width - self.x - 4px
- (card-touch.has-hover
? connection-card.toolbar-width + connection-card.toolbar-right-padding
: 0px));
text: connection.display-name;
color: #30343b;
// The ImGui card footer uses its own 0.52 font
// scale and renders slightly larger than the
// compact body text used in dialogs.
font-size: ImGuiFontStyle.base;
overflow: elide;
vertical-alignment: center;
}
}
}
Rectangle {
x: 7px;
y: 91px;
width: connection-card.content-width;
height: 25px;
background: #00000018;
border-radius: 3px;
Text {
x: 4px;
y: 1px;
height: parent.height - self.y;
width: max(0px, parent.width - self.x - 4px
- (card-touch.has-hover
? connection-card.toolbar-width + connection-card.toolbar-right-padding
: 0px));
text: connection.display-name;
color: #30343b;
// The ImGui card footer uses its own 0.52 font
// scale and renders slightly larger than the
// compact body text used in dialogs.
font-size: ImGuiFontStyle.base;
overflow: elide;
vertical-alignment: center;
ConnectionStatusBadge {
x: 6px;
y: 6px;
width: min(self.preferred-width, parent.width - 12px);
label: connection.online ? UiStrings.online : UiStrings.offline;
online: connection.online;
}
// The software renderer only clips rectangular bounds.
// This outer border masks the four corners there too.
Rectangle {
x: -parent.border-radius;
y: -parent.border-radius;
width: parent.width + 2 * parent.border-radius;
height: parent.height + 2 * parent.border-radius;
border-width: parent.border-radius;
border-radius: 2 * parent.border-radius;
border-color: recent-list.background;
}
}
if connection.online: OnlineStatusIndicator {
x: 16px - self.width / 2;
y: 16px - self.height / 2;
dot-diameter: 9px;
}
if !connection.online: Rectangle {
x: 11.5px;
y: 11.5px;
width: 9px;
height: 9px;
border-radius: 4.5px;
background: #9ca3af;
}
card-touch := TouchArea {
x: 7px;
+79 -51
View File
@@ -1,5 +1,5 @@
import { ScrollView } from "std-widgets.slint";
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
export struct AnnouncementItem {
id: string,
@@ -47,7 +47,8 @@ export component MessagePanel inherits DialogSurface {
callback close;
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
: root.read-save-failed ? MessageStrings.read-save-error : "";
private property <length> content-top: root.notice-text == "" ? 42px : 78px;
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
private property <color> separator-color: #e6e8eb;
private property <string> pending-delete-id;
private property <string> pending-delete-revision;
function request-delete(id: string, revision: string) {
@@ -66,109 +67,135 @@ export component MessagePanel inherits DialogSurface {
return reject;
}
}
background: #f7f8fa;
background: white;
border-radius: 7px;
TouchArea { }
Rectangle {
x: 1px; y: 1px; width: parent.width - 2px; height: 30px;
background: #e7ebf0; border-radius: 7px;
Rectangle { y: parent.height - 7px; height: 7px; background: #e7ebf0; }
title-bar := Rectangle {
x: 0px; y: 0px; width: parent.width; height: 21px;
background: #d3d3d3; border-radius: 7px;
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
message-heading := Text {
x: 12px; width: parent.width - 100px; height: parent.height;
x: 6px; width: parent.width - 100px; height: parent.height;
text: MessageStrings.messages;
font-size: 14px; font-weight: 400; color: #111827;
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124;
vertical-alignment: center; overflow: elide;
}
if root.unread-count > 0: Rectangle {
x: message-heading.x + message-heading.preferred-width + 8px;
y: 5px; height: 20px;
width: max(20px, unread-count-label.preferred-width + 12px);
border-radius: 10px; background: #dbeafe;
y: (parent.height - self.height) / 2; height: 16px;
width: max(16px, unread-count-label.preferred-width + 8px);
border-radius: 8px; background: #eaf2ff;
accessible-role: text;
accessible-label: MessageStrings.unread + " " + root.unread-count;
unread-count-label := Text {
text: "" + root.unread-count; color: #1d4ed8;
font-size: 12px; font-weight: 600;
text: "" + root.unread-count; color: #2563eb;
font-size: ImGuiFontStyle.small; font-weight: 600;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
if root.loading: Text {
x: parent.width - 150px; width: 110px; height: parent.height;
text: MessageStrings.loading; color: #4b5563; font-size: 12px;
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
horizontal-alignment: right; vertical-alignment: center;
}
Rectangle {
x: parent.width - 31px; y: 3px; width: 25px; height: 24px;
IconButton {
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
border-radius: 3px;
background: close-touch.has-hover ? #d8dde5 : transparent;
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
icon-color: #202124;
hover-background: #bfc2c7; pressed-background: #b2b2b2;
accessible-role: button;
accessible-label: MessageStrings.close;
accessible-action-default => { root.close(); }
Text { text: "×"; font-size: 20px; color: #4a5568; horizontal-alignment: center; vertical-alignment: center; }
close-touch := TouchArea { mouse-cursor: pointer; clicked => { root.close(); } }
clicked => { root.close(); }
}
}
if root.notice-text != "": Rectangle {
x: 12px; y: 40px; width: parent.width - 24px; height: 28px;
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px;
border-radius: 4px;
background: #fef2f2;
Text {
x: 8px; width: parent.width - 16px; height: parent.height;
text: root.notice-text; font-size: 12px; overflow: elide;
color: #b42318;
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide;
color: #b3261e;
vertical-alignment: center;
}
}
Rectangle {
x: 12px; y: root.content-top; width: 180px; height: parent.height - self.y - 12px;
background: white; border-width: 1px; border-color: #e2e5e9;
announcement-list := Rectangle {
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px;
announcements-scroll := ScrollView {
width: parent.width; height: parent.height;
VerticalLayout {
width: 160px; spacing: 3px; padding: 5px;
for item in root.items: Rectangle {
height: 56px; width: 150px;
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f1f5fa : white;
width: announcements-scroll.width; spacing: 0px; padding: 0px;
padding-right: 8px;
alignment: start;
for item[index] in root.items: Rectangle {
height: 54px;
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent;
border-radius: 4px;
Text { x: 8px; y: 5px; width: parent.width - 28px; height: 22px; text: item.title; overflow: elide; font-size: 14px; font-weight: item.read ? 400 : 600; color: item.read ? #374151 : #111827; vertical-alignment: center; }
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; }
if !item.read: Rectangle {
x: parent.width - 18px; y: 13px; width: 6px; height: 6px;
border-radius: 3px; background: #2563eb;
x: parent.width - 17px; y: 13px; width: 5px; height: 5px;
border-radius: 2.5px; background: #2563eb;
accessible-role: none;
}
Text { x: 8px; y: 32px; width: parent.width - 42px; text: item.date; color: #4b5563; font-size: 12px; overflow: elide; }
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; }
accessible-role: button;
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
accessible-action-default => { root.open-message(item.id, item.revision); }
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } }
IconButton {
x: parent.width - 25px; y: 29px; width: 20px; height: 20px;
icon: FontAwesomeIcons.xmark; icon-size: 12px;
icon-color: #667085; danger: true;
x: parent.width - 24px; y: 26px; width: 20px; height: 20px;
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small;
icon-color: #61656c; danger: true;
accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + item.title;
accessible-action-default => { root.request-delete(item.id, item.revision); }
clicked => { root.request-delete(item.id, item.revision); }
}
if index < root.items.length - 1: Rectangle {
x: 8px; y: parent.height - 1px;
width: parent.width - 16px; height: 1px;
background: root.separator-color;
accessible-role: none;
}
}
}
}
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.empty; wrap: word-wrap; color: #4b5563; font-size: 14px; horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
x: 202px; y: root.content-top; width: parent.width - 214px; height: parent.height - self.y - 12px;
background: white; border-width: 1px; border-color: #e2e5e9;
if root.selected-id == "" && root.items.length > 0: Text { x: 14px; width: parent.width - 28px; text: MessageStrings.select-message; color: #4b5563; font-size: 14px; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
if root.selected-id != "": ScrollView {
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height;
text: MessageStrings.empty; wrap: word-wrap;
color: #61656c; font-size: ImGuiFontStyle.body;
horizontal-alignment: center; vertical-alignment: center;
}
content-divider := Rectangle {
x: announcement-list.x + announcement-list.width + 12px;
y: root.content-top; width: 1px; height: announcement-list.height;
background: root.separator-color;
visible: root.items.length > 0 || root.selected-id != "";
accessible-role: none;
}
announcement-details := Rectangle {
x: content-divider.x + content-divider.width + 12px;
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height;
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
if root.selected-id != "": details-scroll := ScrollView {
width: parent.width; height: parent.height;
VerticalLayout {
width: root.width - 234px; padding: 12px; spacing: 10px; alignment: start;
Text { vertical-stretch: 0; text: root.selected-title; wrap: word-wrap; font-size: 16px; font-weight: 600; color: #202124; }
Text { vertical-stretch: 0; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #4b5563; font-size: 12px; wrap: word-wrap; }
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px;
spacing: 12px; alignment: start;
VerticalLayout {
vertical-stretch: 0; spacing: 4px;
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; }
}
Rectangle { height: 1px; background: root.separator-color; }
StyledText {
vertical-stretch: 0;
text: root.selected-body;
default-color: #202124; default-font-size: 14px;
default-color: #202124; default-font-size: ImGuiFontStyle.body;
link-color: #2563eb;
link-clicked(url) => { root.open-link(url); }
}
@@ -184,29 +211,30 @@ export component MessagePanel inherits DialogSurface {
triggered => { root.load-more(); }
}
if root.pending-delete-id != "": Rectangle {
background: #00000042; border-radius: 8px; z: 30;
background: #00000042; border-radius: root.border-radius; z: 30;
TouchArea { clicked => { confirmation-keys.focus(); } }
DialogSurface {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 230px; height: confirmation-content.preferred-height;
border-radius: root.border-radius;
TouchArea { clicked => { confirmation-keys.focus(); } }
confirmation-content := VerticalLayout {
padding: 16px;
padding-top: 26px;
padding-bottom: 26px;
spacing: 20px;
Text { text: MessageStrings.delete-confirmation; font-size: 14px; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
HorizontalLayout {
alignment: center; spacing: 8px;
cancel-delete-button := CompactButton {
width: 72px; text-size: 13px; text: MessageStrings.cancel;
width: 72px; text: MessageStrings.cancel;
accessible-role: button; accessible-label: MessageStrings.cancel;
accessible-action-default => { cancel-delete-button.clicked(); }
clicked => { root.pending-delete-id = ""; }
}
confirm-delete-button := CompactButton {
width: 72px; text-size: 13px; text: MessageStrings.delete-message; primary: true;
width: 72px; text: MessageStrings.delete-message; primary: true;
accessible-role: button; accessible-label: MessageStrings.delete-title;
accessible-action-default => { confirm-delete-button.clicked(); }
clicked => {
@@ -35,8 +35,6 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
return -1;
}
root_ = DefaultRootWindow(display_);
int event_base, error_base, major_version, minor_version;
if (!XTestQueryExtension(display_, &event_base, &error_base, &major_version,
&minor_version)) {
@@ -166,7 +164,10 @@ void PlatformMouseController::SetMousePosition(int x, int y) {
if (!display_) {
return;
}
XWarpPointer(display_, None, root_, 0, 0, 0, 0, x, y);
// Inject motion through XTest so XInput2 raw-motion listeners (including
// SDL's global pointer cache) see the movement.
// XWarpPointer moves the cursor without producing those raw events.
XTestFakeMotionEvent(display_, DefaultScreen(display_), x, y, CurrentTime);
XFlush(display_);
}
@@ -54,7 +54,6 @@ class PlatformMouseController final : public MouseController {
enum class WaylandAbsoluteMode { kUnknown, kPixels, kNormalized, kDisabled };
_XDisplay* display_ = nullptr;
unsigned long root_ = 0;
std::vector<DisplayInfo> display_info_list_;
int screen_width_ = 0;
int screen_height_ = 0;
@@ -80,25 +80,36 @@ bool StartNativeWindowDrag() {
}
}
bool HideDisabledMainWindowZoomButton() {
bool ConfigureMainWindowChrome(void *opaque_slint_view) {
@autoreleasepool {
for (NSWindow *window in [NSApp windows]) {
if (![window.title isEqualToString:@"CrossDesk"]) {
continue;
}
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
// The main window has a fixed size, so AppKit disables its zoom button.
// Stream windows remain resizable and must keep their native button.
if (zoom_button == nil || zoom_button.enabled) {
continue;
}
zoom_button.hidden = YES;
return true;
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
NSWindow *window = slint_view.window;
if (window == nil ||
(window.styleMask & NSWindowStyleMaskTitled) == 0) {
return false;
}
return false;
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
if (zoom_button == nil || zoom_button.enabled) {
return false;
}
// Winit creates a transparent AppKit backing even when Slint paints an
// opaque client area. macOS 27 can expose that backing in the titlebar.
// Use a fixed white backing instead of AppKit's theme-dependent titlebar
// material. Aqua keeps the native title and controls readable on white.
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
window.titlebarAppearsTransparent = YES;
window.opaque = YES;
window.backgroundColor = NSColor.whiteColor;
zoom_button.hidden = YES;
NSView *frame_view = window.contentView.superview;
frame_view.needsLayout = YES;
[frame_view layoutSubtreeIfNeeded];
return true;
}
}
+4 -3
View File
@@ -8,9 +8,10 @@ namespace crossdesk {
// left-mouse-down event. Returns false when no suitable native event exists.
bool StartNativeWindowDrag();
// Hides the disabled native zoom button on CrossDesk's fixed-size main
// window. Returns true once the matching AppKit window has been configured.
bool HideDisabledMainWindowZoomButton();
// Sets a fixed white native titlebar and hides the disabled zoom button on
// the fixed-size main window. |slint_view| is its AppKit NSView*. Returns false
// while the native window or its fixed-size constraints are not ready.
bool ConfigureMainWindowChrome(void* slint_view);
// Configures live resize and replaces the stream window's native Space
// fullscreen action with an immediate, single-window fullscreen transition.
@@ -27,6 +27,9 @@
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; };
100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; };
100000000000000000000015 /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* PRIVACY.md */; };
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -54,6 +57,9 @@
200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = "<group>"; };
200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = "<group>"; };
200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -98,6 +104,9 @@
200000000000000000000013 /* ThumbnailFileStore.swift */,
200000000000000000000012 /* PrivacyPreferences.swift */,
200000000000000000000002 /* RemoteSessionModel.swift */,
200000000000000000000019 /* AnnouncementInbox.swift */,
20000000000000000000001A /* AnnouncementText.swift */,
20000000000000000000001B /* AnnouncementInboxView.swift */,
200000000000000000000018 /* RecentConnection.swift */,
200000000000000000000016 /* RemoteVideoSettings.swift */,
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
@@ -264,6 +273,9 @@
files = (
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
100000000000000000000019 /* AnnouncementInbox.swift in Sources */,
10000000000000000000001A /* AnnouncementText.swift in Sources */,
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */,
100000000000000000000018 /* RecentConnection.swift in Sources */,
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
@@ -0,0 +1,378 @@
import Combine
import CryptoKit
import Foundation
struct Announcement: Decodable, Identifiable, Equatable {
let id: Int64
let revision: Int64
let title: String
let body: String
let updated_at: Int64
var read = false
// Reading state belongs to this device, never to the server response.
private enum CodingKeys: String, CodingKey {
case id, revision, title, body, updated_at
}
var date: Date { Date(timeIntervalSince1970: TimeInterval(updated_at)) }
}
struct AnnouncementSnapshot: Equatable {
var items: [Announcement] = []
var total = 0
var unread = 0
var loaded = false
var loading = false
var failed = false
var readSaveFailed = false
var deleteFailed = false
}
/// Main-thread inbox using the desktop announcements_list protocol and local
/// revision semantics. Summary pages count all unread items; bodies load in 20s.
final class AnnouncementInbox: ObservableObject {
@Published private(set) var state = AnnouncementSnapshot()
var send: ((Data, String) -> Void)?
private struct Entry: Decodable {
let id: Int64
let revision: Int64
}
private struct Response: Decodable {
let request_id: String
let summary_only: Bool
let offset: Int
let total: Int
let catalog_revision: Int64
let items: [Entry]
}
private struct BodyResponse: Decodable { let items: [Announcement] }
private struct Request: Encodable {
let type = "announcements_list"
let request_id: String
let summary_only: Bool
let offset: Int
}
private struct LocalState: Codable {
let version: Int
let scope: String
let reads: [String: Int64]
let dismissed: [String: Int64]?
}
private let directory: URL
private var scope = ""
private var file: URL?
private var reads: [Int64: Int64] = [:]
private var dismissed: [Int64: Int64] = [:]
private var catalog: [Int64: Int64] = [:]
private var pendingCatalog: [Int64: Int64] = [:]
private var pageIDs: [Int: [Int64]] = [:]
private var pageBodies: [Int: [Announcement]] = [:]
private var catalogRevision: Int64 = -1
private var pendingRevision: Int64 = -1
private var pendingTotal = 0
private var pageOffset = 0
private var visibleLimit = 20
private var summary = true
private var connected = false
private var requestID: String?
private var timeout: Timer?
init(directory: URL = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("AnnouncementReads", isDirectory: true)) {
self.directory = directory
}
deinit { timeout?.invalidate() }
func configure(host: String, port: Int, deviceID: String) {
guard !host.isEmpty, port > 0, !deviceID.isEmpty else { return }
var host = host.lowercased()
if host.hasSuffix(".") { host.removeLast() }
guard let data = try? JSONSerialization.data(withJSONObject: [host, port, deviceID],
options: [.fragmentsAllowed]),
let nextScope = String(data: data, encoding: .utf8),
scope != nextScope else { return }
let wasConnected = connected
reset()
connected = wasConnected
scope = nextScope
let name = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
file = directory.appendingPathComponent(name + ".json")
loadLocalState()
refresh()
}
func reset() {
cancelRequest()
state = AnnouncementSnapshot()
scope = ""
file = nil
reads = [:]
dismissed = [:]
catalog = [:]
pendingCatalog = [:]
pageIDs = [:]
pageBodies = [:]
catalogRevision = -1
pendingRevision = -1
pendingTotal = 0
visibleLimit = 20
connected = false
}
func setConnected(_ value: Bool) {
connected = value
refresh()
}
func refresh() {
cancelRequest()
pendingCatalog = [:]
pendingRevision = -1
pendingTotal = 0
summary = true
request(offset: 0)
}
func loadMore() {
guard connected, !state.loading, !state.failed, catalogRevision >= 0,
visibleLimit < state.total else { return }
visibleLimit += min(20, state.total - visibleLimit)
rebuildVisibleItems()
}
func markRead(_ item: Announcement) {
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }),
reads[item.id] != item.revision else { return }
var updated = reads
updated[item.id] = item.revision
guard saveLocalState(reads: updated, dismissed: dismissed) else {
state.readSaveFailed = true
return
}
reads = updated
state.readSaveFailed = false
state.deleteFailed = false
updateReadingState()
}
func dismiss(_ item: Announcement) {
// A confirmation can outlive the displayed revision.
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }) else {
state.deleteFailed = true
return
}
var updated = dismissed
updated[item.id] = item.revision
guard saveLocalState(reads: reads, dismissed: updated) else {
state.deleteFailed = true
return
}
dismissed = updated
state.deleteFailed = false
state.readSaveFailed = false
if summary && state.loading {
state.items.removeAll { dismissed[$0.id] == $0.revision }
updateReadingState()
} else {
cancelRequest()
rebuildVisibleItems()
}
}
func failed(requestID: String) {
guard self.requestID == requestID else { return }
cancelRequest()
state.failed = true
}
func receive(_ data: Data) {
guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let id = object["request_id"] as? String, id == requestID else { return }
let decoder = JSONDecoder()
guard object["error"] == nil, let response = try? decoder.decode(Response.self, from: data) else {
failed(requestID: id)
return
}
let limit = summary ? 200 : 20
guard response.summary_only == summary, response.offset >= 0,
response.total >= response.offset, response.total <= Int32.max,
response.catalog_revision >= 0,
response.items.count == min(limit, response.total - response.offset),
response.items.allSatisfy({ $0.id > 0 && $0.revision > 0 }) else {
failed(requestID: id)
return
}
let expectedRevision = summary ? pendingRevision : catalogRevision
if expectedRevision >= 0 && expectedRevision != response.catalog_revision {
refresh()
return
}
if summary {
guard response.offset == pendingCatalog.count,
pendingRevision < 0 || pendingTotal == response.total else {
failed(requestID: id)
return
}
for item in response.items {
guard pendingCatalog.updateValue(item.revision, forKey: item.id) == nil else {
failed(requestID: id)
return
}
}
pendingRevision = response.catalog_revision
pendingTotal = response.total
cancelRequest()
if pendingCatalog.count < pendingTotal {
request(offset: pendingCatalog.count)
return
}
if catalogRevision != pendingRevision {
pageIDs = [:]
pageBodies = [:]
}
catalog = pendingCatalog
catalogRevision = pendingRevision
reads = reads.filter { catalog[$0.key] != nil }
dismissed = dismissed.filter { catalog[$0.key] != nil }
} else {
guard response.offset == pageOffset, response.total == catalog.count,
Set(response.items.map(\.id)).count == response.items.count,
let bodies = try? decoder.decode(BodyResponse.self, from: data).items,
bodies.allSatisfy({ item in
catalog[item.id] == item.revision &&
(1...32_503_680_000).contains(item.updated_at) &&
item.title.utf8.count <= 240 && item.body.utf8.count <= 8000
}) else {
failed(requestID: id)
return
}
cancelRequest()
pageIDs[response.offset] = bodies.map(\.id)
pageBodies[response.offset] = bodies
}
state.failed = false
rebuildVisibleItems()
}
private func cancelRequest() {
timeout?.invalidate()
timeout = nil
requestID = nil
state.loading = false
}
private func request(offset: Int) {
guard connected, !scope.isEmpty else { return }
let id = UUID().uuidString
guard let data = try? JSONEncoder().encode(Request(
request_id: id, summary_only: summary, offset: offset)) else { return }
requestID = id
state.loading = true
state.failed = false
let timer = Timer(timeInterval: 12, repeats: false) { [weak self] _ in
self?.failed(requestID: id)
}
timeout = timer
RunLoop.main.add(timer, forMode: .common)
send?(data, id)
}
private func updateReadingState() {
let visible = catalog.filter { dismissed[$0.key] != $0.value }
state.total = visible.count
state.unread = visible.filter { reads[$0.key] != $0.value }.count
state.items = state.items.map { item in
var item = item
item.read = reads[item.id] == item.revision
return item
}
}
private func rebuildVisibleItems() {
updateReadingState()
let wanted = min(visibleLimit, state.total)
var visible: [Announcement] = []
var retainedPages: Set<Int> = []
var position = 0
var nextOffset: Int?
summary = false
if wanted > 0 {
for offset in stride(from: 0, to: catalog.count, by: 20) {
guard let ids = pageIDs[offset] else { nextOffset = offset; break }
var selection: Set<Int64> = []
for id in ids {
guard let revision = catalog[id], dismissed[id] != revision else { continue }
if position < wanted { selection.insert(id) }
position += 1
}
if selection.isEmpty { pageBodies[offset] = nil; continue }
retainedPages.insert(offset)
guard let bodies = pageBodies[offset] else { nextOffset = offset; break }
visible += bodies.filter { selection.contains($0.id) }
if visible.count == wanted { break }
}
}
if visible.count == wanted {
pageBodies = pageBodies.filter { retainedPages.contains($0.key) }
} else {
// Retain unchanged bodies while fetching a refreshed catalog.
var retained = Set(visible.map(\.id))
for item in state.items where catalog[item.id] == item.revision &&
dismissed[item.id] != item.revision && retained.insert(item.id).inserted {
visible.append(item)
if visible.count == wanted { break }
}
}
state.items = visible
state.loaded = true
updateReadingState()
if let nextOffset {
pageOffset = nextOffset
request(offset: nextOffset)
}
}
private func loadLocalState() {
guard let file,
let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size <= 16 * 1024 * 1024,
let data = try? Data(contentsOf: file),
let saved = try? JSONDecoder().decode(LocalState.self, from: data),
saved.scope == scope, [1, 2].contains(saved.version) else { return }
func revisions(_ entries: [String: Int64]) -> [Int64: Int64] {
var result: [Int64: Int64] = [:]
for (key, revision) in entries {
if let id = Int64(key), id > 0, revision > 0 { result[id] = revision }
}
return result
}
reads = revisions(saved.reads)
dismissed = revisions(saved.dismissed ?? [:])
}
private func saveLocalState(reads: [Int64: Int64], dismissed: [Int64: Int64]) -> Bool {
guard let file else { return false }
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var directory = directory
var values = URLResourceValues()
values.isExcludedFromBackup = true
try directory.setResourceValues(values)
let saved = LocalState(version: 2, scope: scope,
reads: Dictionary(uniqueKeysWithValues: reads.map { (String($0.key), $0.value) }),
dismissed: Dictionary(uniqueKeysWithValues: dismissed.map { (String($0.key), $0.value) }))
try JSONEncoder().encode(saved).write(to: file, options: .atomic)
return true
} catch {
return false
}
}
}
@@ -0,0 +1,189 @@
import SwiftUI
struct AnnouncementBadge: View {
@ObservedObject var inbox: AnnouncementInbox
var body: some View {
Image(systemName: "bell")
.font(.system(size: 18, weight: .semibold))
.frame(width: 40, height: 40)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topTrailing) {
if inbox.state.unread > 0 {
Text(inbox.state.unread > 99 ? "99+" : "\(inbox.state.unread)")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.frame(minWidth: 16, minHeight: 16)
.background(.red, in: Capsule())
.offset(x: 5, y: -3)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("通知公告")
.accessibilityValue(inbox.state.unread > 0 ? "\(inbox.state.unread) 条未读" : "无未读公告")
}
}
struct AnnouncementSelection: Hashable {
let id: Int64
let revision: Int64
}
private enum AnnouncementDate {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter
}()
static func string(_ item: Announcement) -> String { formatter.string(from: item.date) }
}
struct AnnouncementInboxView: View {
@ObservedObject var session: RemoteSessionModel
@ObservedObject var inbox: AnnouncementInbox
@State private var pendingDelete: Announcement?
var body: some View {
List {
if inbox.state.deleteFailed {
Text("无法删除这条公告,请重试。")
.foregroundStyle(.red)
}
if inbox.state.failed && session.announcementsConnected {
HStack {
Text("公告加载失败,请重试。")
Spacer()
Button("重试") { session.refreshAnnouncements() }
.disabled(!session.announcementsConnected)
}
}
Section {
ForEach(inbox.state.items) { item in
NavigationLink(value: HomeDestination.announcement(
AnnouncementSelection(id: item.id, revision: item.revision))) {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(item.read ? Color.clear : Color.accentColor)
.frame(width: 7, height: 7)
.padding(.top, 7)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 7) {
Text(verbatim: item.title)
.fontWeight(item.read ? .regular : .semibold)
.foregroundStyle(.primary)
.lineLimit(2)
Text(AnnouncementDate.string(item))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 5)
.accessibilityElement(children: .combine)
.accessibilityValue(item.read ? "已读" : "未读")
}
.swipeActions {
Button("删除", role: .destructive) { pendingDelete = item }
}
.contextMenu {
Button("删除公告", role: .destructive) { pendingDelete = item }
}
}
if inbox.state.loading {
HStack { Spacer(); ProgressView("加载中…"); Spacer() }
} else if inbox.state.items.count < inbox.state.total && !inbox.state.failed {
Button("加载更多") { inbox.loadMore() }
.frame(maxWidth: .infinity)
.disabled(!session.announcementsConnected)
.onAppear { inbox.loadMore() }
} else if inbox.state.loaded && inbox.state.total == 0 &&
!inbox.state.failed && session.announcementsConnected {
Text("暂无公告")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 120)
}
} header: {
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
}
}
.navigationTitle("通知公告")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { session.refreshAnnouncements() } label: {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel("刷新公告")
.disabled(inbox.state.loading || !session.announcementsConnected)
}
}
.refreshable {
session.refreshAnnouncements()
while inbox.state.loading && !Task.isCancelled {
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
.onAppear { session.refreshAnnouncements() }
.alert("删除公告", isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
)) {
Button("取消", role: .cancel) { pendingDelete = nil }
Button("删除", role: .destructive) {
if let item = pendingDelete { inbox.dismiss(item) }
pendingDelete = nil
}
} message: {
Text("删除这条公告?")
}
}
}
struct AnnouncementDetailView: View {
@ObservedObject var inbox: AnnouncementInbox
let selection: AnnouncementSelection
@Environment(\.openURL) private var openURL
private var item: Announcement? {
inbox.state.items.first { $0.id == selection.id && $0.revision == selection.revision }
}
var body: some View {
ScrollView {
if let item {
VStack(alignment: .leading, spacing: 16) {
Text(verbatim: item.title)
.font(.title2.weight(.semibold))
Text("\(AnnouncementDate.string(item)) · \(item.read ? "已读" : "未读")")
.font(.caption)
.foregroundStyle(.secondary)
if inbox.state.readSaveFailed {
Text("无法保存本地阅读状态,请重试。")
.foregroundStyle(.red)
Button("重试保存阅读状态") { inbox.markRead(item) }
}
Text(AnnouncementText.format(item.body))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.environment(\.openURL, OpenURLAction { url in
guard AnnouncementText.isWebURL(url) else { return .discarded }
openURL(url)
return .handled
})
}
.padding(20)
} else {
Text("这条公告已更新或删除,请返回公告列表查看。")
.foregroundStyle(.secondary)
.padding(20)
}
}
.navigationTitle("公告详情")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if let item { inbox.markRead(item) }
}
}
}
@@ -0,0 +1,90 @@
import Foundation
enum AnnouncementText {
/// Match desktop: preserve plain text and line breaks; recognize only web
/// URLs and [label](URL), without interpreting arbitrary Markdown or HTML.
static func format(_ body: String) -> AttributedString {
let text = Array(body.replacingOccurrences(of: "\r\n", with: "\n"))
var output = AttributedString()
var literal = ""
var position = 0
func hasScheme(at start: Int) -> Bool {
let prefix = String(text[start..<min(text.count, start + 8)]).lowercased()
return prefix.hasPrefix("https://") || prefix.hasPrefix("http://")
}
func urlEnd(from start: Int) -> Int {
let punctuation = Set("<>\"'`\\{},。;:!?、()【】《》「」『』“”‘’")
var parentheses = 0
var brackets = 0
var end = start
while end < text.count {
let character = text[end]
if punctuation.contains(character) || character.unicodeScalars.contains(where: {
$0.value <= 0x20 || $0.value == 0x7f
}) { break }
if character == "(" { parentheses += 1 }
if character == ")" {
if parentheses == 0 { break }
parentheses -= 1
}
if character == "[" { brackets += 1 }
if character == "]" {
if brackets == 0 { break }
brackets -= 1
}
end += 1
}
return end
}
func appendLink(label: String, destination: String) -> Bool {
guard let url = URL(string: destination), isWebURL(url) else { return false }
output.append(AttributedString(literal))
literal = ""
var link = AttributedString(label)
link.link = url
output.append(link)
return true
}
while position < text.count {
if text[position] == "[",
let labelEnd = text[(position + 1)...].firstIndex(of: "]"),
labelEnd > position + 1, labelEnd + 2 < text.count,
text[labelEnd + 1] == "(",
!text[(position + 1)..<labelEnd].contains(where: { "[]\r\n".contains($0) }),
hasScheme(at: labelEnd + 2) {
let end = urlEnd(from: labelEnd + 2)
if end < text.count, text[end] == ")",
appendLink(label: String(text[(position + 1)..<labelEnd]),
destination: String(text[(labelEnd + 2)..<end])) {
position = end + 1
continue
}
}
if hasScheme(at: position) {
var end = urlEnd(from: position)
while end > position, ".,;:!?".contains(text[end - 1]) { end -= 1 }
let address = String(text[position..<end])
if appendLink(label: address, destination: address) {
position = end
continue
}
}
literal.append(text[position])
position += 1
}
output.append(AttributedString(literal))
return output
}
static func isWebURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme),
let host = url.host, !host.isEmpty else { return false }
return !url.absoluteString.unicodeScalars.contains {
$0.value <= 0x20 || $0.value == 0x7f || "\\<>\"".unicodeScalars.contains($0)
}
}
}
@@ -99,6 +99,10 @@ typedef struct {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
snapshot:(BOOL)snapshot;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveAnnouncementMessage:(NSData *)message;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didFailAnnouncementRequest:(NSString *)requestID;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveCursorVisible:(BOOL)visible
shape:(NSInteger)shape
@@ -150,6 +154,8 @@ typedef struct {
subscribe:(BOOL)subscribe
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
- (void)invalidatePresence;
- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID
NS_SWIFT_NAME(sendAnnouncementRequest(_:requestID:));
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
- (void)disconnect;
@@ -615,13 +615,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
if (load_status == errSecSuccess) {
self->_state->identity_with_password = cached.UTF8String;
self->_state->identity_base = BaseIdentity(self->_state->identity_with_password);
NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()];
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
[delegate rtcBridge:self didProvisionIdentity:identifier];
}
});
} else if (load_status != errSecItemNotFound) {
[self reportIdentityStorageStatus:load_status];
// Locked/unavailable Keychain is not the same as a missing identity.
@@ -631,6 +624,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
return;
}
[self createIdentityPeer];
if (load_status == errSecSuccess) {
NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()];
const uint64_t identity_generation = self->_identityGeneration.load();
DispatchMain(^{
if (networking_generation != self->_networkingGeneration.load() ||
identity_generation != self->_identityGeneration.load()) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
[delegate rtcBridge:self didProvisionIdentity:identifier];
}
});
}
});
}
@@ -658,6 +663,32 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_presenceGeneration.fetch_add(1);
}
- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID {
NSData *payload = [request copy];
NSString *identifier = [requestID copy];
const uint64_t generation = _identityGeneration.load();
const uint64_t signal_generation = _signalGeneration.load();
const uint64_t networking_generation = _networkingGeneration.load();
dispatch_async(_rtcQueue, ^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load() ||
networking_generation != self->_networkingGeneration.load()) return;
if (self->_state->identity_peer && self->_state->identity_ready &&
!self->_state->identity_base.empty() &&
SendSignalMessage(self->_state->identity_peer,
static_cast<const char *>(payload.bytes), payload.length) == 0) return;
DispatchMain(^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load() ||
networking_generation != self->_networkingGeneration.load()) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didFailAnnouncementRequest:)]) {
[delegate rtcBridge:self didFailAnnouncementRequest:identifier];
}
});
});
}
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
frameRate:(NSInteger)frameRate
preference:(NSInteger)preference
@@ -1216,10 +1247,11 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
size:(size_t)size
role:(PeerRole)role
generation:(uint64_t)generation {
constexpr size_t kMaxPresenceMessageSize = 256 * 1024;
// Twenty 8 KiB bodies may expand substantially when JSON-escaped.
constexpr size_t kMaxSignalMessageSize = 1024 * 1024;
if (role != PeerRole::Identity || generation != _identityGeneration.load() ||
!message || size == 0 ||
size > kMaxPresenceMessageSize) {
size > kMaxSignalMessageSize) {
return;
}
@@ -1235,6 +1267,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
NSString *type = object[@"type"];
if (![type isKindOfClass:NSString.class]) return;
if ([type isEqualToString:@"announcements"] ||
[type isEqualToString:@"announcements_changed"]) {
DispatchMain(^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load()) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveAnnouncementMessage:)]) {
[delegate rtcBridge:self didReceiveAnnouncementMessage:payload];
}
});
return;
}
NSMutableDictionary<NSString *, NSNumber *> *presence =
[NSMutableDictionary dictionary];
if ([type isEqualToString:@"presence"]) {
+28 -8
View File
@@ -1,6 +1,12 @@
import SwiftUI
import UIKit
enum HomeDestination: Hashable {
case settings
case announcements
case announcement(AnnouncementSelection)
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var session: RemoteSessionModel
@@ -15,6 +21,19 @@ struct ContentView: View {
ConnectionHomeView(session: session)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
// Keep inbox and detail in the same value-based stack.
// Mixing an isPresented inbox with a value-based detail
// can reinsert the inbox above the detail on updates.
.navigationDestination(for: HomeDestination.self) { destination in
switch destination {
case .settings:
ServerSettingsView(session: session)
case .announcements:
AnnouncementInboxView(session: session, inbox: session.announcements)
case .announcement(let selection):
AnnouncementDetailView(inbox: session.announcements, selection: selection)
}
}
}
.toolbarBackground(Color(.systemGroupedBackground), for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
@@ -142,7 +161,6 @@ private struct ConnectionHomeView: View {
@ObservedObject var session: RemoteSessionModel
@FocusState private var remoteIDFocused: Bool
@State private var passwordPromptVisible = false
@State private var settingsVisible = false
@State private var promptRemoteID = ""
@State private var promptPassword = ""
@State private var promptRememberPassword = false
@@ -248,9 +266,6 @@ private struct ConnectionHomeView: View {
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
}
.navigationDestination(isPresented: $settingsVisible) {
ServerSettingsView(session: session)
}
.alert("设备离线", isPresented: Binding(
get: { session.deviceOfflineAlertVisible },
set: { visible in
@@ -267,6 +282,7 @@ private struct ConnectionHomeView: View {
session.remoteID = formatted
}
}
.onDisappear { remoteIDFocused = false }
}
@ToolbarContentBuilder
@@ -288,10 +304,14 @@ private struct ConnectionHomeView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
remoteIDFocused = false
settingsVisible = true
} label: {
NavigationLink(value: HomeDestination.announcements) {
AnnouncementBadge(inbox: session.announcements)
}
.foregroundStyle(.primary)
.disabled(session.isConnecting)
}
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(value: HomeDestination.settings) {
Image(systemName: "gearshape")
.font(.system(size: 18, weight: .semibold))
.frame(width: 40, height: 40)
@@ -338,6 +338,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published private(set) var remotePlatform: RemoteHostPlatform = .unknown
let bridge = CrossDeskRTCBridge()
let announcements = AnnouncementInbox()
private let audioPlayer = RemoteAudioPlayer()
private var activeRemoteID = ""
private var remoteAppVersion = ""
@@ -387,6 +388,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
override init() {
super.init()
bridge.delegate = self
announcements.send = { [weak bridge] data, requestID in
bridge?.sendAnnouncementRequest(data, requestID: requestID)
}
if !savesConnectionThumbnails { clearConnectionThumbnails() }
if hasNetworkConsent { configureBridge() }
}
@@ -401,6 +405,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func applicationDidBecomeActive() {
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
refreshRecentConnectionPresenceAfterForeground()
refreshAnnouncements()
resumeVideoAfterForeground()
}
@@ -440,6 +445,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
// disconnect() only closes the remote session. Consent withdrawal must
// also destroy the persistent signaling peer and its reconnect loop.
bridge.stopNetworking()
announcements.reset()
bridgeConfiguration = nil
localIdentity = ""
signalStatus = "等待隐私授权"
@@ -500,6 +506,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
if bridgeConfiguration != configuration {
bridgeConfiguration = configuration
signalConnected = false
localIdentity = ""
announcements.reset()
signalStatus = "正在连接信令服务"
stopPresenceMonitoring()
invalidatePresence()
@@ -1040,6 +1048,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didChange state: CrossDeskSignalState) {
guard hasNetworkConsent else { return }
signalConnected = state.rawValue == 1
announcements.setConnected(signalConnected)
invalidatePresence()
switch state.rawValue {
case 0: signalStatus = "正在连接信令服务"
@@ -1114,12 +1123,38 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
guard hasNetworkConsent else { return }
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
if let configuration = bridgeConfiguration {
announcements.configure(host: configuration.host, port: configuration.signalPort,
deviceID: localIdentity)
}
refreshRecentConnectionPresence()
if let pendingPresenceRemoteID {
requestPresenceProbe(pendingPresenceRemoteID)
}
}
var announcementsConnected: Bool { hasNetworkConsent && signalConnected }
func refreshAnnouncements() {
guard announcementsConnected else { return }
announcements.refresh()
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAnnouncementMessage message: Data) {
guard announcementsConnected else { return }
let object = (try? JSONSerialization.jsonObject(with: message)) as? [String: Any]
if object?["type"] as? String == "announcements_changed" {
announcements.refresh()
} else {
announcements.receive(message)
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didFailAnnouncementRequest requestID: String) {
guard hasNetworkConsent else { return }
announcements.failed(requestID: requestID)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceivePresence presence: [String: NSNumber],
snapshot: Bool) {
+22
View File
@@ -77,6 +77,28 @@ for that server. Remote control sessions then log in as `C-<identity>` and use
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
## Announcements
The home toolbar's bell opens **通知公告** and shows the unread count (capped
at `99+` on the badge). The persistent identity peer fetches announcements after
signaling connects, so a remote desktop session is not required. Opening the
inbox, returning to the foreground, reconnecting, and `announcements_changed`
events refresh the catalog. Pull to refresh or use the refresh button to retry.
The iOS inbox follows the desktop `announcements_list` protocol: summaries load
in batches of 200 to count all unread announcements, while full bodies load in
batches of 20 as the list scrolls. Opening a detail marks that revision read.
Swipe or long-press a row to delete it after confirmation. Read and deleted
revisions persist only on this device, scoped by server, port and device ID;
updating an announcement makes its new revision visible and unread again.
Plain text, line breaks, HTTP(S) URLs and `[label](URL)` links are supported.
Local state lives in Application Support and is excluded from backups. Failed
writes leave the item unread or undeleted and display a retry message. Failed
requests time out after 12 seconds and retain previously loaded content.
Changing servers or withdrawing network consent clears the displayed inbox and
invalidates pending responses; consent is required before fetching again.
## Video codecs
Every iOS build includes VideoToolbox, dav1d, and SVT-AV1. H.264 uses