mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
Compare commits
13
Commits
597c1dd2c9
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149d20cbd1 | ||
|
|
b935df917f | ||
|
|
fab7edeff1 | ||
|
|
85ca13aa75 | ||
|
|
ff7bda6988 | ||
|
|
380a2a225c | ||
|
|
a535ded76d | ||
|
|
fc67a1543c | ||
|
|
31acb6b2f0 | ||
|
|
2d1241c0a0 | ||
|
|
0031c411e5 | ||
|
|
bc8dbcbf68 | ||
|
|
2d49024bd6 |
@@ -16,6 +16,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -61,6 +62,7 @@
|
||||
#endif
|
||||
#include "rd_log.h"
|
||||
#include "server_window_state.h"
|
||||
#include "ui/announcement_text.h"
|
||||
#include "ui/ui_localization.h"
|
||||
#include "update_checker.h"
|
||||
#include "window_geometry.h"
|
||||
@@ -657,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() {
|
||||
@@ -849,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.
|
||||
@@ -860,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;
|
||||
}
|
||||
@@ -874,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(
|
||||
@@ -906,6 +969,8 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
} // namespace
|
||||
|
||||
struct GuiApplication::SlintUi {
|
||||
uint64_t announcement_version = UINT64_MAX;
|
||||
std::string announcement_selected_id, announcement_selected_revision;
|
||||
slint::ComponentHandle<ui::MainWindow> main = ui::MainWindow::create();
|
||||
std::shared_ptr<slint::VectorModel<ui::ReleaseNoteBlock>>
|
||||
release_note_blocks_model =
|
||||
@@ -981,6 +1046,8 @@ int GuiApplication::Run() {
|
||||
dll_log_path_ = exec_log_path_;
|
||||
InitializeLogger();
|
||||
cache_path_ = path_manager_->GetCachePath().string();
|
||||
announcements_.SetStorageDirectory(path_manager_->GetCachePath() /
|
||||
"announcement_reads");
|
||||
config_center_ = std::make_unique<ConfigCenter>(cache_path_ + "/config.ini");
|
||||
|
||||
LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION);
|
||||
@@ -1027,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();
|
||||
@@ -1175,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.
|
||||
@@ -1267,6 +1337,49 @@ void GuiApplication::ResetSettingsUi() {
|
||||
}
|
||||
|
||||
void GuiApplication::BindMainCallbacks() {
|
||||
ui_->main->on_delete_announcement([this](slint::SharedString id,
|
||||
slint::SharedString revision) {
|
||||
const auto parse = [](const slint::SharedString& value)
|
||||
-> std::optional<int64_t> {
|
||||
const std::string_view text(value.data());
|
||||
int64_t number = 0;
|
||||
const auto result =
|
||||
std::from_chars(text.data(), text.data() + text.size(), number);
|
||||
if (result.ec != std::errc{} ||
|
||||
result.ptr != text.data() + text.size() || number <= 0)
|
||||
return std::nullopt;
|
||||
return number;
|
||||
};
|
||||
const auto announcement_id = parse(id);
|
||||
const auto announcement_revision = parse(revision);
|
||||
if (!announcement_id || !announcement_revision) return;
|
||||
announcements_.Dismiss(*announcement_id, *announcement_revision);
|
||||
SyncAnnouncements();
|
||||
});
|
||||
ui_->main->on_refresh_announcements([this] { announcements_.Refresh(); });
|
||||
ui_->main->on_open_announcement_link([this](slint::SharedString link) {
|
||||
const std::string url(link);
|
||||
if (announcement_text::IsWebUrl(url)) OpenUrl(url);
|
||||
});
|
||||
ui_->main->on_load_more_announcements([this] {
|
||||
announcements_.LoadMore();
|
||||
SyncAnnouncements();
|
||||
});
|
||||
ui_->main->on_open_announcement([this](slint::SharedString id,
|
||||
slint::SharedString revision) {
|
||||
const auto snapshot = announcements_.Get();
|
||||
for (const auto& item : snapshot.items) {
|
||||
if (std::to_string(item["id"].get<int64_t>()) != id.data() ||
|
||||
std::to_string(item["revision"].get<int64_t>()) != revision.data())
|
||||
continue;
|
||||
ui_->announcement_selected_id = id.data();
|
||||
ui_->announcement_selected_revision = revision.data();
|
||||
ui_->announcement_version = UINT64_MAX;
|
||||
announcements_.Read(item["id"], item["revision"]);
|
||||
SyncAnnouncements();
|
||||
break;
|
||||
}
|
||||
});
|
||||
auto& main = ui_->main;
|
||||
main->window().on_close_requested([this] {
|
||||
// Keep the application and all active sessions alive. The tray helper
|
||||
@@ -2284,16 +2397,81 @@ void GuiApplication::UpdateLocalization() {
|
||||
ui_->localized_language = language;
|
||||
}
|
||||
|
||||
void GuiApplication::SyncAnnouncements() {
|
||||
if (signal_connected_) {
|
||||
announcements_.Configure(params_.signal_server_ip,
|
||||
params_.signal_server_port, client_id_);
|
||||
}
|
||||
const auto request = announcements_.TakePendingRequest();
|
||||
if (!request.is_null()) {
|
||||
const auto payload = request.dump();
|
||||
if (!peer_ || SendSignalMessage(peer_, payload.data(), payload.size()) != 0)
|
||||
announcements_.Failed(request["request_id"]);
|
||||
}
|
||||
if (ui_->announcement_version == announcements_.Version()) return;
|
||||
const auto state = announcements_.Get();
|
||||
ui_->announcement_version = state.version;
|
||||
ui_->main->set_announcement_unread(state.unread);
|
||||
ui_->main->set_announcement_total(state.total);
|
||||
ui_->main->set_announcement_loading(state.loading);
|
||||
ui_->main->set_announcement_failed(state.failed);
|
||||
ui_->main->set_announcement_read_save_failed(state.read_save_failed);
|
||||
ui_->main->set_announcement_delete_failed(state.delete_failed);
|
||||
ui_->main->set_announcement_loaded(state.loaded);
|
||||
std::vector<ui::AnnouncementItem> rows;
|
||||
bool selected_found = false;
|
||||
for (const auto& item : state.items) {
|
||||
ui::AnnouncementItem row;
|
||||
const auto id = std::to_string(item["id"].get<int64_t>());
|
||||
const auto revision = std::to_string(item["revision"].get<int64_t>());
|
||||
row.id = UiText(id);
|
||||
row.revision = UiText(revision);
|
||||
row.title = UiText(item["title"].get<std::string>());
|
||||
const auto timestamp =
|
||||
static_cast<time_t>(item["updated_at"].get<int64_t>());
|
||||
std::tm local{};
|
||||
#ifdef _WIN32
|
||||
localtime_s(&local, ×tamp);
|
||||
#else
|
||||
localtime_r(×tamp, &local);
|
||||
#endif
|
||||
char date[32]{};
|
||||
std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M", &local);
|
||||
row.date = UiText(date);
|
||||
row.read = item["read"].get<bool>();
|
||||
if (id == ui_->announcement_selected_id &&
|
||||
revision == ui_->announcement_selected_revision) {
|
||||
selected_found = true;
|
||||
ui_->main->set_announcement_selected_id(row.id);
|
||||
ui_->main->set_announcement_selected_title(row.title);
|
||||
ui_->main->set_announcement_selected_body(
|
||||
announcement_text::Format(item["body"].get<std::string>()));
|
||||
ui_->main->set_announcement_selected_date(row.date);
|
||||
ui_->main->set_announcement_selected_read(row.read);
|
||||
}
|
||||
rows.push_back(std::move(row));
|
||||
}
|
||||
if (!selected_found) {
|
||||
ui_->announcement_selected_id.clear();
|
||||
ui_->main->set_announcement_selected_id("");
|
||||
ui_->main->set_announcement_selected_body(slint::StyledText{});
|
||||
}
|
||||
ui_->main->set_announcements(
|
||||
std::make_shared<slint::VectorModel<ui::AnnouncementItem>>(
|
||||
std::move(rows)));
|
||||
}
|
||||
|
||||
void GuiApplication::SyncMainWindow() {
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
SyncAnnouncements();
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#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;
|
||||
|
||||
@@ -56,6 +56,7 @@ private:
|
||||
void HandlePasswordChangeResult();
|
||||
void HandleCredentialRecovery();
|
||||
void SyncMainWindow();
|
||||
void SyncAnnouncements();
|
||||
void SyncConnectionDialog();
|
||||
void SyncPlatformDialogs();
|
||||
void SyncStreamWindow();
|
||||
|
||||
@@ -20,6 +20,18 @@ struct TranslationRow {
|
||||
|
||||
// Single source of truth for all UI strings.
|
||||
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
||||
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
||||
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
||||
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
|
||||
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \
|
||||
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
||||
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
||||
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
||||
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
|
||||
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
|
||||
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
|
||||
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
|
||||
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
|
||||
@@ -79,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"Включить приватный экран") \
|
||||
@@ -261,6 +273,7 @@ struct TranslationRow {
|
||||
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
|
||||
X(about, u8"关于", "About", u8"О программе") \
|
||||
X(notification, u8"通知", "Notification", u8"Уведомление") \
|
||||
X(new_version, u8"新版本", "New version", u8"Новая версия") \
|
||||
X(new_version_available, u8"新版本可用", "New Version Available", \
|
||||
u8"Доступна новая версия") \
|
||||
X(check_for_updates, u8"检查更新", "Check for updates", \
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
#include "runtime/announcement_inbox.h"
|
||||
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <system_error>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using nlohmann::json;
|
||||
bool Integer(const json& object, const char* key, int64_t low, int64_t high) {
|
||||
return object.contains(key) && object[key].is_number_integer() &&
|
||||
object[key] >= low && object[key] <= high;
|
||||
}
|
||||
std::string ScopeFilename(const std::string& scope) {
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
SHA256(reinterpret_cast<const unsigned char*>(scope.data()), scope.size(),
|
||||
digest);
|
||||
std::string name;
|
||||
constexpr char hex[] = "0123456789abcdef";
|
||||
for (auto byte : digest) {
|
||||
name += hex[byte >> 4];
|
||||
name += hex[byte & 15];
|
||||
}
|
||||
return name + ".json";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AnnouncementInbox::Snapshot AnnouncementInbox::Get() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return state_;
|
||||
}
|
||||
uint64_t AnnouncementInbox::Version() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return state_.version;
|
||||
}
|
||||
void AnnouncementInbox::SetStorageDirectory(
|
||||
const std::filesystem::path& directory) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
directory_ = directory;
|
||||
}
|
||||
void AnnouncementInbox::Configure(std::string host, int port,
|
||||
const std::string& device_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (directory_.empty() || host.empty() || port <= 0 || device_id.empty())
|
||||
return;
|
||||
for (auto& ch : host)
|
||||
if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A';
|
||||
if (host.back() == '.') host.pop_back();
|
||||
const auto scope = json::array({host, port, device_id}).dump();
|
||||
if (scope_ == scope) return;
|
||||
scope_ = scope;
|
||||
read_file_ = directory_ / ScopeFilename(scope);
|
||||
const auto version = state_.version + 1;
|
||||
state_ = {};
|
||||
state_.version = version;
|
||||
catalog_.clear();
|
||||
catalog_revision_ = -1;
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
LoadLocalState();
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::Reset() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto version = state_.version + 1;
|
||||
state_ = {};
|
||||
state_.version = version;
|
||||
scope_.clear();
|
||||
read_file_.clear();
|
||||
reads_.clear();
|
||||
dismissed_.clear();
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
catalog_.clear();
|
||||
catalog_revision_ = -1;
|
||||
connected_ = false;
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::BeginCatalog() {
|
||||
pending_catalog_.clear();
|
||||
pending_revision_ = -1;
|
||||
pending_total_ = 0;
|
||||
phase_ = Phase::Catalog;
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
refresh_ = connected_;
|
||||
++state_.version;
|
||||
}
|
||||
void AnnouncementInbox::SetConnected(bool connected) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
connected_ = connected;
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::Refresh() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::LoadMore() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!connected_ || state_.loading || refresh_ || state_.failed ||
|
||||
catalog_revision_ < 0 || visible_limit_ >= state_.total)
|
||||
return;
|
||||
visible_limit_ += (std::min)(20, state_.total - visible_limit_);
|
||||
RebuildVisibleItems();
|
||||
++state_.version;
|
||||
}
|
||||
void AnnouncementInbox::UpdateReadingState() {
|
||||
state_.total = 0;
|
||||
state_.unread = 0;
|
||||
for (const auto& [id, revision] : catalog_) {
|
||||
if (IsDismissed(id, revision)) continue;
|
||||
++state_.total;
|
||||
const auto read = reads_.find(id);
|
||||
if (read == reads_.end() || read->second != revision) ++state_.unread;
|
||||
}
|
||||
for (auto& item : state_.items) {
|
||||
const auto read = reads_.find(item["id"]);
|
||||
item["read"] = read != reads_.end() && read->second == item["revision"];
|
||||
}
|
||||
}
|
||||
bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& item : state_.items) {
|
||||
if (item["id"] != id || item["revision"] != revision) continue;
|
||||
if (item["read"].get<bool>()) return true;
|
||||
auto updated = reads_;
|
||||
updated[id] = revision;
|
||||
if (!SaveLocalState(updated, dismissed_)) {
|
||||
state_.read_save_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
reads_ = std::move(updated);
|
||||
state_.read_save_failed = false;
|
||||
state_.delete_failed = false;
|
||||
UpdateReadingState();
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
|
||||
const auto found = dismissed_.find(id);
|
||||
return found != dismissed_.end() && found->second == revision;
|
||||
}
|
||||
|
||||
bool AnnouncementInbox::Dismiss(int64_t id, int64_t revision) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& item : state_.items) {
|
||||
if (item["id"] != id || item["revision"] != revision) continue;
|
||||
auto updated = dismissed_;
|
||||
updated[id] = revision;
|
||||
if (!SaveLocalState(reads_, updated)) {
|
||||
state_.delete_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
dismissed_ = std::move(updated);
|
||||
state_.delete_failed = false;
|
||||
state_.read_save_failed = false;
|
||||
// An index refresh already in flight must finish before reusing its order.
|
||||
if (phase_ == Phase::Catalog && (state_.loading || refresh_)) {
|
||||
auto visible = json::array();
|
||||
for (const auto& current : state_.items)
|
||||
if (!IsDismissed(current["id"], current["revision"]))
|
||||
visible.push_back(current);
|
||||
state_.items = std::move(visible);
|
||||
UpdateReadingState();
|
||||
} else {
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
RebuildVisibleItems();
|
||||
}
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
// The confirmation may refer to an announcement updated while it was open.
|
||||
state_.delete_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
|
||||
void AnnouncementInbox::RebuildVisibleItems() {
|
||||
UpdateReadingState();
|
||||
const int wanted = (std::min)(visible_limit_, state_.total);
|
||||
auto visible = json::array();
|
||||
std::set<int> retained_pages;
|
||||
int position = 0;
|
||||
phase_ = Phase::Page;
|
||||
refresh_ = false;
|
||||
for (int physical = 0;
|
||||
wanted > 0 && physical < static_cast<int>(catalog_.size());
|
||||
physical += 20) {
|
||||
const auto ids = page_ids_.find(physical);
|
||||
if (ids == page_ids_.end()) {
|
||||
pending_page_offset_ = physical;
|
||||
refresh_ = connected_;
|
||||
break;
|
||||
}
|
||||
std::set<int64_t> page_selection;
|
||||
for (auto id : ids->second) {
|
||||
const auto entry = catalog_.find(id);
|
||||
if (entry == catalog_.end() || IsDismissed(id, entry->second)) continue;
|
||||
if (position < wanted) page_selection.insert(id);
|
||||
++position;
|
||||
}
|
||||
if (page_selection.empty()) {
|
||||
page_bodies_.erase(physical);
|
||||
continue;
|
||||
}
|
||||
retained_pages.insert(physical);
|
||||
const auto bodies = page_bodies_.find(physical);
|
||||
if (bodies == page_bodies_.end()) {
|
||||
pending_page_offset_ = physical;
|
||||
refresh_ = connected_;
|
||||
break;
|
||||
}
|
||||
for (const auto& item : bodies->second)
|
||||
if (page_selection.count(item["id"])) visible.push_back(item);
|
||||
if (visible.size() == static_cast<size_t>(wanted)) break;
|
||||
}
|
||||
if (visible.size() == static_cast<size_t>(wanted)) {
|
||||
for (auto it = page_bodies_.begin(); it != page_bodies_.end();)
|
||||
if (!retained_pages.count(it->first))
|
||||
it = page_bodies_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
// A failed body fetch must not erase announcements already received. Keep
|
||||
// still-published, unchanged versions until the replacement batch arrives.
|
||||
if (visible.size() < static_cast<size_t>(wanted)) {
|
||||
std::set<int64_t> retained_ids;
|
||||
for (const auto& item : visible) retained_ids.insert(item["id"].get<int64_t>());
|
||||
for (const auto& item : state_.items) {
|
||||
const int64_t id = item["id"];
|
||||
const auto current = catalog_.find(id);
|
||||
if (current == catalog_.end() || current->second != item["revision"] ||
|
||||
IsDismissed(id, current->second) || !retained_ids.insert(id).second)
|
||||
continue;
|
||||
visible.push_back(item);
|
||||
if (visible.size() == static_cast<size_t>(wanted)) break;
|
||||
}
|
||||
}
|
||||
state_.items = std::move(visible);
|
||||
state_.loaded = true;
|
||||
UpdateReadingState();
|
||||
}
|
||||
|
||||
void AnnouncementInbox::Fail() {
|
||||
request_id_.clear();
|
||||
refresh_ = false;
|
||||
state_.loading = false;
|
||||
state_.failed = true;
|
||||
++state_.version;
|
||||
}
|
||||
nlohmann::json AnnouncementInbox::TakePendingRequest(
|
||||
std::chrono::steady_clock::time_point now) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!connected_ || scope_.empty()) return nullptr;
|
||||
if (state_.loading && now >= deadline_) Fail();
|
||||
if (!refresh_) return nullptr;
|
||||
refresh_ = false;
|
||||
request_id_ = std::to_string(++serial_);
|
||||
state_.loading = true;
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
deadline_ = now + std::chrono::seconds(12);
|
||||
return {{"type", "announcements_list"},
|
||||
{"request_id", request_id_},
|
||||
{"summary_only", phase_ == Phase::Catalog},
|
||||
{"offset", phase_ == Phase::Catalog
|
||||
? static_cast<int>(pending_catalog_.size())
|
||||
: pending_page_offset_}};
|
||||
}
|
||||
void AnnouncementInbox::Failed(const std::string& request_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (request_id == request_id_) Fail();
|
||||
}
|
||||
void AnnouncementInbox::Receive(const nlohmann::json& message) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!message.is_object() || !message.contains("request_id") ||
|
||||
message["request_id"] != request_id_ || request_id_.empty())
|
||||
return;
|
||||
if (message.contains("error")) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const bool summary = phase_ == Phase::Catalog;
|
||||
const int limit = summary ? 200 : 20;
|
||||
if (!Integer(message, "offset", 0, INT32_MAX) ||
|
||||
!Integer(message, "total", 0, INT32_MAX) ||
|
||||
!Integer(message, "catalog_revision", 0, INT64_MAX) ||
|
||||
!message.contains("summary_only") || message["summary_only"] != summary ||
|
||||
!message.contains("items") || !message["items"].is_array() ||
|
||||
message["items"].size() > static_cast<size_t>(limit)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const int offset = message["offset"], total = message["total"];
|
||||
const int64_t revision = message["catalog_revision"];
|
||||
if (offset > total ||
|
||||
message["items"].size() !=
|
||||
static_cast<size_t>((std::min)(limit, total - offset))) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const auto expected_revision =
|
||||
summary ? pending_revision_ : catalog_revision_;
|
||||
if (expected_revision >= 0 && expected_revision != revision) {
|
||||
BeginCatalog();
|
||||
return;
|
||||
}
|
||||
for (const auto& item : message["items"]) {
|
||||
if (!item.is_object() || !Integer(item, "id", 1, INT64_MAX) ||
|
||||
!Integer(item, "revision", 1, INT64_MAX)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (!summary) {
|
||||
if (!Integer(item, "updated_at", 1, 32503680000LL)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
for (const auto* key : {"title", "body"}) {
|
||||
if (!item.contains(key) || !item[key].is_string() ||
|
||||
item[key].get_ref<const std::string&>().size() >
|
||||
(std::string(key) == "title" ? 240u : 8000u)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto found = catalog_.find(item["id"]);
|
||||
if (found == catalog_.end() || found->second != item["revision"]) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
if (summary) {
|
||||
if (pending_revision_ < 0) {
|
||||
pending_revision_ = revision;
|
||||
pending_total_ = total;
|
||||
}
|
||||
if (pending_total_ != total ||
|
||||
offset != static_cast<int>(pending_catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
for (const auto& item : message["items"]) {
|
||||
if (!pending_catalog_.emplace(item["id"], item["revision"]).second) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (static_cast<int>(pending_catalog_.size()) < total) {
|
||||
refresh_ = true;
|
||||
++state_.version;
|
||||
return;
|
||||
}
|
||||
if (catalog_revision_ != revision) {
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
}
|
||||
catalog_ = std::move(pending_catalog_);
|
||||
catalog_revision_ = revision;
|
||||
// Prune expired records in memory; the next local action persists them.
|
||||
for (auto* records : {&reads_, &dismissed_}) {
|
||||
for (auto it = records->begin(); it != records->end();)
|
||||
if (!catalog_.count(it->first))
|
||||
it = records->erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
} else {
|
||||
if (total != static_cast<int>(catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (offset != pending_page_offset_) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
std::set<int64_t> unique;
|
||||
std::vector<int64_t> ids;
|
||||
for (const auto& item : message["items"]) {
|
||||
const int64_t id = item["id"];
|
||||
if (!unique.insert(id).second) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
}
|
||||
page_ids_[offset] = std::move(ids);
|
||||
page_bodies_[offset] = message["items"];
|
||||
}
|
||||
RebuildVisibleItems();
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
}
|
||||
|
||||
void AnnouncementInbox::LoadLocalState() {
|
||||
reads_.clear();
|
||||
dismissed_.clear();
|
||||
std::error_code ec;
|
||||
const auto size = std::filesystem::file_size(read_file_, ec);
|
||||
if (ec || size > 16 * 1024 * 1024) return;
|
||||
std::ifstream input(read_file_, std::ios::binary);
|
||||
const auto data = json::parse(input, nullptr, false);
|
||||
if (!data.is_object() || !data.contains("scope") || data["scope"] != scope_ ||
|
||||
!data.contains("version") ||
|
||||
(data["version"] != 1 && data["version"] != 2) ||
|
||||
!data.contains("reads") || !data["reads"].is_object())
|
||||
return;
|
||||
const auto load = [](const json& entries, Revisions& destination) {
|
||||
if (!entries.is_object()) return;
|
||||
for (const auto& entry : entries.items()) {
|
||||
int64_t id = 0;
|
||||
const auto parsed = std::from_chars(
|
||||
entry.key().data(), entry.key().data() + entry.key().size(), id);
|
||||
if (parsed.ec == std::errc{} &&
|
||||
parsed.ptr == entry.key().data() + entry.key().size() && id > 0 &&
|
||||
entry.value().is_number_integer() && entry.value() > 0 &&
|
||||
entry.value() <= INT64_MAX)
|
||||
destination[id] = entry.value();
|
||||
}
|
||||
};
|
||||
load(data["reads"], reads_);
|
||||
if (data.contains("dismissed")) load(data["dismissed"], dismissed_);
|
||||
}
|
||||
bool AnnouncementInbox::SaveLocalState(const Revisions& reads,
|
||||
const Revisions& dismissed) const {
|
||||
if (read_file_.empty()) return false;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(directory_, ec);
|
||||
if (ec) return false;
|
||||
const auto encode = [](const Revisions& records) {
|
||||
auto entries = json::object();
|
||||
for (const auto& [id, revision] : records)
|
||||
entries[std::to_string(id)] = revision;
|
||||
return entries;
|
||||
};
|
||||
const auto payload = json({{"version", 2},
|
||||
{"scope", scope_},
|
||||
{"reads", encode(reads)},
|
||||
{"dismissed", encode(dismissed)}})
|
||||
.dump();
|
||||
auto temporary = read_file_;
|
||||
temporary += ".tmp-" +
|
||||
std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
{
|
||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||
output.write(payload.data(), static_cast<std::streamsize>(payload.size()));
|
||||
output.close();
|
||||
if (!output) {
|
||||
std::filesystem::remove(temporary, ec);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
const bool saved =
|
||||
MoveFileExW(temporary.c_str(), read_file_.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
|
||||
#else
|
||||
std::filesystem::rename(temporary, read_file_, ec);
|
||||
const bool saved = !ec;
|
||||
#endif
|
||||
if (!saved) std::filesystem::remove(temporary, ec);
|
||||
return saved;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-29
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _ANNOUNCEMENT_INBOX_H_
|
||||
#define _ANNOUNCEMENT_INBOX_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
// Announcement data is shared; read and dismissed versions stay on this device.
|
||||
class AnnouncementInbox {
|
||||
public:
|
||||
struct Snapshot {
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
int total = 0, unread = 0;
|
||||
bool loaded = false, loading = false, failed = false;
|
||||
bool read_save_failed = false;
|
||||
bool delete_failed = false;
|
||||
uint64_t version = 0;
|
||||
};
|
||||
Snapshot Get() const;
|
||||
uint64_t Version() const;
|
||||
void SetStorageDirectory(const std::filesystem::path& directory);
|
||||
// Called on the UI thread after login supplies the actual device ID.
|
||||
void Configure(std::string host, int port, const std::string& device_id);
|
||||
void Reset();
|
||||
void SetConnected(bool connected);
|
||||
void Refresh();
|
||||
void LoadMore();
|
||||
bool Read(int64_t id, int64_t revision);
|
||||
bool Dismiss(int64_t id, int64_t revision);
|
||||
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
|
||||
std::chrono::steady_clock::now());
|
||||
void Failed(const std::string& request_id);
|
||||
void Receive(const nlohmann::json& message);
|
||||
|
||||
private:
|
||||
using Revisions = std::map<int64_t, int64_t>;
|
||||
enum class Phase { Catalog, Page };
|
||||
void BeginCatalog();
|
||||
void Fail();
|
||||
void UpdateReadingState();
|
||||
bool IsDismissed(int64_t id, int64_t revision) const;
|
||||
void RebuildVisibleItems();
|
||||
void LoadLocalState();
|
||||
bool SaveLocalState(const Revisions& reads, const Revisions& dismissed) const;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
Snapshot state_;
|
||||
std::filesystem::path directory_, read_file_;
|
||||
std::string scope_, request_id_;
|
||||
Revisions reads_, dismissed_, catalog_, pending_catalog_;
|
||||
// Keep bodies for the loaded list prefix. Lightweight page IDs let us skip
|
||||
// locally deleted entries without sending deletion state upstream.
|
||||
std::map<int, std::vector<int64_t>> page_ids_;
|
||||
std::map<int, nlohmann::json> page_bodies_;
|
||||
int pending_page_offset_ = 0;
|
||||
int visible_limit_ = 20;
|
||||
int64_t catalog_revision_ = -1, pending_revision_ = -1;
|
||||
int pending_total_ = 0;
|
||||
Phase phase_ = Phase::Catalog;
|
||||
bool connected_ = false, refresh_ = false;
|
||||
uint64_t serial_ = 0;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -312,8 +312,12 @@ void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseConnectionPeer() {
|
||||
if (!peer_) return;
|
||||
if (!peer_) {
|
||||
announcements_.Reset();
|
||||
return;
|
||||
}
|
||||
peer_events_->Deactivate();
|
||||
announcements_.Reset();
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "features/settings/settings_manager.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "runtime/gui_state.h"
|
||||
#include "runtime/announcement_inbox.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
#include "runtime/privacy_session_policy.h"
|
||||
|
||||
@@ -99,6 +100,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
#endif
|
||||
|
||||
PrivacyController privacy_;
|
||||
AnnouncementInbox announcements_;
|
||||
PrivacySessionPolicy privacy_sessions_;
|
||||
std::mutex privacy_commands_mutex_;
|
||||
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
|
||||
|
||||
@@ -79,7 +79,11 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||
return;
|
||||
}
|
||||
std::string type = j["type"].get<std::string>();
|
||||
if (type == "presence") {
|
||||
if (type == "announcements_changed") {
|
||||
runtime->announcements_.Refresh();
|
||||
} else if (type == "announcements") {
|
||||
runtime->announcements_.Receive(j);
|
||||
} else if (type == "presence") {
|
||||
if (j.contains("devices") && j["devices"].is_array()) {
|
||||
for (auto& dev : j["devices"]) {
|
||||
if (!dev.is_object()) {
|
||||
@@ -172,6 +176,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
|
||||
std::string client_id(user_id, user_id_size);
|
||||
if (client_id == runtime->client_id_) {
|
||||
runtime->announcements_.SetConnected(status == SignalStatus::SignalConnected);
|
||||
runtime->signal_status_ = status;
|
||||
runtime->device_presence_cache_.SetSignalConnected(
|
||||
status == SignalStatus::SignalConnected);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-29
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _ANNOUNCEMENT_TEXT_H_
|
||||
#define _ANNOUNCEMENT_TEXT_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace crossdesk::announcement_text {
|
||||
|
||||
inline size_t WebSchemeLength(std::string_view url) {
|
||||
for (const std::string_view prefix : {"https://", "http://"}) {
|
||||
if (url.size() >= prefix.size() &&
|
||||
std::equal(prefix.begin(), prefix.end(), url.begin(),
|
||||
[](char expected, unsigned char actual) {
|
||||
return expected == std::tolower(actual);
|
||||
})) {
|
||||
return prefix.size();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline bool IsWebUrl(std::string_view url) {
|
||||
const size_t scheme = WebSchemeLength(url);
|
||||
if (!scheme || url.size() == scheme ||
|
||||
std::any_of(url.begin(), url.end(), [](unsigned char ch) {
|
||||
return ch <= 0x20 || ch == 0x7f || ch == '\\' || ch == '<' ||
|
||||
ch == '>' || ch == '"';
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
const auto authority =
|
||||
url.substr(scheme, url.find_first_of("/?#", scheme) - scheme);
|
||||
return !authority.empty() && authority.back() != '@';
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline size_t UrlEnd(std::string_view text, size_t start) {
|
||||
// Do not include surrounding prose punctuation in a bare URL. Balanced
|
||||
// parentheses and IPv6 brackets are part of the address when present.
|
||||
const std::string_view punctuation =
|
||||
reinterpret_cast<const char*>(u8",。;:!?、()【】《》「」『』“”‘’");
|
||||
int parentheses = 0, brackets = 0;
|
||||
size_t end = start;
|
||||
for (; end < text.size(); ++end) {
|
||||
const unsigned char ch = text[end];
|
||||
if (ch <= 0x20 || ch == 0x7f ||
|
||||
std::string_view("<>\"'`\\{}").find(ch) != std::string_view::npos ||
|
||||
(ch >= 0xe0 && end + 3 <= text.size() &&
|
||||
punctuation.find(text.substr(end, 3)) != std::string_view::npos)) {
|
||||
break;
|
||||
}
|
||||
if (ch == '(') ++parentheses;
|
||||
if (ch == ')' && parentheses-- == 0) break;
|
||||
if (ch == '[') ++brackets;
|
||||
if (ch == ']' && brackets-- == 0) break;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
inline void AppendLiteral(std::string& output, std::string_view text) {
|
||||
for (const unsigned char ch : text) {
|
||||
// Entity-encode spaces so leading indentation is not parsed as code.
|
||||
if (ch == ' ') {
|
||||
output += " ";
|
||||
} else if (ch == '\t') {
|
||||
output += "	";
|
||||
} else {
|
||||
if (ch < 0x80 && std::ispunct(ch)) output += '\\';
|
||||
output += static_cast<char>(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void AppendLink(std::string& output, std::string_view label,
|
||||
std::string_view url) {
|
||||
output += '[';
|
||||
AppendLiteral(output, label);
|
||||
output += "](<";
|
||||
for (const char ch : url) {
|
||||
// Markdown decodes entities in link destinations; keep query strings exact.
|
||||
if (ch == '&')
|
||||
output += "&";
|
||||
else
|
||||
output += ch;
|
||||
}
|
||||
output += ">)";
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline slint::StyledText Format(std::string_view body) {
|
||||
std::string markdown;
|
||||
markdown.reserve(body.size());
|
||||
bool has_link = false;
|
||||
for (size_t pos = 0; pos < body.size();) {
|
||||
if (body[pos] == '[') {
|
||||
const size_t label_end = body.find("](", pos + 1);
|
||||
if (label_end != std::string_view::npos && label_end > pos + 1 &&
|
||||
body.substr(pos + 1, label_end - pos - 1).find_first_of("[]\r\n") ==
|
||||
std::string_view::npos &&
|
||||
WebSchemeLength(body.substr(label_end + 2))) {
|
||||
const size_t end = detail::UrlEnd(body, label_end + 2);
|
||||
const auto url = body.substr(label_end + 2, end - label_end - 2);
|
||||
if (end < body.size() && body[end] == ')' && IsWebUrl(url)) {
|
||||
detail::AppendLink(markdown,
|
||||
body.substr(pos + 1, label_end - pos - 1), url);
|
||||
pos = end + 1;
|
||||
has_link = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (WebSchemeLength(body.substr(pos))) {
|
||||
size_t end = detail::UrlEnd(body, pos);
|
||||
while (end > pos && std::string_view(".,;:!?").find(body[end - 1]) !=
|
||||
std::string_view::npos) {
|
||||
--end;
|
||||
}
|
||||
const auto url = body.substr(pos, end - pos);
|
||||
if (IsWebUrl(url)) {
|
||||
detail::AppendLink(markdown, url, url);
|
||||
pos = end;
|
||||
has_link = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (body[pos] == '\r' && pos + 1 < body.size() && body[pos + 1] == '\n') {
|
||||
++pos;
|
||||
continue;
|
||||
}
|
||||
if (body[pos] == '\n') {
|
||||
// Preserve empty lines instead of letting Markdown collapse them.
|
||||
if (markdown.empty() || markdown.back() == '\n') markdown += " ";
|
||||
markdown += '\n';
|
||||
} else {
|
||||
detail::AppendLiteral(markdown, body.substr(pos, 1));
|
||||
}
|
||||
++pos;
|
||||
}
|
||||
if (has_link) {
|
||||
if (markdown.back() == '\n') markdown += " ";
|
||||
if (const auto styled = slint::StyledText::from_markdown(markdown))
|
||||
return *styled;
|
||||
}
|
||||
return slint::StyledText::from_plain_text(body);
|
||||
}
|
||||
|
||||
} // namespace crossdesk::announcement_text
|
||||
|
||||
#endif
|
||||
@@ -339,6 +339,7 @@ export component MiniToggleSwitch inherits Rectangle {
|
||||
|
||||
export component CompactButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
@@ -355,7 +356,7 @@ export component CompactButton inherits Rectangle {
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #8e9298;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-size: root.text-size;
|
||||
overflow: elide;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Single AOT entry point. Generating one header for the entire UI avoids
|
||||
// duplicate imported-component definitions when C++ uses all three windows.
|
||||
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
||||
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
|
||||
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
||||
export { ServerWindow, ControllerEntry } from "server_window.slint";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LineEdit, ScrollView } from "std-widgets.slint";
|
||||
import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint";
|
||||
import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct RecentConnection {
|
||||
@@ -51,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";
|
||||
@@ -65,6 +66,7 @@ export global UiStrings {
|
||||
in-out property <string> signal-disconnected: "Signal server disconnected";
|
||||
in-out property <string> tls-error: "Signal server TLS certificate error";
|
||||
in-out property <string> update-available: "A new version is available";
|
||||
in-out property <string> new-version: "New version";
|
||||
in-out property <string> check-for-updates: "Check for updates";
|
||||
in-out property <string> checking-for-updates: "Checking for updates…";
|
||||
in-out property <string> up-to-date: "You’re up to date";
|
||||
@@ -146,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 {
|
||||
@@ -273,6 +306,27 @@ export component MainWindow inherits Window {
|
||||
in property <bool> signal-connected: false;
|
||||
in property <bool> signal-tls-error: false;
|
||||
in property <[RecentConnection]> recent-connections;
|
||||
in property <[AnnouncementItem]> announcements;
|
||||
in property <int> announcement-unread;
|
||||
private property <string> announcement-unread-label:
|
||||
root.announcement-unread > 99 ? "99+" : "" + root.announcement-unread;
|
||||
in property <int> announcement-total;
|
||||
in property <bool> announcement-loading;
|
||||
in property <bool> announcement-failed;
|
||||
in property <bool> announcement-read-save-failed;
|
||||
in property <bool> announcement-delete-failed;
|
||||
in property <bool> announcement-loaded;
|
||||
in-out property <bool> messages-open: false;
|
||||
in property <string> announcement-selected-id;
|
||||
in property <string> announcement-selected-title;
|
||||
in property <styled-text> announcement-selected-body;
|
||||
in property <string> announcement-selected-date;
|
||||
in property <bool> announcement-selected-read;
|
||||
callback open-announcement(string, string);
|
||||
callback open-announcement-link(string);
|
||||
callback delete-announcement(string, string);
|
||||
callback refresh-announcements;
|
||||
callback load-more-announcements;
|
||||
in property <bool> update-available: false;
|
||||
in property <UpdateCheckStatus> update-check-status: idle;
|
||||
private property <string> update-status-text:
|
||||
@@ -376,8 +430,6 @@ export component MainWindow inherits Window {
|
||||
private property <string> delete-remote-id: "";
|
||||
private property <bool> copy-toast: false;
|
||||
private property <bool> compact-language: root.language-index == 0;
|
||||
private property <bool> update-icon-blink-on:
|
||||
root.update-available && mod(animation-tick(), 2s) < 1s;
|
||||
private property <bool> recent-tooltip-visible: false;
|
||||
private property <string> recent-tooltip-name: "";
|
||||
private property <string> recent-tooltip-host: "";
|
||||
@@ -410,6 +462,10 @@ export component MainWindow inherits Window {
|
||||
if root.wayland-notice-open { focus-sink.focus(); }
|
||||
}
|
||||
|
||||
changed messages-open => {
|
||||
if root.messages-open { focus-sink.focus(); }
|
||||
}
|
||||
|
||||
reset-remote-id => {
|
||||
root.remote-id-input = "";
|
||||
remote-id-editor.focus();
|
||||
@@ -623,6 +679,10 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
return accept;
|
||||
}
|
||||
if root.messages-open {
|
||||
if event.text == Key.Escape { root.messages-open = false; }
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
@@ -651,22 +711,80 @@ export component MainWindow inherits Window {
|
||||
icon-size: 14px;
|
||||
hover-background: #e1e5eb;
|
||||
pressed-background: #cbd1d9;
|
||||
tooltip: root.announcement-unread > 0
|
||||
? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "")
|
||||
: root.update-available ? UiStrings.update-available : "";
|
||||
z: 8;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
}
|
||||
if root.update-available && !root.menu-open && root.update-icon-blink-on: Text {
|
||||
x: menu-button.x + 16px;
|
||||
y: menu-button.y + 1px;
|
||||
// Unread messages take precedence so the menu has only one indicator.
|
||||
if root.update-available && root.announcement-unread <= 0: Rectangle {
|
||||
x: menu-button.x + menu-button.width - self.width + 4px;
|
||||
y: menu-button.y - 1px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
text: FontAwesomeIcons.circle-arrow-up;
|
||||
color: #24272d;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: white;
|
||||
background: #2563eb;
|
||||
opacity: root.menu-open || mod(animation-tick(), 2s) < 1s ? 1 : 0;
|
||||
z: 9;
|
||||
accessible-role: button;
|
||||
accessible-label: UiStrings.update-available;
|
||||
accessible-action-default => { root.menu-open = !root.menu-open; }
|
||||
// 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;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
}
|
||||
}
|
||||
if root.announcement-unread > 0: Rectangle {
|
||||
x: menu-button.x + menu-button.width - self.width + 4px;
|
||||
y: menu-button.y - 1px;
|
||||
width: max(16px, unread-badge-label.preferred-width + 8px);
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: white;
|
||||
background: #d92d20;
|
||||
z: 9;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.messages + " · " + MessageStrings.unread + " " + root.announcement-unread;
|
||||
accessible-action-default => { root.menu-open = !root.menu-open; }
|
||||
unread-badge-label := Text {
|
||||
text: root.announcement-unread-label;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
}
|
||||
}
|
||||
|
||||
// Local and remote desktop panels.
|
||||
@@ -896,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;
|
||||
@@ -1146,9 +1277,10 @@ export component MainWindow inherits Window {
|
||||
if root.menu-open: Rectangle {
|
||||
x: menu-button.x + menu-button.width - self.width;
|
||||
y: menu-button.y + menu-button.height + 2px;
|
||||
width: min(118px, max(menu-settings-label.preferred-width,
|
||||
menu-about-label.preferred-width + (root.update-available ? 25px : 0px)) + 28px);
|
||||
height: 64px;
|
||||
width: max(80px, max(menu-settings-label.preferred-width,
|
||||
max(menu-messages-label.preferred-width + (root.announcement-unread > 0 ? menu-message-badge.width + 8px : 0px),
|
||||
menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px))) + 24px);
|
||||
height: 93px;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
@@ -1169,7 +1301,7 @@ export component MainWindow inherits Window {
|
||||
accessible-label: UiStrings.settings;
|
||||
accessible-action-default => { settings-touch.clicked(); }
|
||||
menu-settings-label := Text {
|
||||
x: 9px;
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
text: UiStrings.settings;
|
||||
color: #2d3138;
|
||||
@@ -1181,6 +1313,47 @@ export component MainWindow inherits Window {
|
||||
clicked => { root.menu-open = false; root.settings-open = true; }
|
||||
}
|
||||
}
|
||||
menu-messages := Rectangle {
|
||||
height: 27px;
|
||||
background: messages-touch.pressed ? #d8dadd
|
||||
: messages-touch.has-hover ? #f0f1f2 : white;
|
||||
border-radius: 3px;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.messages + (root.announcement-unread > 0 ? " · " + MessageStrings.unread + " " + root.announcement-unread : "");
|
||||
accessible-action-default => { messages-touch.clicked(); }
|
||||
menu-messages-label := Text {
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
color: #2d3138;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
menu-message-badge := Rectangle {
|
||||
visible: root.announcement-unread > 0;
|
||||
x: parent.width - self.width - 7px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: max(18px, menu-message-count.preferred-width + 10px);
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #fee4e2;
|
||||
accessible-role: none;
|
||||
menu-message-count := Text {
|
||||
text: root.announcement-unread-label;
|
||||
font-size: 10px; font-weight: 600; color: #b42318;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
messages-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.menu-open = false;
|
||||
root.messages-open = true;
|
||||
root.refresh-announcements();
|
||||
}
|
||||
}
|
||||
}
|
||||
menu-about := Rectangle {
|
||||
height: 27px;
|
||||
background: about-touch.pressed ? #d8dadd
|
||||
@@ -1189,28 +1362,31 @@ export component MainWindow inherits Window {
|
||||
border-width: 0px;
|
||||
border-radius: 3px;
|
||||
accessible-role: button;
|
||||
accessible-label: UiStrings.about;
|
||||
accessible-label: UiStrings.about + (root.update-available ? " · " + UiStrings.update-available : "");
|
||||
accessible-action-default => { about-touch.clicked(); }
|
||||
menu-about-label := Text {
|
||||
x: 9px;
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
text: UiStrings.about;
|
||||
color: #2d3138;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if root.update-available && root.update-icon-blink-on: Text {
|
||||
x: menu-about-label.x + menu-about-label.preferred-width + 7px;
|
||||
width: 18px;
|
||||
height: parent.height;
|
||||
text: FontAwesomeIcons.circle-arrow-up;
|
||||
color: #2d3138;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
menu-update-badge := Rectangle {
|
||||
visible: root.update-available;
|
||||
x: parent.width - self.width - 7px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: menu-update-label.preferred-width + 12px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #dbeafe;
|
||||
accessible-role: none;
|
||||
menu-update-label := Text {
|
||||
text: UiStrings.new-version;
|
||||
font-size: 11px; font-weight: 400; color: #1d4ed8;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
about-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
@@ -1226,6 +1402,40 @@ export component MainWindow inherits Window {
|
||||
background: ImGuiLineStyle.separator;
|
||||
z: 1;
|
||||
}
|
||||
Rectangle {
|
||||
x: 4px;
|
||||
y: 60.5px;
|
||||
width: parent.width - 8px;
|
||||
height: 1px;
|
||||
background: ImGuiLineStyle.separator;
|
||||
z: 1;
|
||||
}
|
||||
}
|
||||
|
||||
if root.messages-open: Rectangle {
|
||||
background: #00000042;
|
||||
z: 40;
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
MessagePanel {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 520px;
|
||||
height: 330px;
|
||||
items: root.announcements;
|
||||
unread-count: root.announcement-unread; total: root.announcement-total;
|
||||
loading: root.announcement-loading; failed: root.announcement-failed; loaded: root.announcement-loaded;
|
||||
read-save-failed: root.announcement-read-save-failed;
|
||||
delete-failed: root.announcement-delete-failed;
|
||||
connected: root.signal-connected;
|
||||
selected-id: root.announcement-selected-id; selected-title: root.announcement-selected-title;
|
||||
selected-body: root.announcement-selected-body; selected-date: root.announcement-selected-date;
|
||||
selected-read: root.announcement-selected-read;
|
||||
open-message(id, revision) => { root.open-announcement(id, revision); }
|
||||
open-link(url) => { root.open-announcement-link(url); }
|
||||
delete-message(id, revision) => { root.delete-announcement(id, revision); }
|
||||
load-more => { root.load-more-announcements(); }
|
||||
close => { root.messages-open = false; }
|
||||
}
|
||||
}
|
||||
|
||||
copy-timer := Timer {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct AnnouncementItem {
|
||||
id: string,
|
||||
revision: string,
|
||||
title: string,
|
||||
date: string,
|
||||
read: bool,
|
||||
}
|
||||
|
||||
export global MessageStrings {
|
||||
in-out property <string> messages: "Messages";
|
||||
in-out property <string> unread: "Unread";
|
||||
in-out property <string> read: "Read";
|
||||
in-out property <string> empty: "No announcements";
|
||||
in-out property <string> select-message: "Select an announcement to read";
|
||||
in-out property <string> loading: "Loading…";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
|
||||
in-out property <string> close: "Close";
|
||||
in-out property <string> delete-message: "Delete";
|
||||
in-out property <string> delete-title: "Delete announcement";
|
||||
in-out property <string> delete-confirmation: "Delete this announcement?";
|
||||
in-out property <string> delete-error: "Unable to delete this announcement. Please try again.";
|
||||
in-out property <string> cancel: "Cancel";
|
||||
}
|
||||
|
||||
export component MessagePanel inherits DialogSurface {
|
||||
in property <[AnnouncementItem]> items;
|
||||
in property <int> unread-count;
|
||||
in property <int> total;
|
||||
in property <bool> loading;
|
||||
in property <bool> failed;
|
||||
in property <bool> read-save-failed;
|
||||
in property <bool> delete-failed;
|
||||
in property <bool> loaded;
|
||||
in property <bool> connected;
|
||||
in property <string> selected-id;
|
||||
in property <string> selected-title;
|
||||
in property <styled-text> selected-body;
|
||||
in property <string> selected-date;
|
||||
in property <bool> selected-read;
|
||||
callback open-message(string, string);
|
||||
callback open-link(string);
|
||||
callback delete-message(string, string);
|
||||
callback load-more;
|
||||
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: 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) {
|
||||
root.pending-delete-id = id;
|
||||
root.pending-delete-revision = revision;
|
||||
confirmation-keys.focus();
|
||||
}
|
||||
confirmation-keys := FocusScope {
|
||||
width: 0px; height: 0px;
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape {
|
||||
if root.pending-delete-id != "" { root.pending-delete-id = ""; }
|
||||
else { root.close(); }
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
background: white;
|
||||
border-radius: 7px;
|
||||
TouchArea { }
|
||||
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: 6px; width: parent.width - 100px; height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
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: (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: #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: #61656c; font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right; vertical-alignment: center;
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
|
||||
border-radius: 3px;
|
||||
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(); }
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
if root.notice-text != "": Rectangle {
|
||||
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: ImGuiFontStyle.body; overflow: elide;
|
||||
color: #b3261e;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
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: 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: 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 - 17px; y: 13px; width: 5px; height: 5px;
|
||||
border-radius: 2.5px; background: #2563eb;
|
||||
accessible-role: none;
|
||||
}
|
||||
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 - 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: 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: 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: ImGuiFontStyle.body;
|
||||
link-color: #2563eb;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
interval: 100ms;
|
||||
running: root.connected && !root.loading && !root.failed
|
||||
&& root.pending-delete-id == "" && root.items.length > 0
|
||||
&& root.items.length < root.total && announcements-scroll.viewport-height > 0px
|
||||
&& announcements-scroll.viewport-height + announcements-scroll.viewport-y <= announcements-scroll.height + 80px;
|
||||
triggered => { root.load-more(); }
|
||||
}
|
||||
if root.pending-delete-id != "": Rectangle {
|
||||
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: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
|
||||
HorizontalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
cancel-delete-button := CompactButton {
|
||||
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: MessageStrings.delete-message; primary: true;
|
||||
accessible-role: button; accessible-label: MessageStrings.delete-title;
|
||||
accessible-action-default => { confirm-delete-button.clicked(); }
|
||||
clicked => {
|
||||
root.delete-message(root.pending-delete-id, root.pending-delete-revision);
|
||||
root.pending-delete-id = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,21 @@ inline int ApplyMainWindowStrings(
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
auto& strings = window->global<ui::UiStrings>();
|
||||
auto& messages = window->global<ui::MessageStrings>();
|
||||
messages.set_messages(Text(localization::messages[language]));
|
||||
messages.set_unread(Text(localization::message_unread[language]));
|
||||
messages.set_read(Text(localization::message_read[language]));
|
||||
messages.set_empty(Text(localization::message_empty[language]));
|
||||
messages.set_select_message(Text(localization::message_select[language]));
|
||||
messages.set_loading(Text(localization::message_loading[language]));
|
||||
messages.set_read_save_error(Text(localization::message_read_save_error[language]));
|
||||
messages.set_close(Text(localization::message_close[language]));
|
||||
messages.set_delete_message(Text(localization::message_delete[language]));
|
||||
messages.set_delete_title(Text(localization::message_delete_title[language]));
|
||||
messages.set_delete_confirmation(Text(localization::message_delete_confirmation[language]));
|
||||
messages.set_delete_error(Text(localization::message_delete_error[language]));
|
||||
messages.set_cancel(Text(localization::cancel[language]));
|
||||
|
||||
strings.set_local_desktop(Text(localization::local_desktop[language]));
|
||||
strings.set_remote_desktop(Text(localization::remote_desktop[language]));
|
||||
strings.set_recent_connections(
|
||||
@@ -82,6 +97,7 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_tls_error(Text(localization::signal_tls_cert_error[language]));
|
||||
strings.set_update_available(
|
||||
Text(localization::new_version_available[language]));
|
||||
strings.set_new_version(Text(localization::new_version[language]));
|
||||
strings.set_check_for_updates(Text(localization::check_for_updates[language]));
|
||||
strings.set_checking_for_updates(
|
||||
Text(localization::checking_for_updates[language]));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 24d9d9deef...ee7eaff649
Reference in New Issue
Block a user