[feat] add Linux headless mode and interactive console

This commit is contained in:
kunkundi
2026-09-25 00:29:24 +08:00
parent c661319890
commit 191ba26772
28 changed files with 2120 additions and 201 deletions
+55 -20
View File
@@ -6,6 +6,7 @@
#endif
#endif
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iostream>
@@ -26,6 +27,8 @@
#include "render.h"
#ifdef __linux__
#include "platform/linux/headless/headless_console.h"
#include "platform/linux/headless/headless_session.h"
#include "platform/linux/privacy/privacy_guard.h"
#endif
@@ -179,22 +182,7 @@ int HandleServiceCliCommand(const std::string& command) {
} // namespace
#endif
int main(int argc, char* argv[]) {
#ifdef __linux__
// Run before GUI, configuration, logging or daemon initialization. The
// guard owns native privacy resources and restores them if this app exits.
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
return crossdesk::RunLinuxPrivacyGuard();
}
#endif
#ifdef _WIN32
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
return crossdesk::RunSlintRendererProbe();
}
#endif
static int RunApplication(int argc, char* argv[]) {
// Service operations and config migration can log before the GUI starts.
// Configure the directory first, including CLI and daemon child paths.
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
@@ -218,8 +206,7 @@ int main(int argc, char* argv[]) {
if (is_child) {
// child process: run render directly
crossdesk::Render render;
render.Run();
return 0;
return render.Run();
}
#ifdef _WIN32
@@ -233,6 +220,14 @@ int main(int argc, char* argv[]) {
enable_daemon = config_center.IsEnableDaemon();
}
#ifdef __linux__
// The headless supervisor owns the display and application lifetime. A
// detached daemon would outlive its display; systemd can restart the whole
// session instead.
if (std::getenv("CROSSDESK_HEADLESS_ACTIVE") ||
crossdesk::HeadlessConsole::Instance().active()) enable_daemon = false;
#endif
if (enable_daemon) {
// start daemon with restart monitoring
Daemon daemon("CrossDesk");
@@ -251,6 +246,46 @@ int main(int argc, char* argv[]) {
// run without daemon: direct execution
crossdesk::Render render;
render.Run();
return 0;
return render.Run();
}
int main(int argc, char* argv[]) {
#ifdef __linux__
// Run before GUI, configuration, logging or display initialization.
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
return crossdesk::RunLinuxPrivacyGuard();
}
auto& console = crossdesk::HeadlessConsole::Instance();
if (argc == 2 && std::strcmp(argv[1], "--headless-help") == 0) {
crossdesk::PathManager paths("CrossDesk");
CSimpleIniA config;
config.SetUnicode(true);
config.LoadFile((paths.GetCachePath() / "config.ini").string().c_str());
console.SetLanguage(static_cast<int>(config.GetLongValue("Settings", "language", 0)));
std::cout << console.Text("console_cli_help");
return 0;
}
const int result = crossdesk::RunWithLinuxDisplay(
argc, argv, [&] { return RunApplication(argc, argv); }, [&] {
crossdesk::PathManager paths("CrossDesk");
if (console.Enable(paths.GetLogPath(), paths.GetCachePath() / "config.ini")) return true;
std::cerr << "无法初始化日志或控制台,启动失败。\n";
return false;
});
if (console.active()) {
console.Notify(result == 0 ? console.Text("console_exited")
: console.Text("console_stopped") + " " + std::to_string(result) +
"; " + console.Text("console_log_file") + ": " + console.diagnostic_path());
}
return result;
#else
#ifdef _WIN32
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
return crossdesk::RunSlintRendererProbe();
}
#endif
return RunApplication(argc, argv);
#endif
}
+101 -114
View File
@@ -1,5 +1,11 @@
#include "config_center.h"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <optional>
#include <vector>
#include "autostart.h"
#include "rd_log.h"
@@ -128,13 +134,58 @@ int ConfigCenter::Load() {
file_transfer_save_path_ = "";
}
if (persist_config_migration && ini_.SaveFile(config_path_.c_str()) < 0) {
if (persist_config_migration && CommitIni() < 0) {
return -1;
}
return 0;
}
int ConfigCenter::CommitIni() {
// Write beside the destination and rename only after a complete save. A
// failed write must not truncate the last usable configuration.
static std::atomic<unsigned long> sequence{0};
const std::string temporary = config_path_ + ".tmp-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) +
"-" + std::to_string(sequence++);
std::error_code error;
if (ini_.SaveFile(temporary.c_str()) < 0) {
LOG_ERROR("Failed to write configuration: {}", config_path_);
std::filesystem::remove(temporary, error);
return -1;
}
const auto previous = std::filesystem::status(config_path_, error);
if (!error && std::filesystem::is_regular_file(previous)) {
std::filesystem::permissions(temporary, previous.permissions(), error);
if (error) {
LOG_ERROR("Failed to preserve configuration permissions: {}", error.message());
std::filesystem::remove(temporary, error);
return -1;
}
}
std::filesystem::rename(temporary, config_path_, error);
if (!error) return 0;
LOG_ERROR("Failed to replace configuration {}: {}", config_path_, error.message());
std::filesystem::remove(temporary, error);
return -1;
}
int ConfigCenter::StoreValues(
std::initializer_list<std::pair<const char*, std::string>> values) {
std::vector<std::pair<const char*, std::optional<std::string>>> previous;
for (const auto& value : values) {
const char* old = ini_.GetValue(section_, value.first, nullptr);
previous.emplace_back(value.first, old ? std::optional<std::string>(old) : std::nullopt);
ini_.SetValue(section_, value.first, value.second.c_str());
}
if (CommitIni() == 0) return 0;
for (const auto& value : previous) {
if (value.second) ini_.SetValue(section_, value.first, value.second->c_str());
else ini_.Delete(section_, value.first);
}
return -1;
}
int ConfigCenter::Save() {
ini_.SetLongValue(section_, "language", static_cast<long>(language_));
ini_.SetLongValue(section_, "screen_capture_method",
@@ -165,7 +216,7 @@ int ConfigCenter::Save() {
ini_.SetValue(section_, "file_transfer_save_path",
file_transfer_save_path_.c_str());
SI_Error rc = ini_.SaveFile(config_path_.c_str());
SI_Error rc = CommitIni();
if (rc < 0) {
return -1;
}
@@ -176,12 +227,9 @@ int ConfigCenter::Save() {
// setters
int ConfigCenter::SetLanguage(LANGUAGE language) {
const int value = static_cast<int>(language);
if (value < 0 || value > 2 || StoreValues({{"language", std::to_string(value)}}) != 0) return -1;
language_ = language;
ini_.SetLongValue(section_, "language", static_cast<long>(language_));
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
@@ -190,7 +238,7 @@ int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
return -1;
}
ini_.SetLongValue(section_, "screen_capture_method", static_cast<long>(method));
if (ini_.SaveFile(config_path_.c_str()) < 0) {
if (CommitIni() < 0) {
ini_.SetLongValue(section_, "screen_capture_method",
static_cast<long>(screen_capture_method_));
return -1;
@@ -199,41 +247,25 @@ int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
return 0;
}
int ConfigCenter::SetVideoEncodeFormat(
VIDEO_ENCODE_FORMAT video_encode_format) {
video_encode_format_ = video_encode_format;
ini_.SetLongValue(section_, "video_encode_format",
static_cast<long>(video_encode_format_));
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
if (format != VIDEO_ENCODE_FORMAT::H264 && format != VIDEO_ENCODE_FORMAT::AV1) return -1;
if (StoreValues({{"video_encode_format", std::to_string(static_cast<int>(format))}}) != 0) return -1;
video_encode_format_ = format;
return 0;
}
int ConfigCenter::SetHardwareVideoCodec(bool hardware_video_codec) {
hardware_video_codec_ = hardware_video_codec;
ini_.SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetHardwareVideoCodec(bool enabled) {
if (enabled && !IsHardwareVideoCodecAvailable()) return -1;
if (StoreValues({{"hardware_video_codec", enabled ? "true" : "false"}}) != 0) return -1;
hardware_video_codec_ = enabled;
return 0;
}
int ConfigCenter::SetTurnMode(TURN_MODE turn_mode) {
if (!IsValidTurnModeValue(static_cast<long>(turn_mode))) {
return -1;
}
turn_mode_ = turn_mode;
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
ini_.SetBoolValue(section_, "enable_turn",
turn_mode_ != TURN_MODE::DISABLED);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetTurnMode(TURN_MODE mode) {
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
{"enable_turn", mode == TURN_MODE::DISABLED ? "false" : "true"}}) != 0) return -1;
turn_mode_ = mode;
return 0;
}
@@ -249,90 +281,47 @@ int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
: TURN_MODE::FORCE_UDP);
}
int ConfigCenter::SetServerHost(const std::string& signal_server_host) {
signal_server_host_ = signal_server_host;
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetServerHost(const std::string& host) {
if (host.empty() || host.size() > 253 || host.find_first_of("/\\@?#") != std::string::npos ||
std::any_of(host.begin(), host.end(), [](unsigned char c) { return c <= 32 || c == 127; })) return -1;
if (StoreValues({{"signal_server_host", host}}) != 0) return -1;
signal_server_host_ = host;
return 0;
}
int ConfigCenter::SetServerPort(int signal_server_port) {
signal_server_port_ = signal_server_port;
ini_.SetLongValue(section_, "signal_server_port",
static_cast<long>(signal_server_port_));
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetServerPort(int port) {
if (port < 1 || port > 65535 || StoreValues({{"signal_server_port", std::to_string(port)}}) != 0) return -1;
signal_server_port_ = port;
return 0;
}
int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
enable_self_hosted_ = enable_self_hosted;
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
// load from config if self hosted is enabled
if (enable_self_hosted_) {
const char* signal_server_host_value =
ini_.GetValue(section_, "signal_server_host", nullptr);
if (signal_server_host_value != nullptr &&
strlen(signal_server_host_value) > 0) {
signal_server_host_ = signal_server_host_value;
}
const char* signal_server_port_value =
ini_.GetValue(section_, "signal_server_port", nullptr);
if (signal_server_port_value != nullptr &&
strlen(signal_server_port_value) > 0) {
signal_server_port_ = static_cast<int>(
ini_.GetLongValue(section_, "signal_server_port", 0));
}
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
ini_.SetLongValue(section_, "signal_server_port",
static_cast<long>(signal_server_port_));
}
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetSelfHosted(bool enabled) {
if (enabled && (signal_server_host_.empty() || signal_server_port_ < 1 || signal_server_port_ > 65535)) return -1;
if (StoreValues({{"enable_self_hosted", enabled ? "true" : "false"}}) != 0) return -1;
enable_self_hosted_ = enabled;
return 0;
}
int ConfigCenter::SetAutostart(bool enable_autostart) {
enable_autostart_ = enable_autostart;
bool success = false;
if (enable_autostart) {
success = EnableAutostart("CrossDesk");
} else {
success = DisableAutostart("CrossDesk");
}
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
if (!success) {
int ConfigCenter::SetAutostart(bool enabled) {
const bool previous = IsAutostartEnabled("CrossDesk");
if (enabled != previous && !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) {
LOG_ERROR("SetAutostart failed");
return -1;
}
if (StoreValues({{"enable_autostart", enabled ? "true" : "false"}}) != 0) {
if (enabled != previous) {
if (previous) EnableAutostart("CrossDesk");
else DisableAutostart("CrossDesk");
}
return -1;
}
enable_autostart_ = enabled;
return 0;
}
int ConfigCenter::SetDaemon(bool enable_daemon) {
enable_daemon_ = enable_daemon;
ini_.SetBoolValue(section_, "enable_daemon", enable_daemon_);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
int ConfigCenter::SetDaemon(bool enabled) {
if (StoreValues({{"enable_daemon", enabled ? "true" : "false"}}) != 0) return -1;
enable_daemon_ = enabled;
return 0;
}
@@ -340,7 +329,7 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
const bool previous = enable_privacy_screen_.load();
if (previous == enable_privacy_screen) return 0;
ini_.SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen);
if (ini_.SaveFile(config_path_.c_str()) < 0) {
if (CommitIni() < 0) {
ini_.SetBoolValue(section_, "enable_privacy_screen", previous);
LOG_ERROR("Failed to save automatic privacy screen preference");
return -1;
@@ -355,7 +344,7 @@ int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
portable_service_prompt_suppressed_ = suppressed;
ini_.SetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
SI_Error rc = CommitIni();
if (rc < 0) {
return -1;
}
@@ -432,17 +421,15 @@ bool ConfigCenter::IsPortableServicePromptSuppressed() const {
}
int ConfigCenter::SetFileTransferSavePath(const std::string& path) {
if (path.size() >= 512 || std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; })) return -1;
std::lock_guard<std::mutex> lock(file_path_mutex_);
if (StoreValues({{"file_transfer_save_path", path}}) != 0) return -1;
file_transfer_save_path_ = path;
ini_.SetValue(section_, "file_transfer_save_path",
file_transfer_save_path_.c_str());
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
std::string ConfigCenter::GetFileTransferSavePath() const {
std::lock_guard<std::mutex> lock(file_path_mutex_);
return file_transfer_save_path_;
}
} // namespace crossdesk
@@ -8,6 +8,9 @@
#define _CONFIG_CENTER_H_
#include <atomic>
#include <initializer_list>
#include <mutex>
#include <utility>
#include <string>
#include "SimpleIni.h"
@@ -73,6 +76,9 @@ class ConfigCenter {
int Save();
private:
int CommitIni();
int StoreValues(std::initializer_list<std::pair<const char*, std::string>> values);
mutable std::mutex file_path_mutex_;
std::string config_path_;
CSimpleIniA ini_;
const char* section_ = "Settings";
@@ -54,6 +54,9 @@
#include "platform/linux/gui/cuda_graphics.h"
#include "platform/linux/gui/tray/linux_tray.h"
#include "platform/linux/headless/headless_console.h"
// Xlib defines Status as a macro, which collides with UpdateChecker::Status.
#undef Status
#endif
#include "rd_log.h"
#include "server_window_state.h"
@@ -990,6 +993,15 @@ int GuiApplication::Run() {
}
InitializeSettings();
#ifdef __linux__
HeadlessConsole::Instance().BindSettings(
config_center_.get(), [this] {
std::lock_guard<std::mutex> lock(password_change_mutex_);
return HasActiveSession() || password_change_pending_ || credential_recovery_in_progress_;
}, [this](SettingEffect effect) {
ApplySettingsFromConfig(effect == SettingEffect::reconnect);
});
#endif
if (!InitializeSDL()) {
return -1;
}
@@ -1760,6 +1772,17 @@ void GuiApplication::Tick() {
if (!peer_) {
CreateConnectionPeer();
}
#ifdef __linux__
HeadlessConsole::Instance().Poll(
signal_connected_.load(),
[this](const std::string& password) { RequestPasswordChange(password); },
[this] {
const auto password = GenerateRandomPassword(password_saved_);
if (password) RequestPasswordChange(*password);
else HeadlessConsole::Instance().NotifyKey("console_random_failed");
},
[this] { exit_ = true; });
#endif
SDL_Event event;
#if defined(__APPLE__)
@@ -2019,7 +2042,17 @@ void GuiApplication::ShareLocalCursorState() {
}
bool GuiApplication::RequestPasswordChange(const std::string &password) {
if (password.size() != 6) {
const auto notify = [](const std::string& message) {
#ifdef __linux__
HeadlessConsole::Instance().NotifyKey(message.c_str());
#else
(void)message;
#endif
};
if (password.size() != 6 ||
password.find_first_not_of("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
std::string::npos) {
notify("console_password_invalid");
return false;
}
@@ -2027,6 +2060,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
offline_warning_text_ =
localization::signal_disconnected[localization_language_index_];
show_offline_warning_window_ = true;
notify("console_password_offline");
return true;
}
@@ -2034,6 +2068,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
{
std::lock_guard<std::mutex> lock(password_change_mutex_);
if (password_change_pending_) {
notify("console_password_pending");
return true;
}
request_id =
@@ -2066,6 +2101,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
offline_warning_text_ = localization::failed[localization_language_index_];
show_offline_warning_window_ = true;
LOG_ERROR("Could not durably stage password change");
notify("console_password_stage_failed");
return true;
}
@@ -2086,6 +2122,9 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
if (!settings_.ClearPendingPasswordChange()) {
LOG_WARN("Could not clear unsent pending password change");
}
notify("console_password_send_failed");
} else {
notify("console_password_submitted");
}
return true;
}
@@ -2123,6 +2162,10 @@ void GuiApplication::HandlePasswordChangeResult() {
}
if (!succeeded) {
#ifdef __linux__
HeadlessConsole::Instance().NotifyKey(
uncertain ? "console_password_uncertain" : "console_password_rejected");
#endif
if (uncertain) {
LOG_WARN("Password change outcome is unknown: {}; re-authenticating",
error);
@@ -2150,6 +2193,9 @@ void GuiApplication::HandlePasswordChangeResult() {
}
LOG_INFO("Password changed successfully for [{}]", client_id_);
#ifdef __linux__
HeadlessConsole::Instance().NotifyKey("console_password_confirmed");
#endif
if (peer_) {
CloseConnectionPeer();
}
@@ -2181,12 +2227,18 @@ void GuiApplication::HandleCredentialRecovery() {
}
if (promote_pending) {
#ifdef __linux__
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
#endif
if (!settings_.PromotePendingPasswordChange()) {
LOG_WARN("Recovered credential is active but promotion remains pending");
} else {
LOG_INFO("Recovered password change with pending credential");
}
} else if (clear_pending) {
#ifdef __linux__
HeadlessConsole::Instance().NotifyKey("console_password_retained");
#endif
if (!settings_.ClearPendingPasswordChange()) {
LOG_WARN("Active credential recovered but pending record could not be "
"cleared");
@@ -3142,7 +3194,6 @@ void GuiApplication::SaveSettingsFromUi() {
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
main->set_force_relay_enabled(config_center_->IsForceRelay());
config_center_->SetSelfHosted(enable_self_hosted_);
config_center_->SetAutostart(enable_autostart_);
config_center_->SetDaemon(enable_daemon_);
#ifdef _WIN32
@@ -3179,6 +3230,28 @@ void GuiApplication::SaveSettingsFromUi() {
config_center_->SetServerPort(*port);
}
config_center_->SetSelfHosted(enable_self_hosted_);
ApplySettingsFromConfig(!HasActiveSession());
}
void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
language_button_value_ = static_cast<int>(config_center_->GetLanguage());
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec();
enable_turn_ = config_center_->IsEnableTurn();
enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart();
enable_daemon_ = config_center_->IsEnableDaemon();
privacy_.SetText({localization::privacy_screen_unlock_hint[localization_language_index_]});
const auto path = config_center_->GetFileTransferSavePath();
std::snprintf(file_transfer_save_path_buf_, sizeof(file_transfer_save_path_buf_), "%s", path.c_str());
const auto host = config_center_->GetSignalServerHost();
std::snprintf(signal_server_ip_self_, sizeof(signal_server_ip_self_), "%s", host.c_str());
const int port = config_center_->GetSignalServerPort();
const auto port_text = port > 0 ? std::to_string(port) : std::string{};
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
language_button_value_last_ = language_button_value_;
video_encode_format_button_value_last_ = video_encode_format_button_value_;
enable_hardware_video_codec_last_ = enable_hardware_video_codec_;
@@ -3187,11 +3260,17 @@ void GuiApplication::SaveSettingsFromUi() {
enable_autostart_last_ = enable_autostart_;
enable_daemon_last_ = enable_daemon_;
file_transfer_save_path_last_ = path;
ui_->localized_language = -1;
if (!HasActiveSession()) {
#ifdef __linux__
HeadlessConsole::Instance().SetLanguage(localization_language_index_);
#endif
if (ui_) {
ui_->localized_language = -1;
ResetSettingsUi();
}
if (reconnect && !HasActiveSession()) {
// Tick recreates the peer after asynchronous cleanup, using the committed
// configuration. Keep the application and its console running throughout.
CloseAllRemoteSessions();
CreateConnectionPeer();
}
}
@@ -3465,6 +3544,9 @@ bool GuiApplication::OpenUrl(const std::string& url) {
}
void GuiApplication::Cleanup() {
#ifdef __linux__
HeadlessConsole::Instance().BindSettings(nullptr, {}, {});
#endif
if (!ui_) {
SDL_Quit();
return;
@@ -71,6 +71,7 @@ private:
void UpdateLocalization();
void ResetSettingsUi();
void SaveSettingsFromUi();
void ApplySettingsFromConfig(bool reconnect);
#if _WIN32 && CROSSDESK_PORTABLE
void CheckPortableWindowsService();
void StartPortableWindowsServiceInstall();
@@ -20,6 +20,65 @@ struct TranslationRow {
// Single source of truth for all UI strings.
#define CROSSDESK_LOCALIZATION_ALL(X) \
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
X(console_status, u8"状态", "Status", u8"Состояние") \
X(console_online, u8"在线", "Online", u8"В сети") \
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
X(console_id, u8"设备 ID", "Device ID", u8"ID устройства") \
X(console_password, u8"连接密码", "Connection password", u8"Пароль подключения") \
X(console_waiting, u8"等待分配", "Waiting for assignment", u8"Ожидание назначения") \
X(console_password_hidden, u8"(仅在交互终端显示)", "(shown only in an interactive terminal)", u8"(виден только в интерактивном терминале)") \
X(console_cancelled, u8"已取消修改。", "Change cancelled.", u8"Изменение отменено.") \
X(console_password_invalid, u8"密码必须是6位英文字母或数字,未提交修改。", "Password must be 6 ASCII letters or digits; no change submitted.", u8"Пароль должен содержать 6 латинских букв или цифр. Изменение не отправлено.") \
X(console_password_offline, u8"尚未连接服务器,暂时无法修改密码。", "Connect to the server before changing the password.", u8"Для смены пароля подключитесь к серверу.") \
X(console_password_prompt, u8"输入新密码(6位英文字母或数字;输入 cancel 取消):", "New password (6 ASCII letters or digits; cancel to abort):", u8"Новый пароль (6 латинских букв или цифр; cancel — отмена):") \
X(console_quitting, u8"正在退出 CrossDesk……", "Exiting CrossDesk...", u8"Выход из CrossDesk...") \
X(console_unknown_command, u8"未知命令,输入 help 查看帮助。", "Unknown command. Enter help for commands.", u8"Неизвестная команда. Введите help для справки.") \
X(console_line_too_long, u8"命令过长,已忽略。", "Command too long; ignored.", u8"Команда слишком длинная и проигнорирована.") \
X(console_random_failed, u8"无法生成随机密码,请重试。", "Unable to generate a random password; try again.", u8"Не удалось создать случайный пароль. Повторите попытку.") \
X(console_password_pending, u8"密码修改正在处理中,请等待服务器确认。", "Password change pending; wait for server confirmation.", u8"Смена пароля выполняется. Дождитесь подтверждения сервера.") \
X(console_password_stage_failed, u8"无法保存密码修改记录,未提交修改。请检查日志。", "Unable to save password recovery record; no change submitted. Check the log.", u8"Не удалось сохранить запись восстановления пароля. Изменение не отправлено. Проверьте журнал.") \
X(console_password_send_failed, u8"发送密码修改请求失败,请等待连接恢复后重试。", "Failed to submit password change; retry after reconnection.", u8"Не удалось отправить запрос смены пароля. Повторите после подключения.") \
X(console_password_submitted, u8"已提交密码修改,正在等待服务器确认……", "Password change submitted; waiting for server confirmation...", u8"Запрос смены пароля отправлен. Ожидание подтверждения...") \
X(console_password_uncertain, u8"密码修改结果尚未确认,正在重新连接服务器核实。", "Password change not yet confirmed; reconnecting to verify.", u8"Смена пароля не подтверждена. Повторное подключение для проверки.") \
X(console_password_rejected, u8"服务器未接受密码修改,原密码保持不变。详情见日志。", "Server rejected the password change; the original password remains. See the log.", u8"Сервер отклонил смену пароля. Старый пароль сохранён. См. журнал.") \
X(console_password_confirmed, u8"服务器已确认密码修改,正在重新连接。", "Server confirmed the password change; reconnecting.", u8"Сервер подтвердил смену пароля. Повторное подключение.") \
X(console_password_recovered, u8"重新连接确认:新密码已生效。", "Reconnection confirmed: the new password is active.", u8"Повторное подключение подтвердило новый пароль.") \
X(console_password_retained, u8"重新连接确认:继续使用原密码。", "Reconnection confirmed: the original password remains active.", u8"Повторное подключение подтвердило прежний пароль.") \
X(console_exited, u8"CrossDesk 已退出。", "CrossDesk exited.", u8"CrossDesk завершён.") \
X(console_stopped, u8"CrossDesk 已停止,退出码", "CrossDesk stopped; exit code", u8"CrossDesk остановлен; код выхода") \
X(console_settings_title, u8"设置(修改后立即保存)", "Settings (each change is saved immediately)", u8"Настройки (изменения сохраняются сразу)") \
X(console_settings_prompt, u8"输入编号或选项名称修改;back 返回。也可使用 settings set 名称 值。", "Enter an item number or key; back to return. Shortcut: settings set KEY VALUE.", u8"Введите номер или имя параметра; back — назад. Команда: settings set KEY VALUE.") \
X(console_settings_value, u8"输入新值(cancel 取消)", "Enter a new value (cancel to abort)", u8"Введите новое значение (cancel — отмена)") \
X(console_settings_unavailable, u8"设置尚未初始化,请稍后重试。", "Settings are not ready; try again shortly.", u8"Настройки ещё не готовы. Повторите позже.") \
X(console_settings_invalid, u8"无效的选项或值,未保存。请按菜单列出的格式输入。", "Invalid setting or value; not saved. Use the format shown in the menu.", u8"Неверный параметр или значение. Ничего не сохранено. Используйте указанный формат.") \
X(console_settings_save_failed, u8"保存设置失败,原设置保持不变。请检查日志。", "Failed to save settings; the previous value is retained. Check the log.", u8"Не удалось сохранить настройки. Прежнее значение сохранено. Проверьте журнал.") \
X(console_settings_busy, u8"有远程会话或密码修改正在进行,暂时不能更改连接设置。", "Connection settings cannot change during a remote session or password change.", u8"Нельзя менять параметры подключения во время удалённого сеанса или смены пароля.") \
X(console_settings_saved, u8"已保存并生效。", "Saved and applied.", u8"Сохранено и применено.") \
X(console_settings_unchanged, u8"设置未改变。", "Setting unchanged.", u8"Настройка не изменилась.") \
X(console_settings_reconnect, u8"已保存,正在重新连接服务器。", "Saved; reconnecting to the server.", u8"Сохранено. Повторное подключение к серверу.") \
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \
X(console_settings_hardware_unavailable, u8"当前构建不支持硬件编解码,未保存。", "Hardware codecs are unavailable in this build; not saved.", u8"Аппаратные кодеки недоступны в этой сборке. Не сохранено.") \
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
X(console_setting_language, u8"语言", "Language", u8"Язык") \
X(console_setting_codec, u8"视频编码", "Video codec", u8"Видеокодек") \
X(console_setting_hardware, u8"硬件编解码", "Hardware codecs", u8"Аппаратные кодеки") \
X(console_setting_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
X(console_setting_port, u8"信令端口", "Signaling port", u8"Порт сигнализации") \
X(console_setting_privacy, u8"连接时自动开启隐私屏", "Privacy screen on connection", u8"Приватный экран при подключении") \
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \
@@ -14,6 +14,9 @@
#include "localization.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#ifdef __linux__
#include "platform/linux/headless/headless_console.h"
#endif
namespace crossdesk {
@@ -92,6 +95,11 @@ int GuiRuntime::CreateConnectionPeer() {
const char *active_identity =
self_hosted ? self_hosted_user_id_ : client_id_with_password_;
#ifdef __linux__
// Publish the committed identity; an unconfirmed recovery credential must
// not be advertised as the current connection password.
HeadlessConsole::Instance().SetIdentity(active_identity);
#endif
const std::string login_identity =
try_pending_identity ? pending_identity : std::string(active_identity);
std::memset(connection_login_identity_, 0,
@@ -22,6 +22,9 @@
#include "platform/video_renderer.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
#ifdef __linux__
#include "platform/linux/headless/headless_console.h"
#endif
#if _WIN32
#include "interactive_state.h"
@@ -496,6 +499,9 @@ void PeerEventHandler::OnNetStatusReport(
LOG_INFO("Use client id [{}] and save id into cache file", id);
runtime->settings_.Save();
}
#ifdef __linux__
HeadlessConsole::Instance().SetIdentity(id + "@" + password);
#endif
}
std::string remote_id(user_id, user_id_size);
@@ -11,6 +11,8 @@ namespace crossdesk::platform {
namespace {
std::filesystem::path AutostartPath(const std::string& app_name) {
const char* config = std::getenv("XDG_CONFIG_HOME");
if (config && *config) return std::filesystem::path(config) / "autostart" / (app_name + ".desktop");
const char* home = std::getenv("HOME");
if (!home || !home[0]) return {};
return std::filesystem::path(home) / ".config" / "autostart" /
@@ -0,0 +1,85 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DESKTOP_SESSION_SELECTION_H_
#define _DESKTOP_SESSION_SELECTION_H_
#include <algorithm>
#include <optional>
#include <string>
#include <vector>
namespace crossdesk {
// Metadata for an online X11 user session, already filtered to our uid.
struct DesktopLoginSession {
std::string display;
bool current = false;
bool primary = false;
bool active = false;
bool remote = false;
};
inline std::string NormalizeDesktopDisplay(std::string display) {
if (display.compare(0, 5, "unix:") == 0) display.erase(0, 4);
if (display.empty() || display[0] != ':') return display;
if (display.size() > 2 && display.compare(display.size() - 2, 2, ".0") == 0) {
display.resize(display.size() - 2);
}
return display;
}
inline int DesktopSessionPriority(
const std::string& display,
const std::vector<DesktopLoginSession>& sessions) {
int priority = 0;
for (const auto& session : sessions) {
if (NormalizeDesktopDisplay(session.display) !=
NormalizeDesktopDisplay(display)) continue;
const int rank = session.current ? 6
: session.primary && session.active ? 5
: session.active && !session.remote ? 4
: session.active ? 3
: session.primary ? 2 : 1;
priority = std::max(priority, rank);
}
return priority;
}
inline bool DesktopDisplayLess(const std::string& left,
const std::string& right) {
const auto a = NormalizeDesktopDisplay(left);
const auto b = NormalizeDesktopDisplay(right);
const auto is_number = [](const std::string& value) {
return value.size() > 1 && value[0] == ':' &&
value.find_first_not_of("0123456789", 1) == std::string::npos;
};
// Stable numeric order when logind is unavailable or sessions have equal
// priority; do not depend on filesystem enumeration order (or sort :10 < :2).
if (is_number(a) && is_number(b) && a.size() != b.size()) {
return a.size() < b.size();
}
return a < b;
}
inline std::optional<size_t> SelectDesktopSession(
const std::vector<std::string>& available,
const std::vector<DesktopLoginSession>& sessions) {
if (available.empty()) return std::nullopt;
size_t selected = 0;
for (size_t i = 1; i < available.size(); ++i) {
const int candidate = DesktopSessionPriority(available[i], sessions);
const int current = DesktopSessionPriority(available[selected], sessions);
if (candidate > current ||
(candidate == current &&
DesktopDisplayLess(available[i], available[selected]))) selected = i;
}
return selected;
}
} // namespace crossdesk
#endif
@@ -0,0 +1,357 @@
#include "headless_console.h"
#include <fcntl.h>
#include <poll.h>
#include <unistd.h>
#include <algorithm>
#include <charconv>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <iostream>
#include "rd_log.h"
#include "config_center.h"
#include "localization.h"
namespace crossdesk {
namespace {
bool ValidPassword(const std::string& password) {
if (password.size() != 6) return false;
for (unsigned char c : password) {
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9'))) return false;
}
return true;
}
} // namespace
HeadlessConsole& HeadlessConsole::Instance() {
static HeadlessConsole console;
return console;
}
HeadlessConsole::~HeadlessConsole() {
if (!active()) return;
std::cout.flush();
std::cerr.flush();
fflush(nullptr);
dup2(output_, STDOUT_FILENO);
dup2(error_, STDERR_FILENO);
close(output_);
close(error_);
}
bool HeadlessConsole::Enable(const std::filesystem::path& log_directory,
const std::filesystem::path& config_file) {
if (active()) return true;
std::error_code error;
std::filesystem::create_directories(log_directory, error);
if (error) return false;
// Existing loggers keep their own rotating files and filenames. Suppress
// only stdout/stderr copies; do not create a second diagnostic log stream.
const int quiet = open("/dev/null", O_WRONLY | O_CLOEXEC);
if (quiet < 0) return false;
const int out = fcntl(STDOUT_FILENO, F_DUPFD_CLOEXEC, 3);
const int err = fcntl(STDERR_FILENO, F_DUPFD_CLOEXEC, 3);
if (out < 0 || err < 0) {
if (out >= 0) close(out);
if (err >= 0) close(err);
close(quiet);
return false;
}
std::cout.flush();
std::cerr.flush();
fflush(nullptr);
if (dup2(quiet, STDOUT_FILENO) < 0 || dup2(quiet, STDERR_FILENO) < 0) {
dup2(out, STDOUT_FILENO);
dup2(err, STDERR_FILENO);
close(out);
close(err);
close(quiet);
return false;
}
close(quiet);
output_ = out;
error_ = err;
interactive_ = isatty(STDIN_FILENO) && isatty(output_);
// Initialize after redirecting output so even the logger's first message
// stays in its original crossdesk-YYYYMMDD-HHMMSS.log file.
try {
InitLogger(log_directory.string());
for (const auto& sink : get_logger()->sinks()) {
if (const auto file =
std::dynamic_pointer_cast<spdlog::sinks::rotating_file_sink_mt>(sink)) {
diagnostic_path_ = file->filename();
break;
}
}
} catch (const std::exception&) {
dup2(output_, STDOUT_FILENO);
dup2(error_, STDERR_FILENO);
close(output_);
close(error_);
output_ = error_ = -1;
return false;
}
if (!config_file.empty()) {
ConfigCenter config(config_file.string());
SetLanguage(static_cast<int>(config.GetLanguage()));
}
Write(Text("console_title") + "\n" + Text("console_log_file") + ": " + diagnostic_path_ + "\n");
Help();
return true;
}
void HeadlessConsole::Write(const std::string& text) {
if (!active()) return;
size_t offset = 0;
while (offset < text.size()) {
const ssize_t count = write(output_, text.data() + offset, text.size() - offset);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) break;
offset += static_cast<size_t>(count);
}
}
void HeadlessConsole::Notify(const std::string& message) {
if (active()) Write(message + "\n");
}
void HeadlessConsole::SetIdentity(const std::string& identity) {
if (!active()) return;
const auto separator = identity.find('@');
const std::string id = identity.substr(0, separator);
const std::string password = separator == std::string::npos
? "" : identity.substr(separator + 1);
// Credentials are rendered to a terminal, never interpreted as terminal
// escape sequences and never forwarded to the diagnostic log.
if (id.find_first_not_of("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_") !=
std::string::npos || (!password.empty() && !ValidPassword(password))) return;
std::lock_guard<std::mutex> lock(identity_mutex_);
id_ = id;
password_ = password;
}
const std::string& HeadlessConsole::Text(const char* key) const {
return localization::detail::GetTranslatedText(key, language_);
}
void HeadlessConsole::NotifyKey(const char* key) { Notify(Text(key)); }
void HeadlessConsole::SetLanguage(int language) {
language = localization::detail::ClampLanguageIndex(language);
if (language_ != language) {
language_ = language;
displayed_ = false;
}
}
void HeadlessConsole::BindSettings(ConfigCenter* config,
std::function<bool()> connection_busy,
std::function<void(SettingEffect)> applied) {
config_ = config;
connection_busy_ = std::move(connection_busy);
settings_applied_ = std::move(applied);
if (config_) SetLanguage(static_cast<int>(config_->GetLanguage()));
}
void HeadlessConsole::Help() { NotifyKey("console_help"); }
void HeadlessConsole::ShowSettings() {
if (!config_) { NotifyKey("console_settings_unavailable"); return; }
Write("\n" + Text("console_settings_title") + "\n");
const auto settings = GetConsoleSettings(*config_);
for (size_t i = 0; i < settings.size(); ++i) {
const auto& item = settings[i];
std::string value = item.value;
for (auto& c : value) if (static_cast<unsigned char>(c) < 32 || c == 127) c = '?';
Write(std::to_string(i + 1) + ". " + Text(item.label) + " [" + item.key +
"] = " + value + " (" + item.choices + ")\n");
}
NotifyKey("console_settings_prompt");
}
void HeadlessConsole::ChangeSetting(const std::string& key, const std::string& value) {
if (!config_) { NotifyKey("console_settings_unavailable"); return; }
const auto result = ApplyConsoleSetting(*config_, key, value,
connection_busy_ && connection_busy_());
SetLanguage(static_cast<int>(config_->GetLanguage()));
if (result.saved && result.effect != SettingEffect::none && settings_applied_) {
settings_applied_(result.effect);
}
NotifyKey(result.message);
}
bool HeadlessConsole::SettingsCommand(const std::string& line) {
if (line == "settings" || line == "settings show") {
waiting_password_ = false;
selected_setting_.clear();
settings_menu_ = line == "settings";
ShowSettings();
return true;
}
if (line.compare(0, 13, "settings set ") == 0) {
waiting_password_ = false;
selected_setting_.clear();
const auto separator = line.find(' ', 13);
if (separator == std::string::npos) NotifyKey("console_settings_invalid");
else ChangeSetting(line.substr(13, separator - 13), line.substr(separator + 1));
return true;
}
if (!settings_menu_) return false;
if (line == "back" || (line == "cancel" && selected_setting_.empty())) {
settings_menu_ = false;
selected_setting_.clear();
Help();
return true;
}
if (line == "cancel") {
selected_setting_.clear();
NotifyKey("console_cancelled");
ShowSettings();
return true;
}
// Status and quit remain available while navigating the menu.
if (line == "status" || line == "help" || line == "?" || line == "quit" || line == "exit") return false;
if (!selected_setting_.empty()) {
ChangeSetting(selected_setting_, line);
selected_setting_.clear();
ShowSettings();
return true;
}
if (!config_) { NotifyKey("console_settings_unavailable"); return true; }
const auto settings = GetConsoleSettings(*config_);
size_t index = 0;
const auto parsed = std::from_chars(line.data(), line.data() + line.size(), index);
if (parsed.ec == std::errc{} && parsed.ptr == line.data() + line.size() &&
index > 0 && index <= settings.size()) {
selected_setting_ = settings[index - 1].key;
} else {
for (const auto& setting : settings) if (setting.key == line) selected_setting_ = setting.key;
}
if (selected_setting_.empty()) {
NotifyKey("console_settings_invalid");
} else {
const auto item = std::find_if(settings.begin(), settings.end(),
[&](const auto& setting) { return setting.key == selected_setting_; });
Write(Text(item->label) + " [" + item->choices + "]\n" + Text("console_settings_value") + ":\n");
}
return true;
}
void HeadlessConsole::ShowStatus(bool connected) {
{
std::lock_guard<std::mutex> lock(identity_mutex_);
displayed_id_ = id_;
displayed_password_ = password_;
}
displayed_connected_ = connected;
displayed_ = true;
Write("\n" + Text("console_status") + ": " + Text(connected ? "console_online" : "console_offline") +
"\n" + Text("console_id") + ": " + (displayed_id_.empty() ? Text("console_waiting") : displayed_id_) +
"\n" + Text("console_password") + ": " +
(!interactive_ ? Text("console_password_hidden")
: displayed_password_.empty() ? Text("console_waiting") : displayed_password_) + "\n");
}
void HeadlessConsole::Command(
const std::string& line, bool connected,
const std::function<void(const std::string&)>& password,
const std::function<void()>& random_password,
const std::function<void()>& quit) {
if (line == "quit" || line == "exit") {
input_closed_ = true;
NotifyKey("console_quitting");
quit();
return;
}
if (SettingsCommand(line)) return;
if (waiting_password_) {
waiting_password_ = false;
if (line.empty() || line == "cancel") {
NotifyKey("console_cancelled");
} else if (!ValidPassword(line)) {
NotifyKey("console_password_invalid");
} else {
password(line);
}
} else if (line == "status") {
ShowStatus(connected);
} else if (line == "help" || line == "?") {
Help();
} else if (line == "password" || line == "passwd") {
if (!connected) {
NotifyKey("console_password_offline");
} else {
waiting_password_ = true;
NotifyKey("console_password_prompt");
}
} else if (line.compare(0, 9, "password ") == 0 ||
line.compare(0, 7, "passwd ") == 0) {
const std::string value = line.substr(line.find(' ') + 1);
if (!ValidPassword(value)) {
NotifyKey("console_password_invalid");
} else if (!connected) {
NotifyKey("console_password_offline");
} else {
password(value);
}
} else if (line == "random") {
if (connected) random_password();
else NotifyKey("console_password_offline");
} else if (!line.empty()) {
NotifyKey("console_unknown_command");
}
}
void HeadlessConsole::Poll(
bool connected, const std::function<void(const std::string&)>& password,
const std::function<void()>& random_password,
const std::function<void()>& quit) {
if (!active()) return;
if (config_) SetLanguage(static_cast<int>(config_->GetLanguage()));
bool changed = !displayed_ || displayed_connected_ != connected;
{
std::lock_guard<std::mutex> lock(identity_mutex_);
changed |= displayed_id_ != id_ || displayed_password_ != password_;
}
if (changed) ShowStatus(connected);
if (input_closed_) return;
// A job launched in the background must not steal input or receive SIGTTIN.
if (isatty(STDIN_FILENO) && tcgetpgrp(STDIN_FILENO) != getpgrp()) return;
struct pollfd input { STDIN_FILENO, POLLIN, 0 };
if (poll(&input, 1, 0) <= 0) return;
if (!(input.revents & (POLLIN | POLLHUP))) {
if (input.revents & (POLLERR | POLLNVAL)) input_closed_ = true;
return;
}
char buffer[256];
const ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer));
if (count <= 0) {
if (count == 0 || (errno != EINTR && errno != EAGAIN)) input_closed_ = true;
return;
}
for (ssize_t i = 0; i < count && !input_closed_; ++i) {
const char c = buffer[i];
if (c == '\n') {
if (!discarding_line_) {
if (!input_.empty() && input_.back() == '\r') input_.pop_back();
Command(input_, connected, password, random_password, quit);
}
input_.clear();
discarding_line_ = false;
} else if (!discarding_line_) {
if (input_.size() == 1024) {
input_.clear();
discarding_line_ = true;
waiting_password_ = false;
NotifyKey("console_line_too_long");
} else {
input_ += c;
}
}
}
}
} // namespace crossdesk
@@ -0,0 +1,78 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _HEADLESS_CONSOLE_H_
#define _HEADLESS_CONSOLE_H_
#include <filesystem>
#include <functional>
#include <mutex>
#include <string>
#include "headless_settings.h"
namespace crossdesk {
// Enabled before display discovery and fork, with no input thread. The GUI
// thread polls commands; transport callbacks only publish credential snapshots.
class HeadlessConsole {
public:
static HeadlessConsole& Instance();
~HeadlessConsole();
bool Enable(const std::filesystem::path& log_directory,
const std::filesystem::path& config_file = {});
bool active() const { return output_ >= 0; }
const std::string& diagnostic_path() const { return diagnostic_path_; }
void SetIdentity(const std::string& identity);
void Notify(const std::string& message);
const std::string& Text(const char* key) const;
void NotifyKey(const char* key);
void SetLanguage(int language);
void BindSettings(ConfigCenter* config, std::function<bool()> connection_busy,
std::function<void(SettingEffect)> applied);
void Poll(bool connected, const std::function<void(const std::string&)>& password,
const std::function<void()>& random_password,
const std::function<void()>& quit);
private:
HeadlessConsole() = default;
void Write(const std::string& text);
void ShowStatus(bool connected);
void Help();
void ShowSettings();
void ChangeSetting(const std::string& key, const std::string& value);
bool SettingsCommand(const std::string& line);
void Command(const std::string& line, bool connected,
const std::function<void(const std::string&)>& password,
const std::function<void()>& random_password,
const std::function<void()>& quit);
int output_ = -1;
int error_ = -1;
bool interactive_ = false;
bool input_closed_ = false;
bool waiting_password_ = false;
bool discarding_line_ = false;
std::string input_;
std::string diagnostic_path_;
std::mutex identity_mutex_;
std::string id_;
std::string password_;
std::string displayed_id_;
std::string displayed_password_;
bool displayed_connected_ = false;
bool displayed_ = false;
int language_ = 0;
ConfigCenter* config_ = nullptr;
std::function<bool()> connection_busy_;
std::function<void(SettingEffect)> settings_applied_;
bool settings_menu_ = false;
std::string selected_setting_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,657 @@
#include "headless_session.h"
#include "desktop_session_selection.h"
#include "rd_log.h"
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <sys/prctl.h>
#include <sys/random.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
using namespace std::chrono_literals;
volatile sig_atomic_t stop_signal = 0;
void RequestStop(int signal) { stop_signal = signal; }
// Bootstrap runs before GUI initialization, and may fork. Reuse the existing
// application logger when the headless console has silenced standard streams.
// Callers without a prepared console retain their normal stderr diagnostics.
bool log_diagnostics_to_file = false;
class Diagnostic {
public:
explicit Diagnostic(spdlog::level::level_enum level = spdlog::level::err)
: level_(level) {}
template <typename T> Diagnostic& operator<<(const T& value) {
text_ << value;
return *this;
}
~Diagnostic() {
std::string message = text_.str();
if (!log_diagnostics_to_file) {
std::cerr << message;
return;
}
if (!message.empty() && message.back() == '\n') message.pop_back();
get_logger()->log(level_, "{}", message);
}
private:
spdlog::level::level_enum level_;
std::ostringstream text_;
};
bool HasEnv(const char* name) {
const char* value = std::getenv(name);
return value && *value;
}
bool ParseDimension(const std::string& value, int& result) {
const auto parsed =
std::from_chars(value.data(), value.data() + value.size(), result);
return parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size() &&
result >= 320 && result <= 8192 && result % 2 == 0;
}
struct Options {
bool force = false;
bool disabled = false;
bool help = false;
bool new_session = false;
std::string size = "1920x1080";
std::string session;
std::string display;
};
bool ParseOptions(int argc, char* argv[], Options& options) {
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "--headless") {
options.force = true;
} else if (arg == "--no-headless") {
options.disabled = true;
} else if (arg == "--headless-help") {
options.help = true;
} else if (arg == "--headless-size" || arg == "--headless-session" ||
arg == "--headless-display") {
if (++i == argc || argv[i][0] == '\0') {
Diagnostic() << arg << " requires a value\n";
return false;
}
if (arg == "--headless-size") {
options.size = argv[i];
options.new_session = true;
} else if (arg == "--headless-session") {
options.session = argv[i];
options.new_session = true;
} else {
options.display = argv[i];
}
options.force = true;
} else if (arg.rfind("--headless", 0) == 0) {
Diagnostic() << "Unknown option: " << arg << '\n';
return false;
}
}
int width = 0, height = 0;
const auto separator = options.size.find('x');
if (separator == std::string::npos ||
!ParseDimension(options.size.substr(0, separator), width) ||
!ParseDimension(options.size.substr(separator + 1), height)) {
Diagnostic() << "--headless-size requires WIDTHxHEIGHT with even dimensions "
"between 320 and 8192\n";
return false;
}
if (options.force && options.disabled) {
Diagnostic() << "--no-headless conflicts with --headless options\n";
return false;
}
if (!options.display.empty() && options.new_session) {
Diagnostic() << "--headless-display reuses an existing desktop and cannot be "
"combined with --headless-size or --headless-session\n";
return false;
}
return true;
}
struct ExistingDesktop {
std::string display;
std::optional<std::string> authority;
};
std::vector<DesktopLoginSession> GetDesktopLoginSessions() {
// Optional runtime integration: non-systemd distributions and containers
// still discover X11 desktops without a build/runtime dependency on logind.
struct Library {
void* handle = dlopen("libsystemd.so.0", RTLD_LAZY | RTLD_LOCAL);
~Library() { if (handle) dlclose(handle); }
} library;
if (!library.handle) return {};
using UserSessions = int (*)(uid_t, int, char***);
using SessionString = int (*)(const char*, char**);
using SessionFlag = int (*)(const char*);
using UserDisplay = int (*)(uid_t, char**);
using PidSession = int (*)(pid_t, char**);
const auto list = reinterpret_cast<UserSessions>(
dlsym(library.handle, "sd_uid_get_sessions"));
const auto type = reinterpret_cast<SessionString>(
dlsym(library.handle, "sd_session_get_type"));
const auto session_class = reinterpret_cast<SessionString>(
dlsym(library.handle, "sd_session_get_class"));
const auto state = reinterpret_cast<SessionString>(
dlsym(library.handle, "sd_session_get_state"));
const auto display = reinterpret_cast<SessionString>(
dlsym(library.handle, "sd_session_get_display"));
const auto active = reinterpret_cast<SessionFlag>(
dlsym(library.handle, "sd_session_is_active"));
const auto remote = reinterpret_cast<SessionFlag>(
dlsym(library.handle, "sd_session_is_remote"));
const auto primary = reinterpret_cast<UserDisplay>(
dlsym(library.handle, "sd_uid_get_display"));
const auto current = reinterpret_cast<PidSession>(
dlsym(library.handle, "sd_pid_get_session"));
if (!list || !type || !session_class || !state || !display) return {};
auto read = [](SessionString function, const char* id) {
char* value = nullptr;
const int status = function(id, &value);
std::string result = status >= 0 && value ? value : "";
free(value);
return result;
};
char* value = nullptr;
std::string primary_id, current_id;
if (primary && primary(getuid(), &value) >= 0 && value) primary_id = value;
free(value);
value = nullptr;
if (current && current(0, &value) >= 0 && value) current_id = value;
free(value);
if (current_id.empty() && HasEnv("XDG_SESSION_ID")) {
current_id = std::getenv("XDG_SESSION_ID");
}
char** ids = nullptr;
const int count = list(getuid(), 0, &ids);
if (count < 0 || !ids) return {};
std::vector<DesktopLoginSession> sessions;
for (int i = 0; i < count; ++i) {
const std::string kind = read(session_class, ids[i]);
const std::string status = read(state, ids[i]);
if (read(type, ids[i]) == "x11" &&
(kind == "user" || kind == "user-early") &&
(status == "online" || status == "active")) {
const auto address = NormalizeDesktopDisplay(read(display, ids[i]));
// Only local displays, never greeters, SSH forwarding or other users.
if (address.size() > 1 && address[0] == ':' &&
address.find_first_not_of("0123456789.", 1) == std::string::npos) {
sessions.push_back({address, current_id == ids[i], primary_id == ids[i],
active && active(ids[i]) > 0,
remote && remote(ids[i]) > 0});
}
}
free(ids[i]);
}
free(ids);
return sessions;
}
void SetAuthority(const std::optional<std::string>& authority) {
if (authority) {
setenv("XAUTHORITY", authority->c_str(), 1);
} else {
unsetenv("XAUTHORITY");
}
}
Window ReadWindowProperty(Display* display, Window window, Atom property) {
Atom type = None;
int format = 0;
unsigned long count = 0, remaining = 0;
unsigned char* data = nullptr;
Window result = None;
if (XGetWindowProperty(display, window, property, 0, 1, False, XA_WINDOW,
&type, &format, &count, &remaining, &data) == Success &&
type == XA_WINDOW && format == 32 && count == 1 && data) {
result = *reinterpret_cast<Window*>(data);
}
if (data) XFree(data);
return result;
}
bool HasDesktop(const std::string& address) {
Display* display = XOpenDisplay(address.c_str());
if (!display) return false;
// A root framebuffer alone may be the empty Xvfb that caused the black
// background. Require a live EWMH window manager, including its self-check.
// Discovery runs before any threads; a WM disappearing mid-probe is benign.
auto previous = XSetErrorHandler([](Display*, XErrorEvent*) { return 0; });
const Window root = DefaultRootWindow(display);
XWindowAttributes geometry{};
const Atom property = XInternAtom(display, "_NET_SUPPORTING_WM_CHECK", True);
const Window manager = property == None ? None
: ReadWindowProperty(display, root, property);
const bool valid = manager != None &&
ReadWindowProperty(display, manager, property) == manager &&
XGetWindowAttributes(display, root, &geometry) &&
geometry.width > 1 && geometry.height > 1;
XCloseDisplay(display);
XSetErrorHandler(previous);
return valid;
}
std::vector<ExistingDesktop> FindExistingDesktops(
const std::string& requested,
const std::vector<DesktopLoginSession>& sessions) {
const char* value = std::getenv("XAUTHORITY");
const std::optional<std::string> original =
value ? std::optional<std::string>(value) : std::nullopt;
std::vector<std::optional<std::string>> authorities{original};
// An explicit authority must be respected, including for automation and
// isolated tests. Otherwise also try GDM's per-user authority file.
if (!original) {
const std::string gdm = "/run/user/" + std::to_string(getuid()) +
"/gdm/Xauthority";
struct stat info{};
if (stat(gdm.c_str(), &info) == 0 && info.st_uid == getuid() &&
S_ISREG(info.st_mode) && access(gdm.c_str(), R_OK) == 0) {
authorities.emplace_back(gdm);
}
}
auto probe = [&](const std::string& address) -> std::optional<ExistingDesktop> {
for (const auto& authority : authorities) {
SetAuthority(authority);
if (HasDesktop(address)) return ExistingDesktop{address, authority};
}
return std::nullopt;
};
std::vector<ExistingDesktop> matches;
if (!requested.empty()) {
if (auto match = probe(requested)) matches.push_back(*match);
} else if (HasEnv("DISPLAY")) {
// An explicit, usable desktop is unambiguous even with several logins.
if (auto match = probe(std::getenv("DISPLAY"))) matches.push_back(*match);
}
if (requested.empty() && matches.empty()) {
std::vector<std::string> candidates;
// logind may identify a desktop whose X socket is owned by root. Its
// session still belongs to our uid and must pass the same X11/WM probes.
for (const auto& session : sessions) candidates.push_back(session.display);
std::error_code error;
std::filesystem::directory_iterator it("/tmp/.X11-unix", error), end;
for (; !error && it != end; it.increment(error)) {
const std::string name = it->path().filename().string();
struct stat info{};
if (name.size() > 1 && name[0] == 'X' &&
name.find_first_not_of("0123456789", 1) == std::string::npos &&
lstat(it->path().c_str(), &info) == 0 && S_ISSOCK(info.st_mode) &&
info.st_uid == getuid()) {
candidates.push_back(":" + name.substr(1));
}
}
std::sort(candidates.begin(), candidates.end(), DesktopDisplayLess);
candidates.erase(std::unique(candidates.begin(), candidates.end()),
candidates.end());
for (const auto& address : candidates) {
if (auto match = probe(address)) matches.push_back(*match);
}
}
SetAuthority(original);
return matches;
}
void ConfigureHeadlessX11(const std::string& display) {
// Capture, input and all GUI components must use the same X11 desktop,
// including when inheriting Wayland/DRM or NVIDIA PRIME hints from a shell.
setenv("DISPLAY", display.c_str(), 1);
setenv("XDG_SESSION_TYPE", "x11", 1);
unsetenv("WAYLAND_DISPLAY");
unsetenv("WAYLAND_SOCKET");
setenv("CROSSDESK_SCREEN_BACKEND", "x11", 1);
setenv("SDL_VIDEODRIVER", "x11", 1);
setenv("SLINT_BACKEND", "winit-software", 1);
setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1);
unsetenv("__NV_PRIME_RENDER_OFFLOAD");
unsetenv("__NV_PRIME_RENDER_OFFLOAD_PROVIDER");
unsetenv("__GLX_VENDOR_LIBRARY_NAME");
}
std::string FindExecutable(const std::string& name) {
if (name.find('/') != std::string::npos) {
return access(name.c_str(), X_OK) == 0 ? name : std::string{};
}
const char* path = std::getenv("PATH");
std::string paths = path ? path : "/usr/bin:/bin";
size_t start = 0;
do {
const auto end = paths.find(':', start);
const std::string directory = paths.substr(start, end - start);
const std::string candidate =
(directory.empty() ? "." : directory) + "/" + name;
if (access(candidate.c_str(), X_OK) == 0) return candidate;
if (end == std::string::npos) break;
start = end + 1;
} while (start <= paths.size());
return {};
}
// Xauthority's binary format consists of a family and four counted strings,
// all lengths in network byte order. FamilyWild and an empty display number
// allow the cookie to be prepared before Xvfb allocates its display number.
class PrivateAuthority {
public:
bool Create() {
char directory[] = "/tmp/crossdesk-headless-XXXXXX";
if (!mkdtemp(directory)) return false; // mode 0700
directory_ = directory;
path_ = directory_ + "/Xauthority";
unsigned char cookie[16];
size_t offset = 0;
while (offset < sizeof(cookie)) {
const ssize_t count = getrandom(cookie + offset, sizeof(cookie) - offset, 0);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) return false;
offset += count;
}
const int fd = open(path_.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC,
S_IRUSR | S_IWUSR);
if (fd < 0) return false;
FILE* file = fdopen(fd, "wb");
if (!file) {
close(fd);
return false;
}
auto word = [file](unsigned int value) {
return fputc((value >> 8) & 255, file) != EOF &&
fputc(value & 255, file) != EOF;
};
constexpr char protocol[] = "MIT-MAGIC-COOKIE-1";
const bool written = word(65535) && word(0) && word(0) &&
word(sizeof(protocol) - 1) &&
fwrite(protocol, 1, sizeof(protocol) - 1, file) ==
sizeof(protocol) - 1 &&
word(sizeof(cookie)) &&
fwrite(cookie, 1, sizeof(cookie), file) == sizeof(cookie);
const bool closed = fclose(file) == 0;
return written && closed;
}
~PrivateAuthority() {
if (!path_.empty()) unlink(path_.c_str());
if (!directory_.empty()) rmdir(directory_.c_str());
}
const std::string& path() const { return path_; }
private:
std::string directory_;
std::string path_;
};
class SignalGuard {
public:
SignalGuard() {
stop_signal = 0;
struct sigaction action {};
action.sa_handler = RequestStop;
sigemptyset(&action.sa_mask);
sigaction(SIGINT, &action, &old_int_);
sigaction(SIGTERM, &action, &old_term_);
sigaction(SIGHUP, &action, &old_hup_);
}
~SignalGuard() {
sigaction(SIGINT, &old_int_, nullptr);
sigaction(SIGTERM, &old_term_, nullptr);
sigaction(SIGHUP, &old_hup_, nullptr);
}
private:
struct sigaction old_int_ {}, old_term_ {}, old_hup_ {};
};
class Child {
public:
Child() = default;
Child(const Child&) = delete;
Child& operator=(const Child&) = delete;
~Child() { Stop(); }
bool Start(const std::function<int()>& run, bool own_group = true) {
own_group_ = own_group;
const pid_t parent = getpid();
pid_ = fork();
if (pid_ == 0) {
if (own_group_) setpgid(0, 0);
signal(SIGINT, SIG_DFL);
signal(SIGTERM, SIG_DFL);
signal(SIGHUP, SIG_DFL);
// Also stop direct children if the supervisor itself is killed.
if (prctl(PR_SET_PDEATHSIG, SIGTERM) != 0 || getppid() != parent) _exit(1);
_exit(run());
}
if (pid_ < 0) return false;
if (own_group_) setpgid(pid_, pid_);
return true;
}
bool Exited(int* code = nullptr) const {
if (pid_ <= 0) return false;
siginfo_t info{};
// Leave the child waitable until Stop(), preventing pid/group reuse.
if (waitid(P_PID, pid_, &info, WEXITED | WNOHANG | WNOWAIT) != 0 ||
info.si_pid == 0) return false;
if (code) {
*code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status;
}
return true;
}
void Stop() {
if (pid_ <= 0) return;
const pid_t target = own_group_ ? -pid_ : pid_;
kill(target, SIGTERM);
const auto deadline = Clock::now() + 2s;
while (!Exited() && Clock::now() < deadline) std::this_thread::sleep_for(25ms);
kill(target, SIGKILL); // Include descendants of desktop session scripts.
while (waitpid(pid_, nullptr, 0) < 0 && errno == EINTR) {}
pid_ = -1;
}
private:
pid_t pid_ = -1;
bool own_group_ = true;
};
int Exec(const std::vector<std::string>& arguments) {
std::vector<char*> argv;
for (const auto& arg : arguments) argv.push_back(const_cast<char*>(arg.c_str()));
argv.push_back(nullptr);
execv(argv[0], argv.data());
Diagnostic() << "Cannot start " << arguments[0] << ": " << std::strerror(errno)
<< '\n';
return 127;
}
bool StartServer(Child& server, const std::string& executable,
const Options& options, const PrivateAuthority& authority,
std::string& display) {
int pipe_fds[2];
if (pipe2(pipe_fds, O_CLOEXEC | O_NONBLOCK) != 0) return false;
const bool started = server.Start([&] {
close(pipe_fds[0]);
fcntl(pipe_fds[1], F_SETFD, 0);
return Exec({executable, "-displayfd", std::to_string(pipe_fds[1]),
"-screen", "0", options.size + "x24", "-nolisten", "tcp",
"-auth", authority.path(), "-noreset"});
});
close(pipe_fds[1]);
std::string number;
const auto deadline = Clock::now() + 10s;
while (started && !stop_signal && !server.Exited() && Clock::now() < deadline) {
struct pollfd descriptor { pipe_fds[0], POLLIN, 0 };
if (poll(&descriptor, 1, 50) <= 0) continue;
char buffer[32];
const ssize_t size = read(pipe_fds[0], buffer, sizeof(buffer));
if (size <= 0) break;
number.append(buffer, size);
if (number.find('\n') != std::string::npos || number.size() > 16) break;
}
close(pipe_fds[0]);
if (number.size() < 2 || number.back() != '\n') return false;
number.pop_back();
if (number.find_first_not_of("0123456789") != std::string::npos) return false;
display = ":" + number;
return true;
}
int RunHeadless(const Options& options, const std::function<int()>& run) {
const std::string xvfb = FindExecutable("Xvfb");
if (xvfb.empty()) {
Diagnostic() << "No usable display. Headless mode requires Xvfb; on Debian/Ubuntu "
"install it with: sudo apt install xvfb\n";
return 1;
}
std::string session;
std::string dbus;
if (!options.session.empty() && options.session != "none") {
session = FindExecutable(options.session);
dbus = FindExecutable("dbus-run-session");
if (session.empty() || dbus.empty()) {
Diagnostic() << "Headless desktop requires an executable --headless-session "
"and dbus-run-session (Debian/Ubuntu: dbus-daemon)\n";
return 1;
}
}
SignalGuard signals;
PrivateAuthority authority;
if (!authority.Create()) {
Diagnostic() << "Cannot create private headless Xauthority: "
<< std::strerror(errno) << '\n';
return 1;
}
Child server;
std::string display;
if (!StartServer(server, xvfb, options, authority, display)) {
Diagnostic() << "Xvfb failed to become ready within 10 seconds\n";
return stop_signal ? 128 + stop_signal : 1;
}
ConfigureHeadlessX11(display);
setenv("XAUTHORITY", authority.path().c_str(), 1);
setenv("CROSSDESK_HEADLESS_ACTIVE", "1", 1);
Display* probe = XOpenDisplay(nullptr);
if (!probe) {
Diagnostic() << "Cannot connect to the private Xvfb display\n";
return 1;
}
XSetScreenSaver(probe, 0, 0, DefaultBlanking, DefaultExposures);
XCloseDisplay(probe);
Diagnostic(spdlog::level::info) << "CrossDesk headless display " << display << " (" << options.size
<< "), XAUTHORITY=" << authority.path() << '\n';
Child desktop;
if (!session.empty() && !desktop.Start([&] {
unsetenv("DBUS_SESSION_BUS_ADDRESS");
unsetenv("SESSION_MANAGER");
return Exec({dbus, "--", session});
})) return 1;
Child application;
// Keep an interactive application in the foreground terminal group so its
// console can read commands. Xvfb and the optional desktop remain isolated.
if (!application.Start(run, !isatty(STDIN_FILENO))) return 1;
int result = 1;
while (!stop_signal) {
if (application.Exited(&result)) return result;
if (server.Exited() || desktop.Exited()) {
Diagnostic() << "Headless display or desktop session exited; stopping CrossDesk\n";
return 1;
}
std::this_thread::sleep_for(50ms);
}
return 128 + stop_signal;
}
} // namespace
int RunWithLinuxDisplay(int argc, char* argv[],
const std::function<int()>& run_application,
const std::function<bool()>& prepare_headless) {
log_diagnostics_to_file = false;
Options options;
if (!ParseOptions(argc, argv, options)) return 2;
if (options.help) {
std::cout << "Linux headless options:\n"
" --headless Reuse an existing desktop when available\n"
" --headless-display DISPLAY Advanced: override automatic desktop selection\n"
" --headless-size WIDTHxHEIGHT New Xvfb; default 1920x1080; even, 320..8192\n"
" --headless-session EXECUTABLE New desktop (startxfce4, or none for bare Xvfb)\n"
" --no-headless Disable automatic Xvfb fallback\n"
"Normal startup automatically discovers and selects your existing "
"desktop. No display number is required. A new Xvfb does not "
"contain your existing apps.\n";
return 0;
}
XInitThreads();
if (!options.force) {
if (options.disabled || HasEnv("WAYLAND_DISPLAY") || HasEnv("WAYLAND_SOCKET")) {
return run_application();
}
// Preserve normal graphical launches before SSH/headless discovery.
if (HasEnv("DISPLAY")) {
if (Display* display = XOpenDisplay(nullptr)) {
XCloseDisplay(display);
return run_application();
}
}
}
if (prepare_headless) {
if (!prepare_headless()) return 1;
log_diagnostics_to_file = true;
}
if (!options.new_session) {
const auto sessions = GetDesktopLoginSessions();
const auto desktops = FindExistingDesktops(options.display, sessions);
std::vector<std::string> available;
for (const auto& desktop : desktops) available.push_back(desktop.display);
if (auto selected = SelectDesktopSession(available, sessions)) {
const auto& desktop = desktops[*selected];
SetAuthority(desktop.authority);
ConfigureHeadlessX11(desktop.display);
Diagnostic(spdlog::level::info) << "CrossDesk reusing existing desktop " << desktop.display
<< "; Dock, panels and running applications are preserved\n";
if (desktops.size() > 1) {
Diagnostic(spdlog::level::info) << "Automatically selected from " << desktops.size()
<< " available desktops using login-session priority\n";
}
// The desktop belongs to the user, not this process. Never supervise or
// terminate its X server, window manager or applications on exit.
return run_application();
}
if (!options.display.empty()) {
Diagnostic() << "No accessible desktop/window manager on " << options.display
<< ". Check DISPLAY and XAUTHORITY; no new desktop was created.\n";
return 1;
}
}
if (options.session.empty() || options.session == "none") {
Diagnostic(spdlog::level::warn) << "Starting a separate bare Xvfb display: it has no Dock, panels "
"or existing applications. To include a desktop environment, "
"use --headless-session startxfce4 after installing Xfce.\n";
}
return RunHeadless(options, run_application);
}
} // namespace crossdesk
@@ -0,0 +1,23 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _HEADLESS_SESSION_H_
#define _HEADLESS_SESSION_H_
#include <functional>
namespace crossdesk {
// Must run before logging, SDL, Slint or any worker threads are initialized.
// Automatically selects the user's existing desktop, or supervises a private Xvfb
// server and the application as children for the duration of a headless session.
int RunWithLinuxDisplay(int argc, char* argv[],
const std::function<int()>& run_application,
const std::function<bool()>& prepare_headless = {});
} // namespace crossdesk
#endif
@@ -0,0 +1,153 @@
#include "headless_settings.h"
#include <unistd.h>
#include <algorithm>
#include <charconv>
#include <cstdlib>
#include <filesystem>
#include <functional>
#include <optional>
#include "config_center.h"
namespace crossdesk {
namespace {
std::string Lower(std::string value) {
for (auto& c : value) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
return value;
}
std::optional<bool> Boolean(const std::string& value) {
if (value == "on" || value == "true" || value == "1") return true;
if (value == "off" || value == "false" || value == "0") return false;
return std::nullopt;
}
const char* OnOff(bool value) { return value ? "on" : "off"; }
const char* languages[] = {"zh-CN", "en-US", "ru-RU"};
const char* turn_modes[] = {"off", "auto", "udp", "tcp"};
} // namespace
std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
const auto language = std::clamp(static_cast<int>(config.GetLanguage()), 0, 2);
const auto turn = std::clamp(static_cast<int>(config.GetTurnMode()), 0, 3);
return {
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"},
{"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"},
{"hardware", "console_setting_hardware", OnOff(config.IsHardwareVideoCodec()), "on / off"},
{"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
};
}
SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
const std::string& input, bool connection_busy) {
SettingResult result;
if (key == "capture_method" || key == "fps" || key == "quality") {
result.message = "console_settings_readonly";
return result;
}
const bool network = key == "codec" || key == "hardware" || key == "turn" ||
key == "self_hosted" || key == "server_host" || key == "server_port";
if (network && connection_busy) {
result.message = "console_settings_busy";
return result;
}
const std::string value = Lower(input);
int status = -1;
SettingEffect effect = SettingEffect::immediate;
const char* message = "console_settings_saved";
std::string canonical = input;
std::function<int()> save;
if (key == "language") {
int language = value == "zh-cn" || value == "zh" || value == "0" ? 0
: value == "en-us" || value == "en" || value == "1" ? 1
: value == "ru-ru" || value == "ru" || value == "2" ? 2 : -1;
if (language < 0) return result;
canonical = languages[language];
save = [&config, language] { return config.SetLanguage(static_cast<ConfigCenter::LANGUAGE>(language)); };
} else if (key == "codec") {
if (value != "h264" && value != "av1") return result;
canonical = value;
save = [&] { return config.SetVideoEncodeFormat(value == "h264" ? ConfigCenter::VIDEO_ENCODE_FORMAT::H264 : ConfigCenter::VIDEO_ENCODE_FORMAT::AV1); };
} else if (key == "turn") {
const auto found = std::find(std::begin(turn_modes), std::end(turn_modes), value);
if (found == std::end(turn_modes)) return result;
const int mode = static_cast<int>(found - std::begin(turn_modes));
canonical = value;
save = [&config, mode] { return config.SetTurnMode(static_cast<ConfigCenter::TURN_MODE>(mode)); };
} else if (key == "server_host") {
if (input.empty() || input.size() > 253 ||
input.find_first_of("/\\@?#") != std::string::npos ||
std::any_of(input.begin(), input.end(), [](unsigned char c) { return c <= 32 || c == 127; })) return result;
save = [&] { return config.SetServerHost(input); };
} else if (key == "server_port") {
int port = 0;
const auto parsed = std::from_chars(input.data(), input.data() + input.size(), port);
if (parsed.ec != std::errc{} || parsed.ptr != input.data() + input.size() || port < 1 || port > 65535) return result;
canonical = std::to_string(port);
save = [&config, port] { return config.SetServerPort(port); };
} else if (key == "file_path") {
std::string path = input == "default" ? "" : input;
if (path.compare(0, 2, "~/") == 0 && std::getenv("HOME")) path.replace(0, 1, std::getenv("HOME"));
std::error_code error;
if (!path.empty() && (path.size() >= 512 || !std::filesystem::path(path).is_absolute() ||
!std::filesystem::is_directory(path, error) || access(path.c_str(), W_OK | X_OK) != 0 ||
std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; }))) {
result.message = "console_settings_path_invalid";
return result;
}
canonical = path.empty() ? "default" : path;
save = [&config, path] { return config.SetFileTransferSavePath(path); };
effect = SettingEffect::next_file;
message = "console_settings_next_file";
} else {
const auto boolean = Boolean(value);
if (!boolean) return result;
const bool enabled = *boolean;
canonical = OnOff(enabled);
if (key == "hardware") {
if (enabled && !ConfigCenter::IsHardwareVideoCodecAvailable()) {
result.message = "console_settings_hardware_unavailable";
return result;
}
save = [&config, enabled] { return config.SetHardwareVideoCodec(enabled); };
} else if (key == "self_hosted") {
if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
result.message = "console_settings_server_required";
return result;
}
save = [&config, enabled] { return config.SetSelfHosted(enabled); };
} else if (key == "privacy") {
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
effect = SettingEffect::next_connection;
message = "console_settings_next_connection";
} else if (key == "autostart") {
save = [&config, enabled] { return config.SetAutostart(enabled); };
effect = SettingEffect::next_login;
message = "console_settings_autostart";
} else if (key == "daemon") {
save = [&config, enabled] { return config.SetDaemon(enabled); };
effect = SettingEffect::normal_restart;
message = "console_settings_daemon";
} else return result;
}
for (const auto& setting : GetConsoleSettings(config)) {
if (key != "autostart" && key != "hardware" && setting.key == key && setting.value == canonical) {
return {true, SettingEffect::none, "console_settings_unchanged"};
}
}
status = save();
if (status != 0) return {false, SettingEffect::none, "console_settings_save_failed"};
if (network && ((key != "server_host" && key != "server_port") || config.IsSelfHosted())) {
effect = SettingEffect::reconnect;
message = "console_settings_reconnect";
}
return {true, effect, message};
}
} // namespace crossdesk
@@ -0,0 +1,36 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _HEADLESS_SETTINGS_H_
#define _HEADLESS_SETTINGS_H_
#include <string>
#include <vector>
namespace crossdesk {
class ConfigCenter;
enum class SettingEffect { none, immediate, reconnect, next_connection, next_file,
next_login, normal_restart };
struct ConsoleSetting {
std::string key;
const char* label;
std::string value;
const char* choices;
};
struct SettingResult {
bool saved = false;
SettingEffect effect = SettingEffect::none;
const char* message = "console_settings_invalid";
};
std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config);
SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
const std::string& value, bool connection_busy);
} // namespace crossdesk
#endif
@@ -67,6 +67,7 @@ ScreenCapturerX11::~ScreenCapturerX11() { Destroy(); }
int ScreenCapturerX11::Init(const int fps, cb_desktop_data cb) {
Destroy();
if (!cb) return -1;
display_ = XOpenDisplay(nullptr);
if (!display_) {
@@ -75,59 +76,57 @@ int ScreenCapturerX11::Init(const int fps, cb_desktop_data cb) {
}
root_ = DefaultRootWindow(display_);
screen_res_ = XRRGetScreenResources(display_, root_);
if (!screen_res_) {
LOG_ERROR("Failed to get screen resources");
XCloseDisplay(display_);
display_ = nullptr;
return 1;
XWindowAttributes attr{};
if (!XGetWindowAttributes(display_, root_, &attr)) {
LOG_ERROR("Failed to get X11 root window geometry");
Destroy();
return -1;
}
for (int i = 0; i < screen_res_->noutput; ++i) {
RROutput output = screen_res_->outputs[i];
XRROutputInfo* output_info =
XRRGetOutputInfo(display_, screen_res_, output);
if (output_info->connection == RR_Connected && output_info->crtc != 0) {
XRRCrtcInfo* crtc_info =
XRRGetCrtcInfo(display_, screen_res_, output_info->crtc);
std::string name(output_info->name);
if (name.empty()) {
name = "Display" + std::to_string(i + 1);
}
display_info_list_.push_back(DisplayInfo(
(void*)display_, name, true, crtc_info->x, crtc_info->y,
crtc_info->x + crtc_info->width, crtc_info->y + crtc_info->height));
XRRFreeCrtcInfo(crtc_info);
}
if (output_info) {
XRRFreeOutputInfo(output_info);
}
}
XWindowAttributes attr;
XGetWindowAttributes(display_, root_, &attr);
width_ = attr.width;
height_ = attr.height;
if ((width_ & 1) != 0 || (height_ & 1) != 0) {
LOG_WARN("X11 root size {}x{} is not even, aligning down to {}x{} for NV12",
width_, height_, width_ & ~1, height_ & ~1);
width_ &= ~1;
height_ &= ~1;
}
width_ = attr.width & ~1;
height_ = attr.height & ~1;
if (width_ <= 1 || height_ <= 1) {
LOG_ERROR("Invalid capture size after alignment: {}x{}", width_, height_);
Destroy();
return -2;
}
int event_base = 0, error_base = 0;
if (XRRQueryExtension(display_, &event_base, &error_base)) {
screen_res_ = XRRGetScreenResources(display_, root_);
}
if (screen_res_) {
for (int i = 0; i < screen_res_->noutput; ++i) {
XRROutputInfo* output =
XRRGetOutputInfo(display_, screen_res_, screen_res_->outputs[i]);
if (!output) continue;
if (output->connection == RR_Connected && output->crtc != 0) {
XRRCrtcInfo* crtc = XRRGetCrtcInfo(display_, screen_res_, output->crtc);
if (crtc) {
std::string name(output->name, output->nameLen);
if (name.empty()) name = "Display" + std::to_string(i + 1);
// Reject stale/offscreen CRTCs after hot-unplug. The root drawable
// remains a usable virtual screen even when no output is connected.
if (crtc->width > 1 && crtc->height > 1 && crtc->x >= 0 &&
crtc->y >= 0 &&
crtc->x + crtc->width <= static_cast<unsigned>(attr.width) &&
crtc->y + crtc->height <= static_cast<unsigned>(attr.height)) {
display_info_list_.emplace_back(
display_, name, true, crtc->x, crtc->y,
crtc->x + crtc->width, crtc->y + crtc->height);
}
XRRFreeCrtcInfo(crtc);
}
}
XRRFreeOutputInfo(output);
}
}
if (display_info_list_.empty()) {
display_info_list_.emplace_back(display_, "Virtual Display", true,
0, 0, width_, height_);
LOG_INFO("No active X11 outputs; capturing virtual root window {}x{}",
width_, height_);
}
fps_ = fps;
callback_ = cb;
@@ -136,6 +135,7 @@ int ScreenCapturerX11::Init(const int fps, cb_desktop_data cb) {
if (!ProbeCapture()) {
LOG_ERROR("X11 backend probe failed, XGetImage is not usable");
Destroy();
return -3;
}
@@ -147,6 +147,10 @@ int ScreenCapturerX11::Destroy() {
y_plane_.clear();
uv_plane_.clear();
display_info_list_.clear();
monitor_index_ = initial_monitor_index_ = 0;
root_ = 0;
callback_ = nullptr;
if (screen_res_) {
XRRFreeScreenResources(screen_res_);
@@ -162,6 +166,7 @@ int ScreenCapturerX11::Destroy() {
int ScreenCapturerX11::Start(bool show_cursor) {
if (running_) return 0;
if (!display_ || display_info_list_.empty() || !callback_) return -1;
show_cursor_ = show_cursor;
running_ = true;
paused_ = false;
@@ -206,6 +211,8 @@ int ScreenCapturerX11::Resume(int monitor_index) {
}
int ScreenCapturerX11::SwitchTo(int monitor_index) {
if (monitor_index < 0 ||
monitor_index >= static_cast<int>(display_info_list_.size())) return -1;
monitor_index_ = monitor_index;
return 0;
}