diff --git a/.gitattributes b/.gitattributes index 2157237..1719bdf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,7 @@ Cargo.lock text eol=lf LICENSE text eol=lf .gitignore text eol=lf .gitattributes text eol=lf +*.cpp text eol=lf +*.h text eol=lf +*.qml text eol=lf +CMakeLists.txt text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cce610b..737c85a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,13 @@ jobs: cargo clippy --all-targets --locked -- -D warnings cargo test --locked cargo build --locked + - name: Qt manager dependencies + run: sudo apt-get update && sudo apt-get install -y cmake qt6-base-dev qt6-declarative-dev qml6-module-qtqml qml6-module-qtqml-models qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts qml6-module-qtquick-templates qml6-module-qtquick-window qml6-module-qtqml-workerscript + - name: Qt manager build and isolated tests + run: | + cmake -S ui -B build/ui -DCMAKE_BUILD_TYPE=Release + cmake --build build/ui --parallel 2 + ctest --test-dir build/ui --output-on-failure - name: Host recovery and repository tests run: python -m unittest discover -s tests -v - name: Standalone daemon integration diff --git a/README.md b/README.md index 6a55c9b..70ed09b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Hypertile Scene like any other application. The standalone backend is implemented on `develop`: a Rust daemon and CLI with Moonlight process supervision, journaled host recovery, and per-computer desktop -launcher entries. The graphical manager and installable packaging are not implemented yet. +launcher entries. The Qt graphical manager now provides connection controls for +configured computers. Installable packaging is not implemented yet. `main` remains the locked project bootstrap; there is no published app release. The host adapters come from the remote-stream work in @@ -27,8 +28,8 @@ profiles, connection lifecycle, host display recovery, and user controls. - Preserve Moonlight pairing and existing host authentication. - Offer a standalone computer/settings window and, optionally, an Omarchy bar plugin. -Hypertile integration will use generic application launch and window matching. -Hypertile will own placement; Remote Desktops will own connections. +Hypertile Scenes uses generic application launch and window matching. +Hypertile owns placement; Remote Desktops owns connections. See [the extraction plan](docs/EXTRACTION.md) for the implementation sequence, migration requirements, and acceptance criteria. @@ -80,3 +81,13 @@ Initial local measurements and their limits are in [validation](docs/VALIDATION. MIT. This project is independently maintained and is not an official Omarchy, Moonlight, or Sunshine application. + +## Graphical manager + +A separate Qt 6/QML manager is available on `develop` for existing configured +computers. It provides connection controls, profile selection, display recovery, +and per-computer launchers. Closing it leaves connections running. + +See [UI build, preview, and interaction guide](docs/UI.md). Pairing and computer +configuration still use Moonlight and the backend setup guide; no installable +release has been published. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae0b859..30723f8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,9 +4,9 @@ The local daemon, CLI, and client supervisor use Rust with Tokio. Moonlight Qt remains a separate client process; Sunshine remains the remote server. The -planned graphical manager uses Qt 6/QML in a separate process, communicating -over the daemon's Unix socket. The backend has no Qt dependency. No GUI or Qt -bridge is implemented in this feature. +graphical manager uses Qt 6/QML with a small C++ Qt bridge in a separate process. +It reads status over the daemon's Unix socket and submits explicit actions via +the Rust CLI. The backend has no Qt dependency. See [desktop manager](UI.md). The existing Python macOS adapter and Windows transport/recovery algorithms are kept behind a one-operation helper interface. Windows retains its PowerShell @@ -99,4 +99,21 @@ require the local Hypertile plugin. Do not install a second console helper or discard an existing journal during migration. Scenes and layout browsing remain in Hypertile. Generic application launch and -window matching in Scenes are a subsequent feature. +window matching in Scenes now use the installed per-computer desktop entries. + +## Manager process boundary + +The Qt bridge has no streaming, supervision, configuration-writing, or recovery +implementation. Configuration listing runs the Rust CLI once at startup and on +explicit refresh, exposing only computer ID/name, host, platform, default +profile, and profile names. Pairing material is not part of this listing. +Commands use an absolute executable and an argv array, never a shell string. + +Status uses asynchronous QLocalSocket request/reply framing, a 1.5-second +request timeout, a 2 MB reply limit, and at most one outstanding request. The +visible active manager polls every two seconds; an inactive manager stops +polling. Unchanged status does not emit a model update. Command processes have +a 60-second acknowledgement bound, with uncertainty reported if it expires; +only the CLI process is stopped, not the independent daemon or its host work. +No synchronous process/socket wait occurs on the GUI thread. These are resource +bounds, not measured latency or CPU claims. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a1279df..c5b90b1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,7 +4,8 @@ `develop` contains the standalone Rust backend and host adapters. `main` still contains the locked bootstrap. There is no published application release, -installable package, graphical manager, or Omarchy marketplace submission yet. +installable package or Omarchy marketplace submission yet. A development Qt +manager is available for configured computers. The initial CI/workflow bootstrap is complete. Its history was subsequently linearized at the owner's request. This is not an application release and diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..38b4c57 --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,77 @@ +# Desktop manager + +The Qt 6/QML manager is a separate application for configured computers. It +shows connection state, selects an existing profile, connects, focuses, +disconnects, reconnects, restores a pending host display, and installs a +per-computer application launcher. It has no video renderer and owns no host +recovery. Closing it leaves the daemon and Moonlight sessions running. + +## Run from a checkout + +Build the Rust backend using the repository toolchain, then the Qt manager: + +```sh +cargo build --locked +cmake -S ui -B build/ui -DCMAKE_BUILD_TYPE=Release +cmake --build build/ui --parallel 2 +build/ui/remote-desktops-manager --backend "$PWD/target/debug/remote-desktops" +``` + +The UI requires Qt 6.4 or later: Quick, Quick Controls 2, Network, and the Qt +Test development module for the test target. No Qt libraries are linked into +the Rust backend. The manager accepts an absolute backend path; otherwise it +looks beside itself and then on PATH. Configuration and socket paths follow +the backend's XDG conventions. + +Opening the manager reads configured computers and status. It does not start a +connection automatically. An explicit Connect starts the daemon when necessary. +Do not use a development binary to take over active production sessions merely +to test the UI. Use the isolated preview instead: + +```sh +build/ui/remote-desktops-manager --demo +build/ui/remote-desktops-manager --demo --state restore-pending +ctest --test-dir build/ui --output-on-failure +``` + +Demo actions never spawn the backend or access its socket. The three example +computers are synthetic. `--state` also supports `idle`, `preflight`, `empty`, +and `unavailable`; `--compact` exercises the minimum window size. With an +offscreen platform, `--screenshot /tmp/manager.png` exports the rendered demo. +These preview and screenshot options require `--demo`. + +## Interaction design + +- A stable computer list sits beside the selected connection. Selection is + retained by computer ID, rather than by a changing row index. +- One primary action follows state: Connect, Open desktop, or Restore display. + Repeat actions are suppressed while an acknowledgement is pending. A request + acknowledgement is not presented as a successful connection. +- Profiles cannot change during a desired session; disconnect first. Reconnect + restarts the selected client. Disconnect cancels pending connection intent + and asks the daemon to restore its owned host settings. +- Host recovery errors remain visible, with technical detail available on + demand. Recovery records for removed computers remain in the list. There is + deliberately no one-click abandonment of the original host settings. +- Status loss preserves last-known records and labels them unavailable; it + does not pretend that a running remote session disconnected. Refresh retries + observation. Connect/Restore use the CLI's existing daemon-start behavior. +- No thumbnails or performance figures are invented. The device drawing is an + illustration. Window-ready is an identity match, not proof of a rendered + video frame or measured latency. +- Tab navigates controls, arrow keys navigate the focused computer list, + Ctrl+Enter invokes the primary action, Ctrl+R refreshes, and Escape closes + a dialog. Focus rings, accessible names, textual statuses, and restrained + hover transitions complement color cues. + +## Current scope + +This first manager manages existing configuration. Pairing still happens in +Moonlight; adding/editing computer and profile configuration follows the +[backend setup guide](BACKEND.md). The in-app help explains that flow. A guided +pairing/configuration editor is subsequent work; this UI does not offer a +placeholder form or write unvalidated settings. + +The manager does not install itself, register a system service, migrate legacy +configuration, or change Hypertile. Packaging and a graphical setup wizard are +separate delivery steps. diff --git a/scripts/check.py b/scripts/check.py index 1c4fbe4..2a56cb9 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -7,7 +7,7 @@ from urllib.parse import unquote, urlsplit -TEXT_SUFFIXES = {".md", ".py", ".yml", ".yaml", ".sh", ".ps1", ".toml", ".json", ".rs", ".cs"} +TEXT_SUFFIXES = {".md", ".py", ".yml", ".yaml", ".sh", ".ps1", ".toml", ".json", ".rs", ".cs", ".cpp", ".h", ".qml", ".txt"} TEXT_NAMES = {"LICENSE", ".gitignore", ".gitattributes"} LINK = re.compile(r"\[[^\]\n]*\]\(([^)\n]+)\)") diff --git a/src/main.rs b/src/main.rs index 523c384..5c0c7ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,7 +124,17 @@ async fn run(cli: Cli) -> Result<()> { } if matches!(cli.command, Action::Computers) { let value = host::call(json!({"operation":"validate","config":paths.config})).await?; - let entries=value.as_object().unwrap().iter().map(|(name,c)|json!({"computer":name,"profiles":c["profiles"].as_object().unwrap().keys().collect::>()})).collect::>(); + let entries = value + .as_object() + .unwrap() + .iter() + .map(|(name, c)| { + let title = c["title"].as_str().unwrap_or(name); + json!({"computer":name, "name":title.strip_suffix(" - Moonlight").unwrap_or(title), + "host":c["host"], "platform":c["platform"], "default_profile":c["default_profile"], + "profiles":c["profiles"].as_object().unwrap().keys().collect::>()}) + }) + .collect::>(); println!("{}", serde_json::to_string_pretty(&entries)?); return Ok(()); } diff --git a/tests/integration.py b/tests/integration.py index 9cac5e9..9cda02d 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -276,6 +276,15 @@ def test_oversized_socket_request_does_not_stall_other_commands(self): sock.sendall(b"x" * 65_537) self.assertEqual(self.cli("status")["computers"], []) + def test_manager_catalog_exposes_labels_without_pairing_material(self): + entries = self.cli("computers") + self.assertEqual([c["computer"] for c in entries], ["laptop", "other"]) + self.assertEqual(entries[0]["name"], "laptop") + self.assertEqual(entries[0]["profiles"], ["desktop"]) + self.assertEqual(set(entries[0]), {"computer", "name", "host", "platform", "default_profile", "profiles"}) + self.assertNotIn("pairing_uuid", json.dumps(entries)) + self.assertEqual(self.cli("status")["computers"], []) + def test_launcher_install_metadata_and_removal_preserve_other_apps(self): installed = self.cli("launcher", "install", "laptop") path = Path(installed["installed"]) diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt new file mode 100644 index 0000000..e4c0fee --- /dev/null +++ b/ui/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.21) +project(RemoteDesktopsManager VERSION 0.1.0 LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 6.4 REQUIRED COMPONENTS Quick QuickControls2 Network Test) +qt_add_executable(remote-desktops-manager main.cpp Manager.cpp Manager.h) +qt_add_resources(remote-desktops-manager qml PREFIX "/" FILES qml/Main.qml qml/ActionButton.qml qml/ComputerGlyph.qml) +target_link_libraries(remote-desktops-manager PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Network) +target_compile_options(remote-desktops-manager PRIVATE -Wall -Wextra -Wpedantic) +enable_testing() +qt_add_executable(manager-tests tests/manager.cpp Manager.cpp Manager.h) +target_link_libraries(manager-tests PRIVATE Qt6::Test Qt6::Network Qt6::Quick Qt6::QuickControls2) +qt_add_resources(manager-tests qmlTests PREFIX "/" FILES qml/Main.qml qml/ActionButton.qml qml/ComputerGlyph.qml) +add_test(NAME manager COMMAND manager-tests) +set_tests_properties(manager PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +add_test(NAME qml-smoke COMMAND remote-desktops-manager --demo --smoke-test) +set_tests_properties(qml-smoke PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") + +foreach(state idle preflight restore-pending empty unavailable) + add_test(NAME qml-${state} COMMAND remote-desktops-manager --demo --state ${state} --smoke-test) + set_tests_properties(qml-${state} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +endforeach() +add_test(NAME qml-compact COMMAND remote-desktops-manager --demo --compact --smoke-test) +set_tests_properties(qml-compact PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") diff --git a/ui/Manager.cpp b/ui/Manager.cpp new file mode 100644 index 0000000..a8dbb7b --- /dev/null +++ b/ui/Manager.cpp @@ -0,0 +1,187 @@ +#include "Manager.h" +#include +#include +#include +#include +#include +#include +#include + +Manager::Manager(QString backend, QString socket, bool demo, QObject *parent) + : QObject(parent), m_backend(std::move(backend)), m_socketPath(std::move(socket)), m_demo(demo) { + m_poll.setInterval(2000); + connect(&m_poll, &QTimer::timeout, this, &Manager::poll); + if (m_demo) { + m_catalog = QJsonDocument::fromJson(R"([ + {"computer":"studio","name":"Studio Mac","host":"studio.example.net","platform":"macos","default_profile":"desktop","profiles":["desktop","presentation"]}, + {"computer":"work","name":"Work laptop","host":"work.example.net","platform":"windows","default_profile":"desktop","profiles":["desktop"]}, + {"computer":"lab","name":"Linux workstation","host":"lab.example.net","platform":"linux","default_profile":"desktop","profiles":["desktop"]} + ])").array(); + m_sessions = QJsonDocument::fromJson(R"([{"computer":"studio","profile":"desktop","phase":"window-ready","desired":true,"window":{"address":"demo"}},{"computer":"work","phase":"idle","desired":false},{"computer":"lab","phase":"idle","desired":false}])").array(); + m_available = true; m_loading = false; + } else { + QTimer::singleShot(0, this, &Manager::refresh); + m_poll.start(); + } +} +QVariantList Manager::computers() const { + QVariantList out; + QJsonArray all = m_catalog; + // Recovery records remain accessible even after a computer is removed from config. + for (const auto &session : m_sessions) { + bool found = false; + for (const auto &entry : all) if (entry.toObject()["computer"] == session.toObject()["computer"]) found = true; + if (!found) all.append(QJsonObject{{"computer", session.toObject()["computer"]}, {"name", session.toObject()["computer"]}, {"profiles", QJsonArray{}}, {"unconfigured", true}}); + } + for (const auto &entry : all) { + auto item = entry.toObject(); + item["phase"] = "idle"; item["desired"] = false; + for (const auto &session : m_sessions) if (session.toObject()["computer"] == item["computer"]) { + const auto record = session.toObject(); + for (auto i = record.begin(); i != record.end(); ++i) item[i.key()] = i.value(); + } + item["busy"] = m_busy.contains(item["computer"].toString()); + item["stale"] = !m_available; + out.append(item.toVariantMap()); + } + return out; +} +void Manager::publish() { emit changed(); } +void Manager::clearNotice() { m_notice.clear(); publish(); } +void Manager::setActive(bool active) { + m_active = active; + if (m_demo) return; + if (active) { m_poll.start(); poll(); } else m_poll.stop(); +} +void Manager::refresh() { + if (m_demo) { publish(); return; } + loadCatalog(); poll(); +} +void Manager::process(QStringList arguments, std::function complete) { + auto *job = new QProcess(this); + auto *deadline = new QTimer(job); + deadline->setSingleShot(true); + auto output = std::make_shared(); + auto failure = std::make_shared(); + auto done = std::make_shared(false); + auto finish = [job, deadline, complete, done](bool ok, QByteArray data) { + if (std::exchange(*done, true)) return; + deadline->stop(); complete(ok, data); job->deleteLater(); + }; + connect(job, &QProcess::readyReadStandardOutput, this, [job, output, finish] { + *output += job->readAllStandardOutput(); + if (output->size() > 2000000) { job->kill(); finish(false, "Response exceeded the size limit. Refresh to check the current state."); } + }); + connect(job, &QProcess::readyReadStandardError, this, [job, failure] { *failure = (*failure + job->readAllStandardError()).left(8192); }); + connect(job, &QProcess::errorOccurred, this, [finish](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) finish(false, "The Remote Desktops backend could not start. Check the app installation and try again."); + }); + connect(job, qOverload(&QProcess::finished), this, + [output, failure, finish](int code, QProcess::ExitStatus status) { + const bool ok = code == 0 && status == QProcess::NormalExit; + finish(ok, ok ? *output : (failure->isEmpty() ? QByteArray("The request failed. Refresh to check the current state.") : *failure)); + }); + connect(deadline, &QTimer::timeout, this, [job, finish] { + job->kill(); finish(false, "The request timed out. Its outcome may still be pending; refresh before retrying."); + }); + job->start(m_backend, arguments); + deadline->start(60000); +} +void Manager::loadCatalog() { + if (m_catalogLoading) return; + m_catalogLoading = true; + process({"--json", "computers"}, [this](bool ok, QByteArray bytes) { + m_catalogLoading = false; m_loading = false; + QJsonParseError parse; + auto doc = QJsonDocument::fromJson(bytes, &parse); + if (!ok || parse.error != QJsonParseError::NoError || !doc.isArray()) { + m_error = ok ? "Computer settings could not be read. Check the configuration and refresh." : QString::fromUtf8(bytes).trimmed(); + } else { + m_catalog = doc.array(); m_error.clear(); + } + publish(); + }); +} +void Manager::poll() { + if (m_demo || m_socket || !m_active) return; + auto *socket = new QLocalSocket(this); + m_socket = socket; + auto bytes = std::make_shared(); + auto done = std::make_shared(false); + auto changed = std::make_shared(false); + auto finish = [this, socket, done, changed](bool ok) { + if (std::exchange(*done, true)) return; + bool update = m_available != ok || *changed; + m_available = ok; m_socket = nullptr; + socket->abort(); socket->deleteLater(); + if (update) publish(); + }; + connect(socket, &QLocalSocket::connected, this, [socket] { socket->write("{\"command\":\"status\"}\n"); }); + connect(socket, &QLocalSocket::readyRead, this, [this, socket, bytes, finish, changed] { + *bytes += socket->readAll(); + if (bytes->size() > 2000000) { finish(false); return; } + if (!bytes->contains('\n')) return; + QJsonParseError error; + auto reply = QJsonDocument::fromJson(bytes->left(bytes->indexOf('\n')), &error).object(); + bool ok = error.error == QJsonParseError::NoError && reply["ok"].toBool() + && reply["result"].toObject()["computers"].isArray(); + if (ok) { + auto sessions = reply["result"].toObject()["computers"].toArray(); + *changed = sessions != m_sessions; m_sessions = sessions; + } + finish(ok); + }); + connect(socket, &QLocalSocket::errorOccurred, this, [finish](QLocalSocket::LocalSocketError) { finish(false); }); + connect(socket, &QLocalSocket::disconnected, this, [finish] { finish(false); }); + QTimer::singleShot(1500, socket, [finish] { finish(false); }); + socket->connectToServer(m_socketPath); +} +void Manager::act(QString computer, QString action, QString profile) { + static const QSet allowed{"connect", "disconnect", "reconnect", "restore", "focus", "launcher"}; + if (!allowed.contains(action) || m_busy.contains(computer)) return; + bool known = false; + for (const auto &entry : computers()) if (entry.toMap()["computer"].toString() == computer) known = true; + if (!known) return; + if (m_demo) { + m_busy.insert(computer); publish(); + QTimer::singleShot(650, this, [this, computer, action, profile] { + for (qsizetype i = 0; i < m_sessions.size(); ++i) if (m_sessions[i].toObject()["computer"].toString() == computer) { + auto s = m_sessions[i].toObject(); + if (action != "focus" && action != "launcher") { + bool connected = action == "connect" || action == "reconnect"; + s["phase"] = connected ? "window-ready" : "idle"; s["desired"] = connected; + s["window"] = connected ? QJsonValue(QJsonObject{{"address", "demo"}}) : QJsonValue(); + s["error"] = QJsonValue(); s["recovery_pending"] = false; + if (!profile.isEmpty()) s["profile"] = profile; + m_sessions[i] = s; + } + } + m_busy.remove(computer); m_notice = "Preview only — no real computer was changed."; publish(); + }); + return; + } + QStringList args{"--json"}; + if (action == "launcher") args << "launcher" << "install" << computer; + else args << action << computer; + if (action == "connect" && !profile.isEmpty()) args << "--profile" << profile; + m_busy.insert(computer); m_notice.clear(); publish(); + process(args, [this, computer, action](bool ok, QByteArray data) { + m_busy.remove(computer); + m_notice = ok ? (action == "launcher" ? "Launcher added. Find this computer in your app launcher and Scenes." : "Request accepted. Connection status will update shortly.") + : QString::fromUtf8(data).trimmed(); + poll(); publish(); + }); +} +void Manager::copy(QString text) { QGuiApplication::clipboard()->setText(text); } +void Manager::demoState(QString phase) { + if (!m_demo) return; + if (phase == "empty") { m_catalog = {}; m_sessions = {}; publish(); return; } + if (phase == "unavailable") { m_available = false; publish(); return; } + if (m_sessions.isEmpty()) return; + auto s = m_sessions[0].toObject(); + s["phase"] = phase; s["desired"] = phase == "window-ready" || phase == "preflight"; + s["window"] = phase == "window-ready" ? QJsonValue(QJsonObject{{"address", "demo"}}) : QJsonValue(); + s["recovery_pending"] = phase == "restore-pending"; + s["error"] = phase == "restore-pending" ? "The host is unreachable. Its original display settings are saved; restore when it is reachable again." : ""; + m_sessions[0] = s; publish(); +} diff --git a/ui/Manager.h b/ui/Manager.h new file mode 100644 index 0000000..4001fb8 --- /dev/null +++ b/ui/Manager.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class Manager : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantList computers READ computers NOTIFY changed) + Q_PROPERTY(QString error READ error NOTIFY changed) + Q_PROPERTY(QString notice READ notice NOTIFY changed) + Q_PROPERTY(bool loading READ loading NOTIFY changed) + Q_PROPERTY(bool available READ available NOTIFY changed) + Q_PROPERTY(bool demo READ demo CONSTANT) +public: + explicit Manager(QString backend, QString socket, bool demo = false, QObject *parent = nullptr); + QVariantList computers() const; + QString error() const { return m_error; } + QString notice() const { return m_notice; } + bool loading() const { return m_loading; } + bool available() const { return m_available; } + bool demo() const { return m_demo; } + Q_INVOKABLE void refresh(); + Q_INVOKABLE void setActive(bool active); + Q_INVOKABLE void act(QString computer, QString action, QString profile = {}); + Q_INVOKABLE void copy(QString text); + Q_INVOKABLE void clearNotice(); + Q_INVOKABLE void demoState(QString phase); + void poll(); +signals: + void changed(); +private: + void publish(); + void loadCatalog(); + void process(QStringList arguments, std::function complete); + QString m_backend, m_socketPath, m_error, m_notice; + bool m_demo, m_loading = true, m_available = false, m_active = true, m_catalogLoading = false; + QJsonArray m_catalog, m_sessions; + QSet m_busy; + QTimer m_poll; + QLocalSocket *m_socket = nullptr; +}; diff --git a/ui/main.cpp b/ui/main.cpp new file mode 100644 index 0000000..03b936b --- /dev/null +++ b/ui/main.cpp @@ -0,0 +1,60 @@ +#include "Manager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + QGuiApplication app(argc, argv); + app.setApplicationName("Remote Desktops"); + app.setOrganizationName("Remote Desktops"); + app.setDesktopFileName("remote-desktops-manager"); + QQuickStyle::setStyle("Basic"); + QCommandLineParser parser; + parser.addHelpOption(); + parser.addOption({"demo", "Use synthetic computers; never access a real backend."}); + parser.addOption({"backend", "Absolute path to the Rust CLI.", "path"}); + parser.addOption({"smoke-test", "Render the demo and exit with failure on QML warnings."}); + parser.addOption({"screenshot", "Save an isolated demo rendering and exit.", "path"}); + parser.addOption({"compact", "Render the preview at the minimum supported size."}); + parser.addOption({"state", "Initial demo state (restore-pending, preflight, idle, empty, unavailable).", "phase"}); + parser.process(app); + const bool demo = parser.isSet("demo"); + if ((parser.isSet("smoke-test") || parser.isSet("screenshot") || parser.isSet("state") || parser.isSet("compact")) && !demo) return 2; + QString backend = parser.value("backend"); + if (backend.isEmpty()) { + backend = QCoreApplication::applicationDirPath() + "/remote-desktops"; + if (!QFileInfo::exists(backend)) { + const auto installed = QStandardPaths::findExecutable("remote-desktops"); + if (!installed.isEmpty()) backend = installed; + } + } + if (!demo && (backend.isEmpty() || !QFileInfo(backend).isAbsolute())) { + qCritical("Pass --backend with the absolute path to the Rust remote-desktops executable."); + return 2; + } + Manager manager(backend, qEnvironmentVariable("XDG_RUNTIME_DIR") + "/remote-desktops/control.sock", demo); + if (parser.isSet("state")) manager.demoState(parser.value("state")); + QQmlApplicationEngine engine; + bool warnings = false; + QObject::connect(&engine, &QQmlEngine::warnings, &app, [&warnings](const QList &errors) { warnings = true; for (const auto &error : errors) fprintf(stderr, "%s\n", qPrintable(error.toString())); }); + engine.rootContext()->setContextProperty("manager", &manager); + engine.load(QUrl("qrc:/qml/Main.qml")); + if (engine.rootObjects().isEmpty()) return 1; + if (parser.isSet("compact")) { engine.rootObjects().first()->setProperty("width", 820); engine.rootObjects().first()->setProperty("height", 650); } + if (parser.isSet("smoke-test") || parser.isSet("screenshot")) { + QTimer::singleShot(900, &app, [&] { + if (parser.isSet("screenshot")) { + auto *window = qobject_cast(engine.rootObjects().first()); + if (!window || !window->grabWindow().save(parser.value("screenshot"))) warnings = true; + } + app.exit(warnings ? 1 : 0); + }); + } + return app.exec(); +} diff --git a/ui/qml/ActionButton.qml b/ui/qml/ActionButton.qml new file mode 100644 index 0000000..312cc0a --- /dev/null +++ b/ui/qml/ActionButton.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + property bool primary: false + property bool destructive: false + property string hint: "" + implicitHeight: 44 + implicitWidth: Math.max(100, contentItem.implicitWidth + 36) + font.pixelSize: 14 + font.weight: Font.DemiBold + hoverEnabled: true + Accessible.name: text + ToolTip.visible: hovered && hint.length > 0 + ToolTip.text: hint + ToolTip.delay: 650 + contentItem: Text { + text: control.text + font: control.font + color: !control.enabled ? "#747d85" : control.primary ? "#122820" : control.destructive ? "#f2b2a7" : "#e7ece8" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 9 + color: !control.enabled ? "#20272b" : control.primary ? (control.down ? "#85c8a5" : control.hovered ? "#c5f3d6" : "#afe6c5") : control.hovered ? "#303a3e" : "#232c30" + border.width: control.visualFocus ? 2 : 1 + border.color: control.visualFocus ? "#afe6c5" : control.primary ? "transparent" : "#3b474b" + Behavior on color { ColorAnimation { duration: 110 } } + } +} diff --git a/ui/qml/ComputerGlyph.qml b/ui/qml/ComputerGlyph.qml new file mode 100644 index 0000000..57855fb --- /dev/null +++ b/ui/qml/ComputerGlyph.qml @@ -0,0 +1,11 @@ +import QtQuick +Item { + id: icon + property color ink: "#b2c2ba" + property bool laptop: false + implicitWidth: 28 + implicitHeight: 28 + Rectangle { x: 3; y: 4; width: 22; height: 15; radius: 3; color: "transparent"; border.color: icon.ink; border.width: 1.5 } + Rectangle { x: icon.laptop ? 1 : 12; y: 20; width: icon.laptop ? 26 : 4; height: 2; radius: 1; color: icon.ink } + Rectangle { visible: !icon.laptop; x: 8; y: 23; width: 12; height: 1.5; radius: 1; color: icon.ink } +} diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml new file mode 100644 index 0000000..e983a47 --- /dev/null +++ b/ui/qml/Main.qml @@ -0,0 +1,281 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: root + width: 1120; height: 760 + minimumWidth: 820; minimumHeight: 650 + visible: true + title: manager.demo ? "Remote Desktops · Design preview" : "Remote Desktops" + color: "#141c20" + font.family: "Inter" + font.pixelSize: 14 + property string selectedId: "" + property string chosenProfile: "" + readonly property var entries: manager.computers + readonly property var selected: { + for (let i = 0; i < entries.length; ++i) if (entries[i].computer === selectedId) return entries[i] + return entries.length ? entries[0] : null + } + readonly property string phase: selected ? selected.phase : "idle" + readonly property bool connected: selected && !selected.stale && !!selected.window && selected.desired + readonly property bool recovering: phase === "restore-pending" || phase === "attention" && selected && (!!selected.recovery_pending || !!selected.recovery_error) + readonly property bool transitioning: manager.available && ["preflight", "preparing", "connecting", "reconnecting", "stopping", "restoring", "release-pending"].indexOf(phase) >= 0 + readonly property string profile: { + if (!selected) return "" + if (selected.desired && selected.profile) return selected.profile + const profiles = selected.profiles || [] + return profiles.indexOf(chosenProfile) >= 0 ? chosenProfile : selected.default_profile || (profiles.indexOf("desktop") >= 0 ? "desktop" : profiles[0]) || "" + } + readonly property bool canAct: !!root.selected && !root.selected.busy && !root.transitioning && (!root.selected.unconfigured || root.recovering) + onSelectedIdChanged: chosenProfile = "" + onActiveChanged: manager.setActive(active) + function label(p) { + return ({"window-ready":"Connected", running:"Client running", idle:"Disconnected", preflight:"Checking connection", preparing:"Preparing desktop", connecting:"Opening desktop", reconnecting:"Reconnecting", stopping:"Disconnecting", restoring:"Restoring display", "restore-pending":"Restore needed", attention:"Needs attention", "release-pending":"Releasing recovery"})[p] || "Checking status" + } + function action() { + if (!canAct) return + manager.act(selected.computer, recovering ? "restore" : connected ? "focus" : "connect", profile) + } + Shortcut { sequence: "Ctrl+R"; onActivated: manager.refresh() } + Shortcut { sequence: "Ctrl+Return"; enabled: root.canAct; onActivated: root.action() } + Shortcut { sequences: [StandardKey.Cancel]; onActivated: { help.close(); details.close() } } + component Caption: Label { color: "#a6b3b7"; font.pixelSize: 12; font.letterSpacing: 1.4 } + component Body: Label { textFormat: Text.PlainText; color: "#c2ccce"; wrapMode: Text.WordWrap; lineHeight: 1.25 } + component Divider: Rectangle { color: "#2e393e"; height: 1; Layout.fillWidth: true } + + RowLayout { + anchors.fill: parent + spacing: 0 + Rectangle { + Layout.preferredWidth: root.width < 960 ? 258 : 290 + Layout.fillHeight: true + color: "#10171b" + ColumnLayout { + anchors.fill: parent; anchors.margins: 24; spacing: 0 + RowLayout { + Layout.topMargin: 10; spacing: 12 + Rectangle { + width: 34; height: 34; radius: 10; color: "#afe6c5" + ComputerGlyph { anchors.centerIn: parent; ink: "#183729" } + } + Label { text: "Remote\nDesktops"; color: "#edf1ed"; font.pixelSize: 17; font.weight: Font.DemiBold; lineHeight: .95 } + } + Caption { text: "YOUR COMPUTERS"; Layout.topMargin: 48; Layout.bottomMargin: 16 } + ListView { + id: computerList + objectName: "computerList" + Layout.fillWidth: true; Layout.fillHeight: true + clip: true; spacing: 8; model: root.entries + keyNavigationEnabled: true + Keys.onDownPressed: if (currentIndex + 1 < root.entries.length) root.selectedId = root.entries[currentIndex + 1].computer + Keys.onUpPressed: if (currentIndex > 0) root.selectedId = root.entries[currentIndex - 1].computer + currentIndex: { for (let i=0; i +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class ManagerTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QQuickStyle::setStyle("Basic"); QGuiApplication::setQuitOnLastWindowClosed(false); } + void realQmlSelectionActionsAndRecovery() { + Manager m("/missing", "/missing", true); + QQmlApplicationEngine engine; + QSignalSpy warnings(&engine, &QQmlEngine::warnings); + engine.rootContext()->setContextProperty("manager", &m); + engine.load(QUrl("qrc:/qml/Main.qml")); + QVERIFY(!engine.rootObjects().isEmpty()); + auto *window = qobject_cast(engine.rootObjects().first()); + QVERIFY(window); + auto *primary = window->findChild("primaryAction"); + QVERIFY(primary); + QCOMPARE(primary->property("text").toString(), QString("Open desktop ↗")); + auto *list = window->findChild("computerList"); + QVERIFY(list); list->forceActiveFocus(); + QTest::keyClick(window, Qt::Key_Down); + QCOMPARE(window->property("selectedId").toString(), QString("work")); + QCOMPARE(primary->property("text").toString(), QString("Connect ↗")); + QVERIFY(QMetaObject::invokeMethod(primary, "clicked")); + QVERIFY(!primary->property("enabled").toBool()); + QTRY_COMPARE(primary->property("text").toString(), QString("Open desktop ↗")); + window->setProperty("selectedId", "studio"); + m.demoState("restore-pending"); + QCOMPARE(primary->property("text").toString(), QString("Restore display")); + QVERIFY(QMetaObject::invokeMethod(primary, "clicked")); + QTRY_COMPARE(primary->property("text").toString(), QString("Connect ↗")); + auto *help = window->findChild("helpDialog"); + QVERIFY(help); QVERIFY(QMetaObject::invokeMethod(help, "open")); + QTest::keyClick(window, Qt::Key_Escape); + QTRY_VERIFY(!help->property("visible").toBool()); + QCOMPARE(warnings.count(), 0); + window->close(); + QCOMPARE(m.computers()[1].toMap()["phase"].toString(), QString("window-ready")); + } + void demoNeverTouchesBackend() { + Manager m("/does/not/exist", "/does/not/exist", true); + QCOMPARE(m.computers().size(), 3); + m.act("studio", "disconnect"); + QVERIFY(m.computers()[0].toMap()["busy"].toBool()); + m.act("studio", "connect"); // One pending command per computer. + QTRY_COMPARE(m.computers()[0].toMap()["phase"].toString(), "idle"); + m.demoState("restore-pending"); + QVERIFY(m.computers()[0].toMap()["recovery_pending"].toBool()); + m.act("studio", "restore"); + QTRY_COMPARE(m.computers()[0].toMap()["phase"].toString(), "idle"); + m.act("studio", "release"); // No implicit abandonment of recovery. + QVERIFY(!m.computers()[0].toMap()["busy"].toBool()); + } + void statusIsFramedAndStaleRecordsSurvive() { + QTemporaryDir temp; + auto binary = temp.path() + "/backend"; + QFile script(binary); QVERIFY(script.open(QIODevice::WriteOnly)); + script.write("#!/bin/sh\nprintf '%s\\n' '[{\"computer\":\"test\",\"name\":\"Test\",\"profiles\":[\"desktop\"]}]'\n"); + script.close(); script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); + QLocalServer server; QVERIFY(server.listen(temp.path() + "/control.sock")); + bool valid = true; + connect(&server, &QLocalServer::newConnection, this, [&] { + auto *socket = server.nextPendingConnection(); + connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); + connect(socket, &QLocalSocket::readyRead, socket, [&, socket] { + QCOMPARE(socket->readAll(), QByteArray("{\"command\":\"status\"}\n")); + if (!valid) { socket->write("not json\n"); return; } + socket->write("{\"ok\":true,\"result\":"); + QTimer::singleShot(15, socket, [socket] { socket->write("{\"computers\":[{\"computer\":\"test\",\"phase\":\"window-ready\",\"desired\":true},{\"computer\":\"removed\",\"phase\":\"restore-pending\",\"recovery_pending\":true}]}}\n"); }); + }); + }); + Manager m(binary, server.fullServerName()); + QTRY_VERIFY(!m.loading()); QTRY_VERIFY(m.available()); + QCOMPARE(m.computers().size(), 2); + QVERIFY(m.computers()[1].toMap()["unconfigured"].toBool()); + valid = false; m.poll(); + QTRY_VERIFY(!m.available()); + QCOMPARE(m.computers().size(), 2); + QVERIFY(m.computers()[0].toMap()["stale"].toBool()); + m.setActive(false); + valid = true; m.poll(); QTest::qWait(30); + QVERIFY(!m.available()); + m.setActive(true); QTRY_VERIFY(m.available()); + } + void explicitActionsUseArgvAndSuppressDuplicates() { + QTemporaryDir temp; + QString binary = temp.path() + "/fake backend"; + QFile script(binary); QVERIFY(script.open(QIODevice::WriteOnly)); + script.write(R"(#!/bin/sh +if [ "$2" = computers ]; then + printf '%s\n' '[{"computer":"test","profiles":["desktop"]}]' +else + printf '%s\n' "$@" > "${0}.args" + sleep 0.1 + printf '%s\n' '{}' +fi +)"); + script.close(); script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); + Manager m(binary, temp.path() + "/missing.socket"); + QTRY_VERIFY(!m.loading()); + m.act("test", "connect", "desktop"); + QVERIFY(m.computers()[0].toMap()["busy"].toBool()); + m.act("test", "disconnect"); + QTRY_VERIFY(!m.computers()[0].toMap()["busy"].toBool()); + QFile args(binary + ".args"); QVERIFY(args.open(QIODevice::ReadOnly)); + QCOMPARE(args.readAll(), QByteArray("--json\nconnect\ntest\n--profile\ndesktop\n")); + QVERIFY(m.notice().contains("Request accepted")); + } + void missingBackendIsActionable() { + Manager m("/missing/backend", "/missing/socket"); + QTRY_VERIFY(!m.loading()); + QVERIFY(m.error().contains("could not start")); + QVERIFY(!m.available()); + } +}; +QTEST_MAIN(ManagerTests) +#include "manager.moc"