[feat] add DeskHub device wall and shared group actions

This commit is contained in:
dijunkun
2026-10-12 03:08:04 +08:00
parent 54dbd04a8d
commit 73b4d0f645
29 changed files with 3344 additions and 136 deletions
File diff suppressed because it is too large Load Diff
@@ -60,6 +60,18 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
void ChangeMainMode(bool gallery);
void ToggleGalleryFullscreen();
void RestoreMainWindowGeometry(bool before_show = false);
void SyncGallery();
void GalleryAction(const std::string& action, const std::string& value);
bool AddGalleryDevice(const std::string& id, const std::string& password,
const std::string& group, bool remember_password);
bool ImportGalleryRecent(const std::string& group);
void DeviceGroupAction(const std::string& action, const std::string& value);
bool SaveDeviceGroups(const std::string& new_group);
void ReleaseControl();
void SaveGalleryWindowState();
void SyncAnnouncements();
void SyncConnectionDialog();
void SyncPlatformDialogs();
@@ -46,7 +46,8 @@ void ClipboardController::Shutdown() {
pending_remote_text_.reset();
}
void ClipboardController::QueueRemoteText(const char *data, size_t size) {
void ClipboardController::QueueRemoteText(const char* data, size_t size,
const std::string& remote_id) {
if (!events_enabled_.load(std::memory_order_acquire)) {
return;
}
@@ -62,6 +63,11 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
return;
}
const auto session =
remote_id.empty() ? nullptr : owner_.FindRemoteSession(remote_id);
if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load()))
return;
const auto generation = session ? session->input_generation_.load() : 0;
{
std::lock_guard<std::mutex> lock(pending_mutex_);
if (!events_enabled_.load(std::memory_order_relaxed)) {
@@ -70,6 +76,9 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
// Only the newest clipboard value matters. Replacing it also bounds the
// amount of memory a busy or malicious peer can queue.
pending_remote_text_ = std::string(data, size);
pending_remote_id_ = remote_id;
pending_session_ = session;
pending_generation_ = generation;
}
SDL_Event event{};
@@ -87,9 +96,22 @@ void ClipboardController::ApplyPendingRemoteText() {
}
std::optional<std::string> pending_text;
std::string source_id;
std::weak_ptr<gui_detail::RemoteSession> source_session;
uint64_t generation = 0;
{
std::lock_guard<std::mutex> lock(pending_mutex_);
pending_text.swap(pending_remote_text_);
source_id.swap(pending_remote_id_);
source_session.swap(pending_session_);
generation = pending_generation_;
}
if (!source_id.empty()) {
auto props = owner_.FindRemoteSession(source_id);
if (!props || props != source_session.lock() ||
!props->clipboard_allowed_.load() ||
props->input_generation_.load() != generation)
return;
}
if (!pending_text || *pending_text == last_text_) {
return;
@@ -145,7 +167,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
std::shared_lock lock(owner_.remote_sessions_mutex_);
for (const auto &[remote_id, props] : owner_.remote_sessions_) {
if (!props || !props->peer_ || !props->connection_established_ ||
!props->enable_mouse_control_) {
!props->clipboard_allowed_.load()) {
continue;
}
@@ -10,10 +10,15 @@
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
namespace crossdesk::gui_detail {
struct RemoteSession;
}
namespace crossdesk {
class GuiRuntime;
@@ -29,7 +34,8 @@ public:
void Initialize();
void Shutdown();
void QueueRemoteText(const char *data, size_t size);
void QueueRemoteText(const char* data, size_t size,
const std::string& remote_id = {});
void ApplyPendingRemoteText();
void HandleLocalUpdate();
@@ -41,6 +47,9 @@ private:
std::atomic<bool> events_enabled_{false};
std::mutex pending_mutex_;
std::optional<std::string> pending_remote_text_;
std::string pending_remote_id_;
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
uint64_t pending_generation_ = 0;
std::string last_text_;
};
@@ -585,6 +585,10 @@ int SessionDeviceManager::DestroyAudioOutput() {
return 0;
}
void SessionDeviceManager::ClearAudio() {
if (output_stream_) SDL_ClearAudioStream(output_stream_);
}
void SessionDeviceManager::PushAudio(const char *data, size_t size) {
if (!output_stream_) {
return;
@@ -67,6 +67,7 @@ public:
int InitializeAudioOutput();
int DestroyAudioOutput();
void ClearAudio();
void PushAudio(const char *data, size_t size);
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
@@ -0,0 +1,102 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_FULLSCREEN_TRANSITION_H_
#define _GALLERY_FULLSCREEN_TRANSITION_H_
#include <chrono>
#include <cstdint>
namespace crossdesk::gallery {
// Driven by the existing UI tick; never pumps events or creates nested timers.
class FullscreenTransition {
public:
using Clock = std::chrono::steady_clock;
enum class Action { Idle, Apply, Reveal, Finished };
bool Begin(bool fullscreen, Clock::time_point now) {
if (Active())
return false;
target_ = fullscreen;
phase_ = Phase::FadingOut;
phase_at_ = now;
width_ = height_ = 0;
scale_ = 0;
geometry_changed_ = false;
return true;
}
Action Advance(Clock::time_point now, uint32_t width, uint32_t height,
float scale, bool fullscreen, bool restoring) {
using namespace std::chrono_literals;
if (phase_ == Phase::FadingOut) {
if (restoring && now - phase_at_ >= 1500ms) {
Reveal(now);
return Action::Reveal;
}
// Slint fades out for 60ms; allow another frame before native resizing.
if (now - phase_at_ < 80ms || restoring)
return Action::Idle;
phase_ = Phase::Settling;
phase_at_ = stable_at_ = now;
width_ = width;
height_ = height;
scale_ = scale;
return Action::Apply;
}
if (phase_ == Phase::Settling) {
const bool changed =
width != width_ || height != height_ || scale != scale_;
if (changed && width > 0 && height > 0)
geometry_changed_ = true;
if (changed || fullscreen != target_ || restoring) {
width_ = width;
height_ = height;
scale_ = scale;
stable_at_ = now;
}
// Some backends report the requested flag before delivering new bounds.
// Also allow fullscreen modes whose client dimensions genuinely match.
if ((width > 0 && height > 0 && fullscreen == target_ && !restoring &&
(geometry_changed_ || now - phase_at_ >= 300ms) &&
now - stable_at_ >= 32ms) ||
now - phase_at_ >= 1500ms) {
Reveal(now);
return Action::Reveal;
}
}
if (phase_ == Phase::FadingIn && now - phase_at_ >= 80ms) {
Cancel();
return Action::Finished;
}
return Action::Idle;
}
void Reveal(Clock::time_point now) {
phase_ = Phase::FadingIn;
phase_at_ = now;
}
void Cancel() { phase_ = Phase::Idle; }
bool Active() const { return phase_ != Phase::Idle; }
bool Target() const { return target_; }
bool ContentVisible() const {
return phase_ == Phase::Idle || phase_ == Phase::FadingIn;
}
private:
enum class Phase { Idle, FadingOut, Settling, FadingIn };
Phase phase_ = Phase::Idle;
bool target_ = false;
bool geometry_changed_ = false;
uint32_t width_ = 0, height_ = 0;
float scale_ = 0;
Clock::time_point phase_at_{}, stable_at_{};
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,37 @@
#include "gallery_storage.h"
#include <fstream>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace crossdesk::gallery {
bool WriteAtomically(const std::filesystem::path &path,
const std::string &text) {
auto temporary = path;
temporary += ".tmp";
try {
{
std::ofstream output;
output.exceptions(std::ios::failbit | std::ios::badbit);
output.open(temporary, std::ios::binary | std::ios::trunc);
output.write(text.data(), text.size());
output.close();
}
#ifdef _WIN32
if (MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
return true;
#else
std::filesystem::rename(temporary, path);
return true;
#endif
} catch (...) {
}
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
return false;
}
} // namespace crossdesk::gallery
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_STORAGE_H_
#define _GALLERY_STORAGE_H_
#include <filesystem>
#include <string>
namespace crossdesk::gallery {
bool WriteAtomically(const std::filesystem::path &path,
const std::string &text);
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,193 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_WINDOW_STATE_H_
#define _GALLERY_WINDOW_STATE_H_
#include <slint.h>
#include <algorithm>
#include <cmath>
#include <optional>
namespace crossdesk::gallery {
struct WindowState {
bool wall_fullscreen = false;
bool wall_maximized = false;
// Store client dimensions in logical pixels, independent of the display DPI.
std::optional<slint::LogicalSize> wall_size;
std::optional<slint::PhysicalPosition> wall_position, connection_position;
void BeginModeChange(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486) {
// Never capture a half-applied mode transition as the user's wall geometry.
Observe(!wall, position, size, scale, fullscreen, maximized,
compact_height);
if (wall) {
// Modes share the current desktop location. An old wall position must
// not undo a move made while the connection page was visible.
if (connection_position)
wall_position = connection_position;
} else {
// Fullscreen/maximized coordinates describe the screen, not the ordinary
// window. Return to the last normal anchor before restoring compact size.
if (wall_position)
connection_position = wall_position;
}
BeginRestore();
}
void Observe(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486) {
if (Restoring())
return;
if (!wall) {
connection_position = position;
return;
}
if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height))
return;
scale = std::max(scale, 1.0f);
wall_fullscreen = fullscreen;
wall_maximized = !fullscreen && maximized;
if (!fullscreen && !maximized) {
wall_size = slint::LogicalSize(
slint::Size<float>{size.width / scale, size.height / scale});
wall_position = position;
}
}
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool visible,
bool minimized, bool fullscreen, bool maximized,
float compact_height = 486) {
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
// Leave those alone, and let an in-flight restoration retain its target.
if (Restoring() || !visible || minimized || fullscreen || maximized ||
size.width == 0 || size.height == 0 ||
!IsUndersized(size, scale, compact_height))
return false;
if (wall) {
wall_position = position;
wall_fullscreen = false;
wall_maximized = false;
} else {
connection_position = position;
}
// Keep the last valid logical size; only the current anchor is captured.
BeginRestore();
return true;
}
slint::PhysicalSize RestoreSize(bool wall, float scale,
float compact_height = 486) const {
const float width =
wall && wall_size ? std::max(640.0f, wall_size->width) : 640;
const float height = wall && wall_size
? std::max(compact_height, wall_size->height)
: compact_height;
scale = std::max(scale, 1.0f);
return slint::PhysicalSize(
slint::Size<uint32_t>{uint32_t(std::lround(width * scale)),
uint32_t(std::lround(height * scale))});
}
void BeginRestore() {
phase_ = Phase::Normal;
target_.reset();
last_size_.reset();
matches_ = 0;
}
void PrepareForShow(slint::Window &window, bool wall,
slint::PhysicalSize size,
std::optional<slint::PhysicalPosition> position,
float initial_y_offset = 0) {
const float scale = std::max(window.scale_factor(), 1.0f);
// The actual display scale is resolved when the native window is created.
// Give the backend logical geometry so its first visible frame is correct.
window.set_size(slint::LogicalSize(
slint::Size<float>{size.width / scale, size.height / scale}));
if (position) {
(wall ? wall_position : connection_position) = position;
window.set_position(slint::LogicalPosition(slint::Point<float>{
position->x / scale, position->y / scale + initial_y_offset}));
}
}
bool Restoring() const { return phase_ != Phase::Idle; }
bool RestoringNormal() const { return phase_ == Phase::Normal; }
// A set_size request can complete asynchronously, after layout constraints
// and the backing scale have changed. Acknowledge observations on later
// ticks.
bool NormalSizeRestored(slint::PhysicalSize actual,
slint::PhysicalSize target) {
if (!RestoringNormal())
return false;
if (target_ && SameSize(*target_, target) && SameSize(actual, target))
++matches_;
else
matches_ = 0;
target_ = target;
if (matches_ < 2)
return false;
phase_ = Phase::Presentation;
matches_ = 0;
last_size_.reset();
return true;
}
bool FinishRestore(bool wall, slint::PhysicalSize actual, bool fullscreen,
bool maximized) {
if (phase_ != Phase::Presentation)
return false;
const bool want_fullscreen = wall && wall_fullscreen;
const bool want_maximized = wall && !want_fullscreen && wall_maximized;
const bool normal = !want_fullscreen && !want_maximized;
if (normal && !fullscreen && !maximized && target_ &&
!SameSize(actual, *target_)) {
// A late constraint/DPI update can invalidate an earlier acknowledgement.
// Reissue the normal size instead of getting stuck or saving that size.
BeginRestore();
return false;
}
const bool ready = fullscreen == want_fullscreen &&
(want_fullscreen || maximized == want_maximized) &&
(!normal || (target_ && SameSize(actual, *target_)));
if (ready && last_size_ && SameSize(actual, *last_size_))
++matches_;
else
matches_ = 0;
last_size_ = actual;
if (matches_ < 2)
return false;
phase_ = Phase::Idle;
return true;
}
private:
enum class Phase { Idle, Normal, Presentation };
Phase phase_ = Phase::Idle;
std::optional<slint::PhysicalSize> target_, last_size_;
int matches_ = 0;
static bool IsUndersized(slint::PhysicalSize size, float scale,
float compact_height) {
scale = std::max(scale, 1.0f);
return double(size.width) + 1 < 640 * scale ||
double(size.height) + 1 < compact_height * scale;
}
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
return std::abs(double(a.width) - b.width) <= 1 &&
std::abs(double(a.height) - b.height) <= 1;
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,252 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_WORKSPACE_H_
#define _GALLERY_WORKSPACE_H_
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "gallery_storage.h"
namespace crossdesk::gallery {
struct Member {
std::string id;
std::string group;
};
// Layout contains no credentials and never reconnects devices when loaded.
struct Workspace {
// Group assignments are shared by history and the wall. Wall enrollment is
// independent, so assigning a group alone must never add a device to it.
std::vector<Member> members;
std::vector<std::string> groups;
std::set<std::string> selected;
int page = 0;
int capacity = 6;
static std::string NormalizeId(const std::string &input) {
std::string result;
for (size_t i = 0; i < input.size();) {
const auto ch = static_cast<unsigned char>(input[i]);
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
++i;
continue;
}
if (i + 2 < input.size() && ch == 0xEF &&
static_cast<unsigned char>(input[i + 1]) == 0xBC &&
static_cast<unsigned char>(input[i + 2]) >= 0x90 &&
static_cast<unsigned char>(input[i + 2]) <= 0x99) {
result += char('0' + static_cast<unsigned char>(input[i + 2]) - 0x90);
i += 3;
} else if (input.compare(i, 3, "\xe3\x80\x80") == 0)
i += 3;
else
result += input[i++];
}
return result;
}
static bool ValidId(const std::string &id) {
return id.size() == 9 &&
std::all_of(id.begin(), id.end(),
[](unsigned char c) { return c >= '0' && c <= '9'; });
}
bool Add(const std::string &id, const std::string &group = {}) {
if (!ValidId(id))
return false;
const bool joined = wall_ids_.insert(id).second;
// A device grouped earlier in history joins at the end when explicitly
// added, while retaining its existing group assignments.
if (joined)
std::stable_partition(members.begin(), members.end(),
[&](const auto &m) { return m.id != id; });
if (group.empty() && HasAssignments(id))
return joined;
return AddAssignment(id, group) || joined;
}
std::vector<std::string> Ids(const std::string &group = {}) const {
std::vector<std::string> ids;
std::set<std::string> seen;
for (const auto &m : members)
if (wall_ids_.count(m.id) && (group.empty() || m.group == group) &&
seen.insert(m.id).second)
ids.push_back(m.id);
return ids;
}
// History filtering uses group assignments, independently of wall enrollment.
// nullopt means all devices; an empty name means no named group.
bool MatchesGroup(const std::string &id,
const std::optional<std::string> &group) const {
if (!group)
return true;
if (group->empty())
return std::none_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && !m.group.empty();
});
return std::any_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && m.group == *group;
});
}
size_t AddMany(const std::vector<std::string> &ids,
const std::string &group = {}) {
size_t added = 0;
for (const auto &id : ids) {
// Without a target group, an existing membership already puts the device
// on the wall. An explicit group may reuse that same device/session.
if (group.empty() && wall_ids_.count(id))
continue;
if (Add(id, group))
++added;
}
return added;
}
// Both editors replace the same group assignments without changing wall
// enrollment. Keep the position of devices already on the wall untouched.
bool SetGroups(const std::string &id, const std::vector<std::string> &names) {
if (!ValidId(id))
return false;
std::vector<Member> replacements;
std::set<std::string> seen;
for (const auto &name : names)
if (!name.empty() && seen.insert(name).second)
replacements.push_back({id, name});
if (replacements.empty())
replacements.push_back({id, ""});
std::vector<Member> updated;
bool inserted = false;
for (const auto &member : members) {
if (member.id != id) {
updated.push_back(member);
} else if (!inserted) {
updated.insert(updated.end(), replacements.begin(), replacements.end());
inserted = true;
}
}
if (!inserted)
updated.insert(updated.end(), replacements.begin(), replacements.end());
members = std::move(updated);
for (const auto &member : replacements)
if (!member.group.empty() &&
std::find(groups.begin(), groups.end(), member.group) == groups.end())
groups.push_back(member.group);
PruneGroups();
return true;
}
void Remove(const std::string &id, const std::string &group = {}) {
if (group.empty()) {
// Removing from the wall leaves history's group assignments intact.
wall_ids_.erase(id);
} else if (MatchesGroup(id, group)) {
// Removing the last named group leaves an ungrouped wall member.
// Reuse the editor path so enrollment and card order stay unchanged.
std::vector<std::string> remaining;
for (const auto &member : members)
if (member.id == id && member.group != group)
remaining.push_back(member.group);
SetGroups(id, remaining);
}
selected.erase(id);
}
void ResizePage(int new_capacity, int count) {
const int first = page * capacity;
capacity = std::max(1, new_capacity);
page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity));
}
// The caller supplies every ID in the current filter, across all pages.
void ToggleSelectAll(const std::vector<std::string> &ids) {
std::set<std::string> targets(ids.begin(), ids.end());
if (selected == targets)
selected.clear();
else
selected = std::move(targets);
}
bool Load(const std::filesystem::path &path) {
try {
if (!std::filesystem::exists(path))
return true;
std::ifstream file(path);
const auto doc = nlohmann::json::parse(file);
const int version = doc.at("version").get<int>();
if ((version != 1 && version != 2) || !doc.at("members").is_array())
return false;
if (version == 2 && !doc.at("wall_ids").is_array())
return false;
Workspace loaded;
for (const auto &member : doc.at("members")) {
const auto id = member.at("id").get<std::string>();
loaded.AddAssignment(id, member.value("group", ""));
// Every saved member in the old format was already on the wall.
if (version == 1 && ValidId(id))
loaded.wall_ids_.insert(id);
}
if (version == 2)
for (const auto &value : doc.at("wall_ids")) {
const auto id = value.get<std::string>();
if (ValidId(id) && loaded.HasAssignments(id))
loaded.wall_ids_.insert(id);
}
members = std::move(loaded.members);
groups = std::move(loaded.groups);
wall_ids_ = std::move(loaded.wall_ids_);
return true;
} catch (...) {
return false;
}
}
bool Save(const std::filesystem::path &path) const {
try {
nlohmann::json doc{{"version", 2},
{"members", nlohmann::json::array()},
{"wall_ids", wall_ids_}};
for (const auto &m : members)
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
return WriteAtomically(path, doc.dump(2));
} catch (...) {
return false;
}
}
private:
std::set<std::string> wall_ids_;
bool HasAssignments(const std::string &id) const {
return std::any_of(members.begin(), members.end(),
[&](const auto &m) { return m.id == id; });
}
bool AddAssignment(const std::string &id, const std::string &group) {
if (!ValidId(id))
return false;
if (std::any_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && m.group == group;
}))
return false;
if (!group.empty() &&
std::find(groups.begin(), groups.end(), group) == groups.end())
groups.push_back(group);
members.push_back({id, group});
return true;
}
void PruneGroups() {
groups.erase(std::remove_if(groups.begin(), groups.end(),
[&](const auto &name) {
return std::none_of(members.begin(),
members.end(),
[&](const auto &m) {
return m.group == name;
});
}),
groups.end());
}
};
} // namespace crossdesk::gallery
#endif
@@ -139,11 +139,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
std::string remote_user_id(user_id, user_id_size);
auto props =
runtime->FindRemoteSession(remote_user_id);
if (props && !props->enable_mouse_control_) {
if (props && !props->clipboard_allowed_.load()) {
return;
}
runtime->clipboard_.QueueRemoteText(data, size);
runtime->clipboard_.QueueRemoteText(
data, size, props ? remote_user_id : std::string{});
}
return;
} else if (source_id == runtime->file_feedback_label_) {
@@ -329,6 +329,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
props->net_traffic_stats_.Reset();
props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false;
props->audio_allowed_ = false;
props->clipboard_allowed_ = false;
runtime->ResetRemoteServiceStatus(*props);
{
std::lock_guard lock(props->video_settings_mutex_);
@@ -1,3 +1,6 @@
#include <libyuv.h>
#include <algorithm>
#include <chrono>
#include <cstring>
#include <memory>
@@ -5,6 +8,7 @@
#include <string>
#include <vector>
#include "nv12_scaler.h"
#include "platform/video_renderer.h"
#include "runtime/gui_runtime.h"
#include "runtime/peer_event_handler.h"
@@ -46,6 +50,68 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
// Convert only visible thumbnails, off the UI thread. A session retains
// one latest RGBA image, not an unbounded queue of frames.
const int preview_width = props->gallery_preview_width_.load();
// Multiple video streams may decode concurrently for the same session.
// Serialize access to the shared conversion buffers. Visible previews
// consume incoming frames at the negotiated interactive cadence.
std::unique_lock conversion_lock(props->gallery_conversion_mutex_,
std::defer_lock);
if (preview_width > 0) conversion_lock.lock();
if (conversion_lock.owns_lock()) {
const auto* native = GetNativeVideoFrame(video_frame);
const int w = native ? native->width : video_frame->width;
const int h = native ? native->height : video_frame->height;
if (w > 0 && h > 0 && w <= 16384 && h <= 16384 && !(w % 2) && !(h % 2)) {
const size_t bytes = static_cast<size_t>(w) * h * 3 / 2;
const uint8_t* data =
reinterpret_cast<const uint8_t*>(video_frame->data);
if (native) {
props->gallery_nv12_.resize(bytes);
data = native->copy_to_nv12(native->owner,
props->gallery_nv12_.data(), bytes) == 0
? props->gallery_nv12_.data()
: nullptr;
} else if (video_frame->size < bytes)
data = nullptr;
if (data) {
const double scale = std::min(
{1.0, double(preview_width) / w, double(preview_width) / h});
const int dw = std::max(2, int(w * scale) & ~1);
const int dh = std::max(2, int(h * scale) & ~1);
auto& scaled = props->gallery_scaled_;
scaled.resize(static_cast<size_t>(dw) * dh * 3 / 2);
if (ScaleNv12ViaI420(data, w, data + w * h, w, w, h, scaled.data(),
dw, scaled.data() + dw * dh, dw, dw, dh,
libyuv::kFilterBilinear,
&props->gallery_scratch_) == 0) {
auto rgba = std::make_shared<std::vector<unsigned char>>(
static_cast<size_t>(dw) * dh * 4);
if (libyuv::NV12ToABGR(scaled.data(), dw, scaled.data() + dw * dh,
dw, rgba->data(), dw * 4, dw, dh) == 0) {
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_rgba_ = std::move(rgba);
props->gallery_width_ = dw;
props->gallery_height_ = dh;
props->gallery_frame_at_ = timing_now;
++props->gallery_sequence_;
}
}
}
}
}
if (conversion_lock.owns_lock()) conversion_lock.unlock();
// A wall-only session does not need a second full-resolution CPU buffer.
if (props->wall_managed_ && !props->tab_opened_) {
{
std::lock_guard lock(props->video_frame_mutex_);
props->video_width_ = video_frame->width;
props->video_height_ = video_frame->height;
}
props->streaming_ = true;
return;
}
const auto timing = props->video_latency_->Frame(
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_),
timing_now);
@@ -205,6 +271,10 @@ void PeerEventHandler::OnReceiveAudioBuffer(
return;
}
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
if (!session || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true;
runtime->devices_.PushAudio(data, size);
@@ -79,6 +79,18 @@ struct RemoteSession {
uint32_t preview_request_ = 0;
PreviewSubscription preview_requested_;
std::chrono::steady_clock::time_point preview_request_at_{};
std::atomic<bool> clipboard_allowed_{false};
std::atomic<uint64_t> input_generation_{0};
std::atomic<bool> audio_allowed_{false};
std::atomic<int> gallery_preview_width_{0};
std::mutex gallery_frame_mutex_;
std::shared_ptr<std::vector<unsigned char>> gallery_rgba_;
int gallery_width_ = 0, gallery_height_ = 0;
uint64_t gallery_sequence_ = 0;
std::chrono::steady_clock::time_point gallery_frame_at_{};
// Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
Params params_;
PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_;
+4 -3
View File
@@ -7,6 +7,8 @@
#ifndef _RUNTIME_STATE_H_
#define _RUNTIME_STATE_H_
#include <stream_names.h>
#include <atomic>
#include <chrono>
#include <cstdint>
@@ -19,10 +21,8 @@
#include <utility>
#include <vector>
#include <stream_names.h>
#include "config_center.h"
#include "common/task_queue_lock_free.h"
#include "config_center.h"
#include "path_manager.h"
#include "runtime/device_presence_cache.h"
#include "runtime/preview_subscriptions.h"
@@ -38,6 +38,7 @@ namespace crossdesk::gui_detail {
struct InfrastructureState {
PreviewSubscriptions preview_subscriptions_;
std::mutex audio_delivery_mutex_;
std::unique_ptr<ConfigCenter> config_center_;
ConfigCenter::LANGUAGE localization_language_ =
ConfigCenter::LANGUAGE::CHINESE;
+3 -1
View File
@@ -49,6 +49,7 @@ export global FontAwesomeIcons {
out property <string> signal: "\u{f012}";
out property <string> shield-halved: "\u{f3ed}";
out property <string> square-full: "\u{f45c}";
out property <string> tags: "\u{f02c}";
out property <string> trash-can: "\u{f2ed}";
out property <string> user-secret: "\u{f21b}";
out property <string> volume-high: "\u{f028}";
@@ -59,6 +60,7 @@ export global FontAwesomeIcons {
export component IconButton inherits Rectangle {
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-above: false;
in property <bool> enabled: true;
in property <bool> danger: false;
in property <length> icon-size: 16px;
@@ -103,7 +105,7 @@ export component IconButton inherits Rectangle {
if touch.has-hover && root.tooltip != "": Rectangle {
x: min(root.width - self.width, 0px);
y: root.height + 3px;
y: root.tooltip-above ? -self.height - 3px : root.height + 3px;
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
@@ -4,3 +4,7 @@ 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, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
export { GalleryStrings } from "device_strings.slint";
export { DeviceGroupOption } from "device_group_dialog.slint";
@@ -0,0 +1,54 @@
import { IconButton, FontAwesomeIcons } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export global DeviceCardStyle {
out property <length> toolbar-width: 104px;
}
component CardAction inherits IconButton {
width: 26px; height: 23px;
icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
tooltip-above: true;
}
// Shared by recent connections and DeskHub; callers supply only behavior.
export component DeviceCardActions inherits Rectangle {
in property <string> remove-label;
in property <bool> connected: false;
callback edit-alias();
callback edit-groups();
callback remove-device();
callback connect-device();
callback disconnect-device();
width: DeviceCardStyle.toolbar-width; height: 23px;
background: #4a4e55dc; border-radius: 5px;
HorizontalLayout {
spacing: 0px;
CardAction {
icon: FontAwesomeIcons.pen;
tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать");
clicked => { root.edit-alias(); }
}
CardAction {
icon: FontAwesomeIcons.tags;
tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
clicked => { root.edit-groups(); }
}
CardAction {
icon: FontAwesomeIcons.trash-can;
tooltip: root.remove-label;
clicked => { root.remove-device(); }
}
CardAction {
icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
icon-size: 13px;
tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить");
clicked => {
if root.connected { root.disconnect-device(); }
else { root.connect-device(); }
}
}
}
}
@@ -0,0 +1,87 @@
import { ScrollView, LineEdit } from "std-widgets.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption {
name: string,
selected: bool,
}
export component DeviceGroupDialog inherits Rectangle {
in property <string> device-name;
in property <string> device-id;
in property <[DeviceGroupOption]> options;
in property <string> error;
in-out property <string> new-group;
callback toggle-group(string);
callback save(string);
callback cancel();
background: #00000040;
TouchArea {}
dialog := Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: 8px;
border-width: 1px; border-color: #b2b2b2;
TouchArea {}
Text {
x: 18px; y: 16px; height: 24px;
text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
color: #24272d; font-size: 17px; font-weight: 600;
}
Text {
x: 18px; y: 47px; width: parent.width - 36px; height: 18px;
text: root.device-name; color: #30343b; font-size: 12px; overflow: elide;
}
Text {
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
}
group-list := Rectangle {
x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px;
ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width;
VerticalLayout {
spacing: 0px;
for option[index] in root.options: Rectangle {
height: 32px;
background: row-touch.has-hover ? #eef4fc : transparent;
ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == "");
}
Text {
x: 39px; width: parent.width - 48px; height: parent.height;
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
}
}
}
}
LineEdit {
x: 18px; y: group-list.y + group-list.height + 12px;
width: parent.width - 36px; height: 30px;
text <=> root.new-group;
placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)");
}
if root.error != "": Text {
x: 18px; y: group-list.y + group-list.height + 48px;
width: parent.width - 36px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
HorizontalLayout {
x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px;
spacing: 10px;
Rectangle { horizontal-stretch: 1; }
CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } }
CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } }
}
}
}
@@ -0,0 +1,6 @@
export global GalleryStrings {
in-out property <int> language: 0;
public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en;
}
}
+454
View File
@@ -0,0 +1,454 @@
import { GalleryStrings } from "device_strings.slint";
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
import { GroupFilter } from "group_filter.slint";
import { ScrollView, LineEdit } from "std-widgets.slint";
import { CompactButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
export struct GalleryTile {
id: string,
name: string,
status: string,
connected: bool,
online: bool,
presence-known: bool,
stale: bool,
selected: bool,
has-frame: bool,
frame: image,
}
export struct GalleryRecentDevice {
id: string,
name: string,
selected: bool,
online: bool,
}
export struct GalleryActionDevice {
id: string,
name: string,
connected: bool,
}
export component GalleryView inherits Rectangle {
in property <bool> controlling: false;
// Pointer mapping must use the exact image viewport, including its inset.
out property <float> video-width: video.width / 1px;
out property <float> video-height: video.height / 1px;
private property <bool> vertical-rail: focus-view.width > 840px;
private property <int> rail-current-index: 0;
changed rail-current-index => { root.reveal-current(); }
changed width => { root.reveal-current(); }
changed height => { root.reveal-current(); }
function reveal-current() {
if root.vertical-rail {
rail.viewport-x = 0px;
let top = root.rail-current-index * 100px;
rail.viewport-y = clamp(rail.viewport-y, -top, rail.height - top - 92px);
rail.viewport-y = clamp(rail.viewport-y, min(0px, rail.height - rail.viewport-height), 0px);
} else {
rail.viewport-y = 0px;
let left = root.rail-current-index * 144px;
rail.viewport-x = clamp(rail.viewport-x, -left, rail.width - left - 136px);
rail.viewport-x = clamp(rail.viewport-x, min(0px, rail.width - rail.viewport-width), 0px);
}
}
callback pointer-input(PointerEventButton, PointerEventKind, length, length);
callback scroll-input(length, length, length, length);
callback key-input(string, bool, bool, bool, bool, bool);
in property <[GalleryTile]> tiles;
in property <[string]> groups;
in-out property <int> group-index: 0;
in property <int> columns: 3;
in property <int> rows: 2;
in property <int> page: 0;
in property <int> pages: 1;
in property <int> count: 0;
in property <int> connected-count: 0;
in property <int> selected-count: 0;
in property <int> hidden-selected: 0;
in property <string> notice;
in property <GalleryTile> focused;
in-out property <bool> batch: false;
in-out property <bool> adding: false;
in-out property <bool> importing: false;
in property <[GalleryRecentDevice]> recent-devices;
in property <int> recent-selected: 0;
in property <string> import-group;
private property <bool> confirming: false;
private property <string> action-name;
private property <string> action-code;
in property <[GalleryActionDevice]> selected-devices;
callback action(string, string);
callback add-device(string, string, string, bool) -> bool;
callback import-recent(string) -> bool;
callback edit-alias(string, string);
callback edit-groups(string, string);
callback remove-device(string);
background: white;
changed adding => { if root.adding { root.action("release", ""); } }
changed importing => { root.action(root.importing ? "release" : "recent-close", ""); }
public function reset-transient() { root.batch = false; root.adding = false; root.importing = false; root.confirming = false; }
function confirm-action(code: string, name: string) {
root.action-code = code; root.action-name = name; root.confirming = true;
}
toolbar := HorizontalLayout {
x: 29px; y: 12px; width: parent.width - 58px; height: 24px;
visible: root.focused.id == "";
spacing: 8px;
GroupFilter { width: 155px; height: 24px; system-count: 1; model: root.groups; current-index <=> root.group-index;
selected(index) => { root.action("group", index); }
}
Rectangle { horizontal-stretch: 1; }
CompactButton { width: 82px; primary: root.batch; text: root.batch ? GalleryStrings.tr("完成", "Done", "Готово") : GalleryStrings.tr("批量选择", "Select", "Выбрать"); clicked => { root.batch = !root.batch; root.action("selection", ""); } }
CompactButton { width: 116px; text: GalleryStrings.tr("从最近连接添加", "Add from recent", "Из недавних"); clicked => { root.action("recent-open", ""); root.importing = true; } }
CompactButton { width: 90px; primary: true; text: GalleryStrings.tr("添加设备", "Add device", "Добавить"); clicked => { root.action("add-open", ""); root.adding = true; } }
}
grid := Rectangle {
x: toolbar.x; y: toolbar.y + toolbar.height + 12px; width: toolbar.width;
height: footer.y - 12px - self.y;
visible: root.focused.id == "";
for tile[index] in root.tiles: Rectangle {
x: mod(index, root.columns) * (grid.width + 12px) / root.columns;
y: floor(index / root.columns) * (grid.height + 12px) / root.rows;
width: (grid.width - (root.columns - 1) * 12px) / root.columns;
height: (grid.height - (root.rows - 1) * 12px) / root.rows;
border-radius: 6px; background: tile.selected ? #eaf3ff : #eff0f2;
border-width: tile.selected ? 2px : 1px;
border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : #c9cdd2;
Rectangle {
x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 36px;
background: #24272d;
if tile.has-frame: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; }
if !tile.has-frame || !tile.connected || tile.stale: Text {
text: tile.status; color: #ffffff; font-size: 12px;
horizontal-alignment: center; vertical-alignment: center;
wrap: word-wrap;
}
}
Text { x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
presence-badge := Rectangle {
visible: root.batch || !tile-touch.has-hover;
x: parent.width - (root.batch ? 35px : 9px) - self.width; y: parent.height - 23px;
width: presence-label.preferred-width + 12px; height: 18px;
border-radius: 9px; background: tile.online ? #eaf6ee : #e2e5e9;
presence-label := Text {
x: 6px; height: parent.height;
text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
color: tile.online ? #17854c : #62666d; font-size: 10px; vertical-alignment: center;
}
}
if root.batch: Rectangle {
x: parent.width - 27px; y: parent.height - 23px;
width: 18px; height: 18px; border-radius: 5px; border-width: 1px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
animate background, border-color { duration: 120ms; }
if tile.selected: Text {
text: FontAwesomeIcons.check; color: white;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
horizontal-alignment: center; vertical-alignment: center;
}
}
tile-touch := TouchArea {
clicked => { root.action(root.batch ? "select" : "focus", tile.id); }
if tile-touch.has-hover && !root.batch: DeviceCardActions {
x: parent.width - self.width - 6px; y: parent.height - 26px;
remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub");
connected: tile.connected;
edit-alias => { root.action("release", ""); root.edit-alias(tile.id, tile.name); }
edit-groups => { root.edit-groups(tile.id, tile.name); }
remove-device => { root.action("release", ""); root.remove-device(tile.id); }
connect-device => { root.action("connect", tile.id); }
disconnect-device => { root.action("disconnect", tile.id); }
}
}
}
if root.count == 0: Text {
text: GalleryStrings.tr("添加设备,建立你的桌面墙", "Add devices to build your DeskHub", "Добавьте устройства в DeskHub");
color: #808080; horizontal-alignment: center; vertical-alignment: center;
}
}
focus-view := Rectangle {
visible: root.focused.id != "";
x: 29px; y: 7px; width: parent.width - 58px;
height: parent.height - 51px;
HorizontalLayout {
x: 0px; y: 0px; width: parent.width; height: 28px; spacing: 8px;
CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
Rectangle {
horizontal-stretch: 1; min-width: 0px;
Text { x: 0px; width: parent.width; height: parent.height; text: root.focused.name; color: #303943; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; }
}
Rectangle {
width: min(focus-state.preferred-width + 16px, 110px); height: 22px;
border-radius: 5px;
background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height;
text: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
: root.focused.connected && root.focused.has-frame && !root.focused.stale
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
font-size: 10px; color: root.controlling ? #3e77b3 : #6b7480; vertical-alignment: center; overflow: elide;
}
}
CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
}
Rectangle {
x: 0px; y: 36px; width: root.vertical-rail ? parent.width - 158px : parent.width;
height: root.vertical-rail ? parent.height - 36px : parent.height - 152px;
background: #eff1f4; border-radius: 6px;
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : #d5dbe3;
video := Rectangle {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
background: #24272d;
if root.focused.has-frame: Image {
width: parent.width; height: parent.height; source: root.focused.frame; image-fit: contain;
opacity: root.focused.stale || !root.focused.connected ? 0.4 : 1;
}
video-focus := FocusScope {
property <bool> active-control: root.controlling;
changed active-control => { if self.active-control { self.focus(); } }
width: parent.width; height: parent.height;
focus-on-click: true;
focus-lost => { if root.controlling { root.action("release", ""); } }
key-pressed(event) => {
if root.controlling {
if event.text == Key.Escape && event.modifiers.shift && (event.modifiers.control || event.modifiers.meta) {
root.action("release", "");
} else { root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); }
}
accept
}
key-released(event) => {
if root.controlling { root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); }
accept
}
TouchArea {
pointer-event(event) => {
if event.kind == PointerEventKind.down { video-focus.focus(); }
if root.controlling { root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); }
}
scroll-event(event) => {
if root.controlling { root.scroll-input(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); }
accept
}
}
}
if !root.focused.has-frame || root.focused.stale || !root.focused.connected: Text { text: root.focused.status; color: white; horizontal-alignment: center; vertical-alignment: center; }
}
}
rail := ScrollView {
x: root.vertical-rail ? parent.width - 148px : 0px;
y: root.vertical-rail ? 36px : parent.height - 106px;
width: root.vertical-rail ? 148px : parent.width;
height: root.vertical-rail ? parent.height - 36px : 106px;
// Reserve 14px below or 12px beside cards for the scrollbar.
viewport-width: root.vertical-rail ? self.width : max(self.width, root.tiles.length * 144px - 8px);
viewport-height: root.vertical-rail ? max(self.height, root.tiles.length * 100px - 8px) : self.height;
horizontal-scrollbar-policy: root.vertical-rail ? always-off : as-needed;
vertical-scrollbar-policy: root.vertical-rail ? as-needed : always-off;
for tile[index] in root.tiles: Rectangle {
private property <bool> current: tile.id == root.focused.id;
init => { if self.current { root.rail-current-index = index; root.reveal-current(); } }
changed current => { if self.current { root.rail-current-index = index; root.reveal-current(); } }
x: root.vertical-rail ? 0px : index * 144px;
y: root.vertical-rail ? index * 100px : 0px;
width: 136px; height: 92px; border-radius: 5px;
background: tile.id == root.focused.id ? #edf5ff : #f5f6f8;
border-width: tile.id == root.focused.id ? 2px : 1px;
border-color: tile.id == root.focused.id ? #76aeea : #d5dbe3;
Rectangle {
x: 5px; y: 5px; width: 126px; height: 52px; background: #24272d;
if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; }
if !tile.has-frame || tile.stale || !tile.connected: Text {
text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide;
horizontal-alignment: center; vertical-alignment: center;
}
}
Text { x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; }
Text {
x: 7px; y: 78px; width: 86px; height: 12px; font-size: 9px;
text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
color: tile.online ? #17854c : #7a828d; overflow: elide;
}
if tile.id == root.focused.id: Text {
x: 99px; y: 78px; width: 30px; height: 12px;
text: GalleryStrings.tr("当前", "Current", "Выбрано"); color: #4d83bd; font-size: 9px; horizontal-alignment: right; overflow: elide;
}
TouchArea { clicked => { root.action("focus", tile.id); } }
}
}
}
// Reuse the footer for selection actions so entering batch mode never
// changes card geometry or moves a device beneath the pointer.
footer := HorizontalLayout {
x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: 12px;
if root.batch: CompactButton {
width: 90px; enabled: root.count > 0;
text: root.count > 0 && root.selected-count == root.count
? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор")
: GalleryStrings.tr("全选", "Select all", "Выбрать все");
clicked => { root.action("all-select", ""); }
}
Rectangle {
horizontal-stretch: 1; min-width: 0px;
Text {
y: 0px; width: parent.width; height: root.batch && root.hidden-selected > 0 ? 13px : parent.height;
text: root.batch ? GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count : root.connected-count + " / " + root.count + GalleryStrings.tr(" 台已连接", " connected", " подключено");
color: #62666d; vertical-alignment: center; font-size: 12px; overflow: elide;
}
if root.batch && root.hidden-selected > 0: Text {
y: 14px; width: parent.width; height: 10px;
text: GalleryStrings.tr("跨页 ", "Other pages ", "Другие страницы ") + root.hidden-selected;
color: #80858d; font-size: 10px; overflow: elide;
}
}
if root.batch: HorizontalLayout {
width: 214px; spacing: 8px;
CompactButton { width: 58px; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } }
CompactButton { width: 62px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
CompactButton { width: 78px; enabled: root.selected-count > 0; text: GalleryStrings.tr("分发文件", "Send file", "Файл"); clicked => { root.confirm-action("file", self.text); } }
}
HorizontalLayout {
width: 112px; height: 24px; spacing: 6px;
CompactButton { width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; }
CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
}
}
if root.adding: Rectangle {
background: #00000042;
TouchArea {}
add-dialog := Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
function submit() {
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
password.text = "";
root.adding = false;
}
}
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; }
Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; }
device-id := LineEdit {
x: 96px; y: 56px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр");
input-type: InputType.number;
accepted => { password.focus(); }
}
Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; }
password := LineEdit {
x: 96px; y: 100px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства");
input-type: InputType.password;
accepted => { group.focus(); }
}
Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; }
group := LineEdit {
x: 96px; y: 144px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)");
accepted => { add-dialog.submit(); }
}
remember := ImGuiCheckBox { x: 96px; y: 188px; }
Text { x: 123px; y: 188px; height: 21px; text: GalleryStrings.tr("记住密码", "Remember password", "Запомнить пароль"); font-size: 12px; vertical-alignment: center;
TouchArea { clicked => { remember.checked = !remember.checked; } }
}
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
CompactButton {
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
enabled: device-id.text != "" && password.text != "";
clicked => { add-dialog.submit(); }
}
}
}
if root.importing: Rectangle {
background: #00000042;
TouchArea {}
Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-devices.length > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-devices.length; }
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); font-size: 12px; vertical-alignment: center; }
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-devices.length > 0; clicked => { root.action("recent-all", ""); } }
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.recent-selected + " / " + root.recent-devices.length; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
Rectangle {
x: 18px; y: 102px; width: 404px; height: 160px;
border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true;
recent-list := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
viewport-height: max(self.height, root.recent-devices.length * 44px);
for device[index] in root.recent-devices: Rectangle {
y: index * 44px; width: recent-list.viewport-width; height: 44px;
background: device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white;
ImGuiCheckBox { x: 8px; y: 11px; checked: device.selected; }
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; font-size: 12px; overflow: elide; vertical-alignment: center; }
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: #808080; }
Rectangle { x: parent.width - 74px; y: 19px; width: 6px; height: 6px; border-radius: 3px; background: device.online ? #21b567 : #92969e; }
Text {
x: parent.width - 61px; y: 12px; width: 57px; height: 20px;
text: device.online ? GalleryStrings.tr("在线", "Online", "В сети") : GalleryStrings.tr("离线", "Offline", "Не в сети");
color: device.online ? #17854c : #808080; font-size: 11px; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.action("recent-select", device.id); } }
}
}
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
}
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
}
}
if root.confirming: Rectangle {
background: #00000042; TouchArea {}
Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: 360px; height: confirmation-list.height + 118px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
Text {
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
color: #24272d; font-size: 16px; font-weight: 600; vertical-alignment: center;
}
Rectangle {
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
background: #eff3f8; border-radius: 11px;
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
}
confirmation-list := Rectangle {
x: 20px; y: 58px; width: parent.width - 40px;
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true;
confirmation-scroll := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
viewport-height: max(self.height, root.selected-devices.length * 52px);
for device[index] in root.selected-devices: Rectangle {
y: index * 52px; width: confirmation-scroll.viewport-width; height: 52px;
Text { x: 12px; y: 8px; width: parent.width - 32px - connection-badge.width; height: 18px; text: device.name; color: #303740; font-size: 12px; font-weight: 600; overflow: elide; vertical-alignment: center; }
Text { x: 12px; y: 29px; width: parent.width - 32px - connection-badge.width; height: 16px; text: "ID " + device.id; color: #8a929e; font-size: 11px; vertical-alignment: center; }
connection-badge := Rectangle {
x: parent.width - self.width - 12px; y: 16px;
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
background: device.connected ? #eaf6ee : #f0f2f5;
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
}
if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; }
}
}
}
CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
CompactButton {
x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true;
text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить");
clicked => { root.confirming = false; root.action(root.action-code, ""); }
}
}
}
}
+161
View File
@@ -0,0 +1,161 @@
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint";
// Shared by history and DeskHub; system choices precede the named groups.
export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
in-out property <int> current-index: 0;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
private property <length> visible-height: min(root.list-height, 162px);
private property <length> scroll-y: 0px;
private property <bool> popup-animation-enabled: false;
private property <float> popup-progress: 0;
animate popup-progress {
duration: 140ms; easing: ease-out;
enabled: root.popup-animation-enabled;
}
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2;
border-radius: 4px;
animate background, border-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus;
accessible-role: combobox;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: popup.is-open;
accessible-value: root.model[root.current-index];
function reveal-selection() {
let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px);
if top < -root.scroll-y { root.scroll-y = -top; }
if top + 26px > -root.scroll-y + root.visible-height {
root.scroll-y = root.visible-height - top - 26px;
}
root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px);
}
function choose(index: int) {
if !root.enabled || root.model.length == 0 { return; }
root.current-index = clamp(index, 0, root.model.length - 1);
root.reveal-selection();
root.selected(root.current-index);
}
function open-menu() {
if root.enabled && root.model.length > 0 {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0;
root.reveal-selection();
popup.show();
popup-open-timer.running = true;
}
}
// Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer {
interval: 16ms;
running: false;
triggered => {
self.running = false;
if popup.is-open {
root.popup-animation-enabled = true;
root.popup-progress = 1;
}
}
}
function handle-key(event: KeyEvent) -> EventResult {
if !root.enabled { return reject; }
if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; }
if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; }
if event.text == Key.Return || event.text == " " {
if popup.is-open { popup.close(); } else { root.open-menu(); }
return accept;
}
if event.text == Key.Escape && popup.is-open { popup.close(); return accept; }
return reject;
}
Text {
x: 10px; width: parent.width - 36px; height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center;
accessible-role: none;
}
Text {
x: parent.width - 25px; width: 16px; height: parent.height;
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center;
transform-rotation: popup.is-open ? 180deg : 0deg;
animate transform-rotation { duration: 140ms; easing: ease-out; }
accessible-role: none;
}
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea {
enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
}
popup := PopupWindow {
x: 0px; y: root.height + 4px;
width: root.width; height: root.visible-height + 8px;
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height;
opacity: root.popup-progress;
background: white; border-width: 1px;
border-color: #c9cdd2; border-radius: 5px;
ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width;
viewport-height: root.list-height;
viewport-y <=> root.scroll-y;
Rectangle {
width: parent.width; height: root.list-height;
if root.has-divider: Rectangle {
x: 6px; y: root.system-count * 26px + 3px;
width: parent.width - 12px; height: 1px; background: #e9ebee;
}
for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: 3px;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item;
accessible-label: choice;
Text {
x: 8px; width: parent.width - 34px; height: parent.height;
text: choice; color: #30343b; font-size: 12px;
overflow: elide; vertical-alignment: center;
accessible-role: none;
}
if index == root.current-index: Text {
x: parent.width - 24px; width: 16px; height: parent.height;
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #4e87c0;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
clicked => { root.choose(index); popup.close(); }
}
}
}
}
}
}
}
}
+269 -72
View File
@@ -1,3 +1,8 @@
import { GalleryStrings } from "device_strings.slint";
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint";
import { GalleryView, GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
import { GroupFilter } from "group_filter.slint";
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";
@@ -6,7 +11,9 @@ export struct RecentConnection {
remote-id: string,
display-name: string,
host-name: string,
group-names: string,
online: bool,
connected: bool,
thumbnail: image,
has-thumbnail: bool,
platform: string,
@@ -286,20 +293,86 @@ export component MainWindow inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 640px;
preferred-height: 450px + root.titlebar-height;
preferred-height: 486px + root.titlebar-height;
min-width: 640px;
max-width: 640px;
min-height: 450px + root.titlebar-height;
max-height: 450px + root.titlebar-height;
max-width: root.gallery-mode ? 16384px : 640px;
min-height: 486px + root.titlebar-height;
max-height: root.gallery-mode ? 16384px : 486px + root.titlebar-height;
no-frame: root.custom-titlebar;
background: root.wayland-titlebar ? transparent : white;
default-font-size: ImGuiFontStyle.base;
in-out property <bool> gallery-mode: false;
in property <bool> gallery-fullscreen: false;
in property <bool> gallery-transitioning: false;
in property <bool> gallery-content-visible: true;
changed gallery-transitioning => {
if root.gallery-transitioning {
focus-sink.focus();
root.menu-open = false;
root.recent-tooltip-visible = false;
}
}
in-out property <string> connection-input-error;
in property <bool> gallery-controlling: false;
out property <float> gallery-video-width: gallery.video-width;
out property <float> gallery-video-height: gallery.video-height;
callback gallery-pointer(PointerEventButton, PointerEventKind, length, length);
callback gallery-scroll(length, length, length, length);
callback gallery-key(string, bool, bool, bool, bool, bool);
in property <[GalleryTile]> gallery-tiles;
in property <[string]> gallery-groups;
in-out property <int> gallery-group-index: 0;
in property <int> gallery-columns: 3;
in property <int> gallery-rows: 2;
in property <int> gallery-page: 0;
in property <int> gallery-pages: 1;
in property <int> gallery-count: 0;
in property <int> gallery-connected: 0;
in property <int> gallery-selected: 0;
in property <int> gallery-hidden-selected: 0;
in property <string> gallery-notice;
in property <[GalleryActionDevice]> gallery-selected-devices;
in property <GalleryTile> gallery-focused;
in-out property <bool> gallery-batch: false;
in-out property <bool> gallery-adding: false;
in-out property <bool> gallery-importing: false;
in property <[GalleryRecentDevice]> gallery-recent-devices;
in property <int> gallery-recent-selected: 0;
in property <string> gallery-import-group;
out property <float> gallery-width: root.width / 1px;
out property <float> gallery-height: gallery.height / 1px;
callback change-mode(bool);
callback toggle-gallery-fullscreen();
callback gallery-action(string, string);
callback gallery-add-device(string, string, string, bool) -> bool;
callback gallery-import-recent(string) -> bool;
in-out property <bool> device-group-open: false;
private property <string> device-group-id;
private property <string> device-group-name;
in property <[DeviceGroupOption]> device-group-options;
in property <string> device-group-error;
in-out property <string> device-group-new-name;
callback device-group-action(string, string);
callback device-group-save(string) -> bool;
function open-device-groups(id: string, name: string) {
root.gallery-action("release", "");
focus-sink.focus();
root.recent-tooltip-visible = false;
root.device-group-id = id;
root.device-group-name = name;
root.device-group-new-name = "";
root.device-group-action("open", id);
root.device-group-open = true;
}
public function reset-gallery-transient() { gallery.reset-transient(); root.menu-open = false; }
in property <bool> custom-titlebar: false;
in property <bool> wayland-titlebar: false;
in property <bool> window-active: true;
private property <length> titlebar-height:
root.custom-titlebar ? (root.wayland-titlebar ? 32px : 31px) : 0px;
root.custom-titlebar && !root.gallery-fullscreen ? (root.wayland-titlebar ? 32px : 31px) : 0px;
private property <length> content-header-height: 38px;
private property <length> wayland-corner-radius: 12px;
in property <string> local-id: "";
in property <string> local-password: "";
@@ -310,6 +383,13 @@ export component MainWindow inherits Window {
in property <bool> signal-reconnecting: false;
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <[string]> recent-groups: [GalleryStrings.tr("全部设备", "All devices", "Все устройства"), GalleryStrings.tr("未分组", "Ungrouped", "Без группы")];
in-out property <int> recent-group-index: 0;
callback recent-group-selected(int);
changed recent-group-index => {
root.recent-tooltip-visible = false;
recent-scroll.viewport-x = 0px;
}
in property <[AnnouncementItem]> announcements;
in property <int> announcement-unread;
private property <string> announcement-unread-label:
@@ -389,13 +469,14 @@ export component MainWindow inherits Window {
// as disabled so the fixed settings layout never turns into a blank row.
in property <bool> hardware-codec-available: true;
callback copy-local-id;
callback copy-local-id() -> bool;
callback toggle-password-visibility;
callback refresh-password;
callback reset-password(string) -> bool;
callback format-remote-id(string) -> string;
callback connect-requested(string);
callback recent-connect(string);
callback recent-disconnect(string);
callback recent-edit-alias(string, string);
callback recent-delete(string);
callback save-settings() -> bool;
@@ -439,29 +520,28 @@ export component MainWindow inherits Window {
in-out property <string> alias-input: "";
in-out property <bool> delete-open: false;
private property <string> delete-remote-id: "";
private property <bool> delete-from-gallery: false;
private property <bool> copy-toast: false;
private property <bool> compact-language: root.language-index == 0;
private property <bool> recent-tooltip-visible: false;
private property <string> recent-tooltip-name: "";
private property <string> recent-tooltip-host: "";
private property <string> recent-tooltip-id: "";
private property <string> recent-tooltip-groups: "";
private property <bool> recent-tooltip-online: false;
private property <length> recent-tooltip-pointer-x: 0px;
private property <length> recent-tooltip-pointer-y: 0px;
private property <length> recent-tooltip-width: 260px;
private property <length> recent-tooltip-height:
root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name
? 80px : 64px;
private property <length> recent-tooltip-x: min(
root.width - root.recent-tooltip-width - 8px,
max(8px,
root.recent-tooltip-pointer-x > root.width * 0.7
? root.recent-tooltip-pointer-x - root.recent-tooltip-width - 12px
: root.recent-tooltip-pointer-x + 12px));
private property <length> recent-tooltip-y: min(
root.height - root.titlebar-height - root.recent-tooltip-height - 8px,
max(8px, root.recent-tooltip-pointer-y - root.recent-tooltip-height - 8px));
changed menu-open => { if root.menu-open { root.gallery-action("release", ""); } }
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
changed settings-open => {
if root.settings-open {
root.settings-scroll-y = 0px;
@@ -490,7 +570,7 @@ export component MainWindow inherits Window {
root.wayland-titlebar ? root.wayland-corner-radius : 0px;
clip: root.wayland-titlebar;
if root.custom-titlebar && root.wayland-titlebar: Rectangle {
if root.custom-titlebar && root.wayland-titlebar && !root.gallery-fullscreen: Rectangle {
x: 0px;
y: 0px;
width: parent.width;
@@ -583,7 +663,7 @@ export component MainWindow inherits Window {
}
}
if root.custom-titlebar && !root.wayland-titlebar: Rectangle {
if root.custom-titlebar && !root.wayland-titlebar && !root.gallery-fullscreen: Rectangle {
x: 0px;
y: 0px;
width: root.width;
@@ -673,6 +753,12 @@ export component MainWindow inherits Window {
width: root.width;
height: root.height - self.y;
clip: true;
opacity: root.gallery-content-visible || !root.gallery-mode ? 1 : 0;
animate opacity {
duration: 60ms;
easing: ease-in-out;
enabled: root.gallery-mode;
}
Rectangle {
width: parent.width;
@@ -714,10 +800,10 @@ export component MainWindow inherits Window {
// App actions live in the content header; window controls and dragging
// remain in the native or Windows-specific title bar above it.
menu-button := IconButton {
x: parent.width - 40px;
y: 4px;
width: 32px;
height: 32px;
x: parent.width - mode-switch.x - self.width;
y: mode-switch.y;
width: mode-switch.height;
height: mode-switch.height;
icon: FontAwesomeIcons.bars;
icon-size: 14px;
hover-background: #e1e5eb;
@@ -726,7 +812,7 @@ export component MainWindow inherits Window {
? 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; }
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
// Unread messages take precedence so the menu has only one indicator.
if root.update-available && root.announcement-unread <= 0: Rectangle {
@@ -767,7 +853,7 @@ export component MainWindow inherits Window {
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.menu-open = !root.menu-open; }
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
}
if root.announcement-unread > 0: Rectangle {
@@ -794,10 +880,51 @@ export component MainWindow inherits Window {
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.menu-open = !root.menu-open; }
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
}
mode-switch := Rectangle {
x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px;
background: #eef1f5;
border-width: 0.5px; border-color: #d5dbe3; border-radius: 6px;
Rectangle {
x: root.gallery-mode ? 82px : 2px;
y: 2px; width: 80px; height: parent.height - 4px;
background: @linear-gradient(180deg, #c4e0fd, #abd0f8);
border-width: 0.5px; border-color: #97bde5; border-radius: 4px;
drop-shadow-color: #315b8b20;
drop-shadow-blur: 2px; drop-shadow-offset-y: 1px;
Rectangle {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
border-width: 0.5px; border-color: #ffffff70; border-radius: 3px;
}
animate x { duration: 160ms; easing: ease-out; }
}
for label[index] in [GalleryStrings.tr("连接", "Connect", "Связь"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle {
x: 2px + index * 80px; y: 2px; width: 80px; height: parent.height - 4px;
Text {
text: label;
color: (index == 1) == root.gallery-mode ? #234b75 : #697483;
font-weight: (index == 1) == root.gallery-mode ? 600 : 400;
horizontal-alignment: center; vertical-alignment: center; font-size: 12px;
animate color { duration: 120ms; easing: ease-out; }
}
TouchArea {
clicked => { root.change-mode(index == 1); }
}
}
}
if root.gallery-mode: CompactButton {
x: menu-button.x - self.width - 8px;
y: mode-switch.y;
width: 87px;
height: mode-switch.height;
text: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit full", "Выйти") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-gallery-fullscreen(); }
}
Rectangle {
y: root.content-header-height; height: parent.height - self.y - 24px; visible: !root.gallery-mode;
// Local and remote desktop panels.
Rectangle {
y: 0px;
@@ -856,13 +983,14 @@ export component MainWindow inherits Window {
y: 46px;
width: 29px;
height: 30px;
icon: FontAwesomeIcons.copy;
icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy;
icon-size: 16px;
tooltip: UiStrings.copied;
clicked => {
root.copy-local-id();
root.copy-toast = true;
copy-timer.restart();
if root.copy-local-id() {
root.copy-toast = true;
copy-timer.restart();
}
}
}
Rectangle { x: 8px; y: 88px; width: 240px; height: 1px; background: ImGuiLineStyle.separator; }
@@ -921,7 +1049,7 @@ export component MainWindow inherits Window {
}
}
Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.divider; }
Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.border; }
SectionTitle { x: 356px; y: 9px; width: 256px; height: 31px; text: UiStrings.remote-desktop; }
Rectangle {
@@ -959,10 +1087,11 @@ export component MainWindow inherits Window {
// Run formatting after the input event has
// finished so the caret state isn't reset by
// a synchronous two-way binding rewrite.
edited => { remote-id-format-timer.restart(); }
edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); }
accepted => { root.connect-requested(root.remote-id-input); }
}
}
Text { x: 8px; y: 92px; width: 240px; height: 52px; text: root.connection-input-error; font-size: 12px; color: #a33832; wrap: word-wrap; }
IconButton {
x: 175px;
y: 42px;
@@ -982,7 +1111,7 @@ export component MainWindow inherits Window {
// Recent connections: horizontal cards with thumbnail, state and actions.
recent-panel := Rectangle {
y: 234px;
height: 192px;
height: parent.height - self.y;
background: white;
border-width: 0px;
border-color: ImGuiLineStyle.border;
@@ -990,6 +1119,13 @@ export component MainWindow inherits Window {
Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; }
SectionTitle { x: 29px; y: 7px; height: 28px; text: UiStrings.recent-connections; }
GroupFilter {
x: parent.width - self.width - 29px; y: 8px;
width: 155px; height: 28px;
model: root.recent-groups;
current-index <=> root.recent-group-index;
selected(index) => { root.recent-group-selected(index); }
}
recent-list := Rectangle {
x: 29px;
@@ -1002,6 +1138,13 @@ export component MainWindow inherits Window {
border-radius: 10px;
clip: true;
if root.recent-connections.length == 0: Text {
text: root.recent-group-index == 0
? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений")
: GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений");
color: #808080; font-size: ImGuiFontStyle.base;
horizontal-alignment: center; vertical-alignment: center;
}
recent-scroll := ScrollView {
width: parent.width;
height: parent.height;
@@ -1018,7 +1161,7 @@ export component MainWindow inherits Window {
height: 117px;
background: transparent;
private property <length> content-width: self.width - 30px;
private property <length> toolbar-width: 79px;
private property <length> toolbar-width: DeviceCardStyle.toolbar-width;
private property <length> toolbar-right-padding: 3px;
Rectangle {
@@ -1113,12 +1256,19 @@ export component MainWindow inherits Window {
image-touch := TouchArea {
width: parent.width;
height: 84px;
private property <string> group-names: connection.group-names;
changed group-names => {
if self.has-hover && root.recent-tooltip-id == connection.remote-id {
root.recent-tooltip-groups = self.group-names;
}
}
clicked => { root.recent-connect(connection.remote-id); }
changed has-hover => {
if self.has-hover {
root.recent-tooltip-name = connection.display-name;
root.recent-tooltip-host = connection.host-name;
root.recent-tooltip-id = connection.remote-id;
root.recent-tooltip-groups = self.group-names;
root.recent-tooltip-online = connection.online;
root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x
+ recent-scroll.x + recent-scroll.viewport-x
@@ -1142,41 +1292,24 @@ export component MainWindow inherits Window {
}
}
}
if card-touch.has-hover: Rectangle {
if card-touch.has-hover: DeviceCardActions {
x: parent.width - self.width - connection-card.toolbar-right-padding;
y: 85px;
width: connection-card.toolbar-width;
height: 23px;
background: #4a4e55dc;
border-radius: 5px;
HorizontalLayout {
spacing: 0px;
IconButton {
width: 26px; height: 23px; icon: FontAwesomeIcons.pen; icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
clicked => {
root.alias-remote-id = connection.remote-id;
root.alias-input = connection.display-name;
root.alias-open = true;
}
}
IconButton {
width: 26px; height: 23px; icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
clicked => {
root.delete-remote-id = connection.remote-id;
root.delete-open = true;
}
}
IconButton {
width: 27px; height: 23px; icon: FontAwesomeIcons.arrow-right-long; icon-size: 13px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
clicked => { root.recent-connect(connection.remote-id); }
}
remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись");
connected: connection.connected;
edit-alias => {
root.alias-remote-id = connection.remote-id;
root.alias-input = connection.display-name;
root.alias-open = true;
}
edit-groups => { root.open-device-groups(connection.remote-id, connection.display-name); }
remove-device => {
root.delete-remote-id = connection.remote-id;
root.delete-from-gallery = false;
root.delete-open = true;
}
connect-device => { root.recent-connect(connection.remote-id); }
disconnect-device => { root.recent-disconnect(connection.remote-id); }
}
}
if index < root.recent-connections.length - 1: Rectangle {
@@ -1192,9 +1325,38 @@ export component MainWindow inherits Window {
}
}
}
gallery := GalleryView {
y: root.content-header-height; width: parent.width; height: parent.height - self.y - 24px;
visible: root.gallery-mode;
controlling: root.gallery-controlling;
pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); }
scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); }
key-input(text, pressed, ctrl, alt, shift, meta) => { root.gallery-key(text, pressed, ctrl, alt, shift, meta); }
tiles: root.gallery-tiles; groups: root.gallery-groups;
group-index <=> root.gallery-group-index;
columns: root.gallery-columns; rows: root.gallery-rows;
page: root.gallery-page; pages: root.gallery-pages; count: root.gallery-count;
connected-count: root.gallery-connected; selected-count: root.gallery-selected;
hidden-selected: root.gallery-hidden-selected; notice: root.gallery-notice;
selected-devices: root.gallery-selected-devices;
focused: root.gallery-focused; batch <=> root.gallery-batch; adding <=> root.gallery-adding;
importing <=> root.gallery-importing; recent-devices: root.gallery-recent-devices;
recent-selected: root.gallery-recent-selected; import-group: root.gallery-import-group;
action(code, value) => { root.gallery-action(code, value); }
add-device(id, password, group, remember) => { root.gallery-add-device(id, password, group, remember) }
import-recent(group) => { root.gallery-import-recent(group) }
edit-alias(id, name) => {
root.alias-remote-id = id; root.alias-input = name; root.alias-open = true;
}
edit-groups(id, name) => { root.open-device-groups(id, name); }
remove-device(id) => {
root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true;
}
}
// Status bar.
Rectangle {
y: 426px;
y: parent.height - 24px;
height: 24px;
background: #fafbfc;
Rectangle {
@@ -1255,9 +1417,11 @@ export component MainWindow inherits Window {
// pointer can otherwise cover a dialog button and hide it from the user.
if root.recent-tooltip-visible && !root.connection-dialog-open: Rectangle {
x: root.recent-tooltip-x;
y: root.recent-tooltip-y;
y: min(root.height - root.titlebar-height - self.height - 8px,
max(8px, root.recent-tooltip-pointer-y - self.height - 8px));
width: root.recent-tooltip-width;
height: root.recent-tooltip-height;
height: (root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name
? 80px : 64px) + tooltip-groups.preferred-height + 2px;
background: #22252bf2;
border-width: 1px;
border-color: #ffffff38;
@@ -1285,6 +1449,14 @@ export component MainWindow inherits Window {
font-size: ImGuiFontStyle.small;
overflow: elide;
}
tooltip-groups := Text {
text: GalleryStrings.tr("分组", "Groups", "Группы") + ": " +
(root.recent-tooltip-groups == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : root.recent-tooltip-groups);
color: #e1e4e8;
font-size: ImGuiFontStyle.small;
wrap: word-wrap;
vertical-stretch: 0;
}
Text {
text: root.recent-tooltip-online ? UiStrings.online : UiStrings.offline;
color: root.recent-tooltip-online ? #62d98b : #d1d5db;
@@ -1536,14 +1708,32 @@ export component MainWindow inherits Window {
}
}
// One editor and one callback path for recent connections and DeskHub.
if root.device-group-open: DeviceGroupDialog {
z: 40;
device-id: root.device-group-id;
device-name: root.device-group-name;
options: root.device-group-options;
error: root.device-group-error;
new-group <=> root.device-group-new-name;
toggle-group(name) => { root.device-group-action("toggle", name); }
save(name) => {
if root.device-group-save(name) {
root.device-group-action("close", "");
root.device-group-open = false;
}
}
cancel => { root.device-group-action("close", ""); root.device-group-open = false; }
}
// Recent connection alias modal.
if root.alias-open: Rectangle {
background: #00000042;
z: 40;
TouchArea { clicked => { focus-sink.focus(); } }
DialogSurface {
x: root.compact-language ? 205px : 160px;
y: 145px;
x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px;
y: root.gallery-mode ? (parent.height - self.height) / 2 : 145px;
width: root.compact-language ? 230px : 320px;
height: 160px;
VerticalLayout {
@@ -1588,16 +1778,16 @@ export component MainWindow inherits Window {
z: 40;
TouchArea { clicked => { focus-sink.focus(); } }
DialogSurface {
x: root.compact-language ? 205px : 160px;
y: 155px;
x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px;
y: root.gallery-mode ? (parent.height - self.height) / 2 : 155px;
width: root.compact-language ? 230px : 320px;
height: 140px;
VerticalLayout {
padding: 20px; spacing: 18px;
DialogPromptText { prompt: UiStrings.confirm-delete; }
DialogPromptText { prompt: root.delete-from-gallery ? (root.gallery-group-index > 0 ? GalleryStrings.tr("从当前分组移除此设备?", "Remove from this group?", "Убрать из этой группы?") : GalleryStrings.tr("从桌面墙移除此设备?", "Remove from DeskHub?", "Убрать из DeskHub?")) : UiStrings.confirm-delete; }
HorizontalLayout {
alignment: center; spacing: 8px;
PrimaryButton { text: UiStrings.ok; clicked => { root.recent-delete(root.delete-remote-id); root.delete-open = false; } }
PrimaryButton { text: UiStrings.ok; clicked => { if root.delete-from-gallery { root.gallery-action("remove", root.delete-remote-id); } else { root.recent-delete(root.delete-remote-id); } root.delete-open = false; } }
SecondaryButton { text: UiStrings.cancel; clicked => { root.delete-open = false; } }
}
}
@@ -1672,8 +1862,8 @@ export component MainWindow inherits Window {
text: UiStrings.ok;
clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); }
}
if !root.connection-validating: ConnectionDialogButton {
x: 117px;
ConnectionDialogButton {
x: root.connection-validating ? (parent.width - self.width) / 2 : 117px;
y: 118px;
text: UiStrings.cancel;
clicked => { root.connection-cancel(); }
@@ -2265,6 +2455,13 @@ export component MainWindow inherits Window {
}
}
// Ignore clicks against moving cards and duplicate fullscreen requests.
if root.gallery-transitioning: TouchArea {
x: 0px; y: root.titlebar-height;
width: root.width; height: root.height - self.y;
z: 250;
}
if root.custom-titlebar: Rectangle {
x: 0px;
y: 0px;
@@ -40,6 +40,7 @@ inline int ApplyMainWindowStrings(
int language_index) {
const int language =
localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>();
auto& messages = window->global<ui::MessageStrings>();
messages.set_messages(Text(localization::messages[language]));
@@ -2,6 +2,8 @@
#import <AppKit/AppKit.h>
#include <cmath>
@interface CrossDeskStreamFullscreenTarget : NSObject
- (void)toggleStreamFullscreen:(id)sender;
@end
@@ -17,6 +19,12 @@
namespace crossdesk {
namespace {
__weak NSWindow *main_window = nil;
bool main_fullscreen = false;
bool main_gallery = false;
NSRect main_restore_frame = NSZeroRect;
NSWindowStyleMask main_restore_style = NSWindowStyleMaskTitled;
NSApplicationPresentationOptions main_restore_presentation;
__weak NSWindow *stream_window = nil;
__weak NSView *stream_slint_view = nil;
CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil;
@@ -53,6 +61,14 @@ void SetTitlebarButtonsHidden(NSWindow *window, BOOL hidden) {
[window standardWindowButton:NSWindowZoomButton].hidden = hidden;
}
void HideMainWindowZoomButton(NSWindow *window) {
NSButton *button = [window standardWindowButton:NSWindowZoomButton];
button.target = nil;
button.action = nil;
button.enabled = NO;
button.hidden = YES;
}
void CenterWindowOnScreen(NSWindow *window, NSScreen *screen) {
if (window == nil || screen == nil) {
return;
@@ -80,7 +96,17 @@ bool StartNativeWindowDrag() {
}
}
bool ConfigureMainWindowChrome(void *opaque_slint_view) {
float MainWindowInitialPositionYOffset() {
@autoreleasepool {
const NSRect content = NSMakeRect(0, 0, 640, 486);
const NSRect frame = [NSWindow frameRectForContentRect:content
styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
NSWindowStyleMaskMiniaturizable];
return float(NSMaxY(frame) - NSMaxY(content));
}
}
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
@autoreleasepool {
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
NSWindow *window = slint_view.window;
@@ -91,9 +117,19 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
if (zoom_button == nil || zoom_button.enabled) {
return false;
}
if (zoom_button == nil) return false;
main_window = window;
main_gallery = gallery;
if (main_fullscreen) return true;
if (gallery)
window.styleMask |= NSWindowStyleMaskResizable;
else
window.styleMask &= ~NSWindowStyleMaskResizable;
window.collectionBehavior =
(window.collectionBehavior & ~(NSWindowCollectionBehaviorFullScreenPrimary |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
NSWindowCollectionBehaviorFullScreenNone;
// Winit creates a transparent AppKit backing even when Slint paints an
// opaque client area. macOS 27 can expose that backing in the titlebar.
@@ -104,7 +140,7 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
window.titlebarAppearsTransparent = YES;
window.opaque = YES;
window.backgroundColor = NSColor.whiteColor;
zoom_button.hidden = YES;
HideMainWindowZoomButton(window);
NSView *frame_view = window.contentView.superview;
frame_view.needsLayout = YES;
@@ -113,6 +149,71 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
}
}
bool SetMainWindowFullscreen(bool fullscreen) {
@autoreleasepool {
if (!main_window || (fullscreen && !main_gallery)) return false;
if (main_fullscreen == fullscreen) return true;
if (fullscreen) {
NSScreen *screen = main_window.screen ?: NSScreen.mainScreen;
if (!screen) return false;
main_restore_frame = main_window.frame;
main_restore_style = main_window.styleMask;
main_restore_presentation = NSApp.presentationOptions;
main_fullscreen = true;
main_window.styleMask |= NSWindowStyleMaskFullSizeContentView;
main_window.titleVisibility = NSWindowTitleHidden;
main_window.movable = NO;
SetTitlebarButtonsHidden(main_window, YES);
NSApp.presentationOptions |=
NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar;
// Let the next event-loop frame paint the updated Slint layout. A
// synchronous draw here exposes stale grid geometry during the resize.
[main_window setFrame:screen.frame display:NO animate:NO];
} else {
main_fullscreen = false;
NSApp.presentationOptions = main_restore_presentation;
main_window.styleMask = main_restore_style;
main_window.titleVisibility = NSWindowTitleVisible;
main_window.movable = YES;
[main_window standardWindowButton:NSWindowCloseButton].hidden = NO;
[main_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO;
// The main window uses the in-app fullscreen action in both modes.
HideMainWindowZoomButton(main_window);
[main_window setFrame:main_restore_frame display:NO animate:NO];
}
main_window.contentView.needsDisplay = YES;
return true;
}
}
bool IsMainWindowFullscreen() { return main_fullscreen; }
bool IsMainWindowActive() { return main_window && [NSApp isActive] && [main_window isKeyWindow]; }
bool MaximizeMainWindowWithoutAnimation() {
@autoreleasepool {
if (!main_window || !main_gallery || main_fullscreen) return false;
NSScreen *screen = main_window.screen ?: NSScreen.mainScreen;
if (!screen) return false;
[main_window setFrame:screen.visibleFrame display:YES animate:NO];
return true;
}
}
bool IsMainWindowMaximized() {
@autoreleasepool {
if (!main_window || !main_gallery || main_fullscreen) return false;
NSScreen *screen = main_window.screen;
if (!screen) return false;
// isZoomed can report true for a constrained compact window. Only treat
// the ordinary window as maximized when it fills the usable screen.
const NSRect frame = main_window.frame;
const NSRect available = screen.visibleFrame;
return std::abs(frame.origin.x - available.origin.x) < 1 &&
std::abs(frame.origin.y - available.origin.y) < 1 &&
std::abs(frame.size.width - available.size.width) < 1 &&
std::abs(frame.size.height - available.size.height) < 1;
}
}
bool ConfigureStreamWindowLiveResize(void *opaque_slint_view) {
@autoreleasepool {
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
+14 -4
View File
@@ -8,10 +8,20 @@ namespace crossdesk {
// left-mouse-down event. Returns false when no suitable native event exists.
bool StartNativeWindowDrag();
// 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);
// Sets a white native titlebar and hides its zoom button in both main-window
// modes. DeskHub remains resizable. |slint_view| is its AppKit NSView*.
// Returns false while the native window or its titlebar controls are not ready.
bool ConfigureMainWindowChrome(void* slint_view, bool gallery = false);
// Winit's initial macOS position is a content origin, while subsequent moves
// use the outer frame. Return the logical titlebar inset for initial placement.
float MainWindowInitialPositionYOffset();
bool SetMainWindowFullscreen(bool fullscreen);
bool IsMainWindowFullscreen();
bool IsMainWindowActive();
// AppKit's animated zoom spins a nested run loop and cannot be called from a
// Slint timer. Restore maximization by setting the visible screen frame directly.
bool MaximizeMainWindowWithoutAnimation();
bool IsMainWindowMaximized();
// Configures live resize and replaces the stream window's native Space
// fullscreen action with an immediate, single-window fullscreen transition.
+1 -1
View File
@@ -510,7 +510,7 @@ function setup_targets()
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
"apps/desktop/src/gui/features/settings/*.cpp")
"apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/features/gallery/*.cpp")
-- Only these translation units consume the application version. Keep
-- version changes out of the flags for media and the rest of the GUI.
add_files("apps/desktop/src/gui/application/gui_application.cpp",
+53
View File
@@ -0,0 +1,53 @@
# 桌面墙(DeskHub)
主窗口标题栏下方可以切换“连接”和“桌面墙”,菜单位于同一行右侧。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。
全屏按钮通过短暂淡出、等待窗口尺寸稳定、淡入完成切换,常规总时长约 0.2 秒;过渡期间屏蔽设备操作与重复全屏点击。窗口尺寸变化会立即更新网格,避免在新窗口中先显示旧的行列布局。过渡复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会恢复内容与交互。
可见的普通窗口若低于连接页最小尺寸,桌面墙恢复上次有效大小,连接页恢复固定大小;最小化、隐藏和全屏窗口不会触发这一恢复。窗口恢复以实际尺寸确认完成,期间不记录过渡尺寸;macOS 的最大化恢复使用无动画原生窗口调整,避免系统缩放动画嵌套事件循环。
## 使用
- 在桌面墙中点“添加设备”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。
- “从最近连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“批量选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。
- 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。
- “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。
- 卡片名称栏以文字标签显示“在线/离线”,与画面中的连接状态分开,不遮挡预览。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示“未知”,已有活动连接的设备仍显示在线。
- 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。
- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“从最近连接添加”或“添加设备”才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。
- 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。
- “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。
- 网格随窗口大小增加行列,紧凑窗口为 3 × 2;翻页不关闭连接。分组内顺序不会按在线状态自动重排。
- 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。
- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;紧凑窗口的设备缩略图位于下方,宽窗口移到右侧。缩略图显示设备名、在线状态和当前设备标记,缺少画面或画面停滞时有状态占位;滚动条单独留出空间。调整尺寸后当前设备仍在列表中,选中时自动滚入可见区域。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。
- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/分发文件及分页控件在同一底栏显示。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
## 预览与兼容性
桌面墙的所有已连接设备统一使用各自的交互画质、30/60 帧设置和降级策略。缩略图、放大查看、操控、翻页和隐藏窗口均不改变发送档位。新版两端通过 `supports_deskhub_preview` 同步订阅,视频按编码器协商的帧率发送,不再额外限帧;声音仍需明确开启。
为兼容旧版控制端,被控端仍接受暂停和 1/8 帧订阅;这些低帧率请求使用保持分辨率策略,避免套用交互视频的帧率与关键帧时延预算而反复缩小画面。当前桌面墙不再发出这些请求。
预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;外层圆角卡片与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。
旧版被控端仍可连接,按其支持的交互视频策略发送。多设备同时发送交互视频会增加带宽和编解码负载。
## 批量操作
连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。
批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。
`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。
批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。
## 验证
```sh
xmake build crossdesk
xmake run slint_ui_smoke_test
xmake run crossdesk_wire_test
xmake run keyboard_state_wire_test
```