#include "SwayWindowBackend.hpp" #include #include #include #include #include #include namespace { constexpr int kMaxConnectAttempts = 8; QString quoteCriteriaValue(const QString &value) { QString escaped = value; escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); escaped.replace(QLatin1Char('"'), QStringLiteral("\\\"")); return escaped; } bool isScratchpadWorkspace(const QString &name) { return name == QLatin1String("__i3_scratch"); } bool isPseudoOutput(const QString &name) { return name == QLatin1String("__i3"); } qint64 jsonId(const QJsonValue &value) { if (value.isDouble()) return static_cast(value.toDouble()); return value.toVariant().toLongLong(); } QString floatingState(const QJsonObject &node) { return node.value(QStringLiteral("floating")).toString(); } bool nodeIsFloating(const QJsonObject &node) { const QString state = floatingState(node); if (state == QLatin1String("user_on") || state == QLatin1String("auto_on")) return true; return node.value(QStringLiteral("type")).toString() == QLatin1String("floating_con"); } bool nodeLooksLikeView(const QJsonObject &node) { const int pid = node.value(QStringLiteral("pid")).toInt(); if (pid <= 0) return false; const QString appId = node.value(QStringLiteral("app_id")).toString(); if (!appId.isEmpty()) return true; if (node.contains(QStringLiteral("window")) && !node.value(QStringLiteral("window")).isNull()) return true; const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject(); return !properties.isEmpty(); } } // namespace SwayWindowBackend::SwayWindowBackend(QObject *parent) : WindowBackend(parent) , m_ipc(new SwayIpcClient(this)) , m_retryTimer(new QTimer(this)) { m_retryTimer->setSingleShot(true); connect(m_retryTimer, &QTimer::timeout, this, &SwayWindowBackend::tryConnect); connect(m_ipc, &SwayIpcClient::eventReceived, this, &SwayWindowBackend::handleEvent); connect(m_ipc, &SwayIpcClient::connectedChanged, this, &SwayWindowBackend::connectedChanged); connect(m_ipc, &SwayIpcClient::disconnected, this, [this]() { m_windows.clear(); m_workspaces.clear(); m_focusedWindowId = 0; emit windowsChanged(); emit workspacesChanged(); emit focusChanged(); }); tryConnect(); } bool SwayWindowBackend::isConnected() const { return m_ipc->isConnected(); } QVector SwayWindowBackend::windows() const { return m_windows; } QVector SwayWindowBackend::workspaces() const { return m_workspaces; } qint64 SwayWindowBackend::focusedWindowId() const { return m_focusedWindowId; } bool SwayWindowBackend::focusWindow(qint64 windowId) { if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) { if (!run(QStringLiteral("[con_id=%1] scratchpad show").arg(windowId))) return false; m_minimized.remove(windowId); } return run(QStringLiteral("[con_id=%1] focus").arg(windowId)); } bool SwayWindowBackend::closeWindow(qint64 windowId) { // `kill` asks the client to close; it does not SIGKILL the process. const bool ok = run(QStringLiteral("[con_id=%1] kill").arg(windowId)); m_savedGeometry.remove(windowId); m_maximized.remove(windowId); m_minimized.remove(windowId); return ok; } bool SwayWindowBackend::maximizeWindow(qint64 windowId) { saveGeometry(windowId); m_maximized.insert(windowId); m_minimized.remove(windowId); return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, " "resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt") .arg(windowId)); } bool SwayWindowBackend::restoreWindow(qint64 windowId) { if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) { const bool ok = run(QStringLiteral("[con_id=%1] scratchpad show, floating enable").arg(windowId)); m_minimized.remove(windowId); if (!ok) return false; if (!m_maximized.contains(windowId) && m_savedGeometry.contains(windowId)) { const QRect rect = m_savedGeometry.value(windowId); return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px, " "move absolute position %4 px %5 px") .arg(windowId) .arg(rect.width()) .arg(rect.height()) .arg(rect.x()) .arg(rect.y())); } return true; } if (m_savedGeometry.contains(windowId)) { const QRect rect = m_savedGeometry.take(windowId); m_maximized.remove(windowId); return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, " "resize set width %2 px height %3 px, " "move absolute position %4 px %5 px") .arg(windowId) .arg(rect.width()) .arg(rect.height()) .arg(rect.x()) .arg(rect.y())); } m_maximized.remove(windowId); return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, " "resize set width 70 ppt height 70 ppt, move position center") .arg(windowId)); } bool SwayWindowBackend::minimizeWindow(qint64 windowId) { saveGeometry(windowId); m_minimized.insert(windowId); return run(QStringLiteral("[con_id=%1] move scratchpad").arg(windowId)); } bool SwayWindowBackend::setWindowFloating(qint64 windowId, bool floating) { return run(QStringLiteral("[con_id=%1] floating %2") .arg(windowId) .arg(floating ? QStringLiteral("enable") : QStringLiteral("disable"))); } bool SwayWindowBackend::moveWindow(qint64 windowId, int x, int y) { return run(QStringLiteral("[con_id=%1] move absolute position %2 px %3 px") .arg(windowId) .arg(x) .arg(y)); } bool SwayWindowBackend::resizeWindow(qint64 windowId, int width, int height) { if (width < 1 || height < 1) return false; return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px") .arg(windowId) .arg(width) .arg(height)); } bool SwayWindowBackend::snapWindow(qint64 windowId, SnapEdge edge) { saveGeometry(windowId); m_minimized.remove(windowId); if (edge == SnapMaximize) { m_maximized.insert(windowId); return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, " "resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt") .arg(windowId)); } m_maximized.remove(windowId); const QString x = (edge == SnapRight) ? QStringLiteral("50") : QStringLiteral("0"); return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, " "resize set width 50 ppt height 100 ppt, move position %2 ppt 0 ppt") .arg(windowId) .arg(x)); } bool SwayWindowBackend::switchWorkspace(const QString &workspaceId) { if (workspaceId.isEmpty()) return false; return run(QStringLiteral("workspace \"%1\"").arg(quoteCriteriaValue(workspaceId))); } bool SwayWindowBackend::updateFocusedGeometry() { if (!m_ipc->isConnected() || m_focusedWindowId == 0) return false; const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree); if (!tree.isObject()) return false; QVector windows; QVector workspaces; collect(tree.object(), {}, {}, false, &windows, &workspaces); for (WindowInfo &window : windows) { if (window.id != m_focusedWindowId) continue; for (WindowInfo &cached : m_windows) { if (cached.id != window.id) continue; if (cached.x == window.x && cached.y == window.y && cached.width == window.width && cached.height == window.height && cached.decoX == window.decoX && cached.decoY == window.decoY && cached.decoWidth == window.decoWidth && cached.decoHeight == window.decoHeight && cached.fullscreen == window.fullscreen) return false; cached.x = window.x; cached.y = window.y; cached.width = window.width; cached.height = window.height; cached.decoX = window.decoX; cached.decoY = window.decoY; cached.decoWidth = window.decoWidth; cached.decoHeight = window.decoHeight; cached.fullscreen = window.fullscreen; return true; } break; } return false; } void SwayWindowBackend::tryConnect() { if (m_ipc->isConnected()) return; const QString path = SwayIpcClient::socketPath(); if (path.isEmpty()) { qWarning("nebula: SWAYSOCK is unset; WindowService will continue without compositor IPC"); return; } ++m_connectAttempts; if (!m_ipc->connectToSway()) { if (m_connectAttempts < kMaxConnectAttempts) { const int delay = 150 * m_connectAttempts; qInfo("nebula: Sway IPC not ready, retrying in %d ms (%d/%d)", delay, m_connectAttempts, kMaxConnectAttempts); m_retryTimer->start(delay); return; } qWarning("nebula: could not connect to Sway IPC at %s; window management is unavailable", qPrintable(path)); return; } if (!m_ipc->subscribe({QStringLiteral("window"), QStringLiteral("workspace"), QStringLiteral("output")})) { qWarning("nebula: Sway IPC subscribe failed; window management is unavailable"); m_ipc->disconnectFromSway(); return; } qInfo("nebula: WindowService connected to Sway IPC"); refresh(); } void SwayWindowBackend::refresh() { if (!m_ipc->isConnected()) return; const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree); if (!tree.isObject()) return; QVector windows; QVector workspaces; collect(tree.object(), {}, {}, false, &windows, &workspaces); QHash counts; qint64 focused = 0; for (WindowInfo &window : windows) { window.maximized = m_maximized.contains(window.id); if (window.minimized) m_minimized.insert(window.id); else m_minimized.remove(window.id); if (!window.minimized) counts[window.workspace] += 1; if (window.focused && !window.minimized) focused = window.id; } for (WorkspaceInfo &workspace : workspaces) { workspace.windowCount = counts.value(workspace.name, 0); if (workspace.focused) continue; for (const WindowInfo &window : windows) { if (window.focused && !window.minimized && window.workspace == workspace.name) { workspace.focused = true; workspace.visible = true; break; } } } const bool focusDidChange = (focused != m_focusedWindowId); m_windows = std::move(windows); m_workspaces = std::move(workspaces); m_focusedWindowId = focused; emit windowsChanged(); emit workspacesChanged(); if (focusDidChange) emit focusChanged(); } void SwayWindowBackend::handleEvent(quint32 type, const QJsonDocument &payload) { if (type == SwayIpcClient::WindowEvent) { const QJsonObject object = payload.object(); const QString change = object.value(QStringLiteral("change")).toString(); if (change == QLatin1String("new")) { const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id"))); QTimer::singleShot(0, this, [this, id]() { handleNewWindow(id); }); return; } if (change == QLatin1String("close")) { const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id"))); m_savedGeometry.remove(id); m_maximized.remove(id); m_minimized.remove(id); } } if (m_refreshQueued) return; m_refreshQueued = true; QTimer::singleShot(0, this, [this]() { m_refreshQueued = false; refresh(); }); } void SwayWindowBackend::handleNewWindow(qint64 windowId) { refresh(); const WindowInfo *window = findWindow(windowId); if (!window || window->minimized || window->fullscreen) return; const int slot = m_cascadeIndex++ % 8; if (slot == 0) return; const int offset = slot * 32; run(QStringLiteral("[con_id=%1] move right %2 px, move down %3 px") .arg(windowId) .arg(offset) .arg(offset)); } bool SwayWindowBackend::run(const QString &command) { if (!m_ipc->isConnected()) return false; const QJsonDocument reply = m_ipc->request(SwayIpcClient::RunCommand, command.toUtf8()); const QJsonArray results = reply.array(); bool ok = !results.isEmpty(); for (const QJsonValue &value : results) { const QJsonObject object = value.toObject(); if (!object.value(QStringLiteral("success")).toBool(true)) { qWarning("nebula: compositor command failed: %s (%s)", qPrintable(command), qPrintable(object.value(QStringLiteral("error")).toString())); ok = false; } } if (ok && !m_refreshQueued) { m_refreshQueued = true; QTimer::singleShot(0, this, [this]() { m_refreshQueued = false; refresh(); }); } return ok; } bool SwayWindowBackend::saveGeometry(qint64 windowId) { const WindowInfo *window = findWindow(windowId); if (!window) return false; if (!m_savedGeometry.contains(windowId) && !m_maximized.contains(windowId)) m_savedGeometry.insert(windowId, QRect(window->x, window->y, window->width, window->height)); return true; } const WindowInfo *SwayWindowBackend::findWindow(qint64 windowId) const { for (const WindowInfo &window : m_windows) { if (window.id == windowId) return &window; } return nullptr; } bool SwayWindowBackend::isShellChrome(const QJsonObject &node) { const QString appId = node.value(QStringLiteral("app_id")).toString(); if (appId == QLatin1String("org.nebulaos.shell") || appId.startsWith(QLatin1String("nebula-"))) return true; const QString name = node.value(QStringLiteral("name")).toString(); if (name == QLatin1String("Nebula Desktop") || name == QLatin1String("Nebula Top Bar") || name == QLatin1String("Nebula Launcher") || name == QLatin1String("Nebula Window Chrome")) { return true; } const QString shell = node.value(QStringLiteral("shell")).toString(); if (shell.contains(QLatin1String("layer"), Qt::CaseInsensitive)) return true; return false; } void SwayWindowBackend::collect(const QJsonObject &node, const QString &workspace, const QString &output, bool onScratchpad, QVector *windows, QVector *workspaces) { const QString type = node.value(QStringLiteral("type")).toString(); const QString name = node.value(QStringLiteral("name")).toString(); QString nextWorkspace = workspace; QString nextOutput = output; bool nextScratchpad = onScratchpad; if (type == QLatin1String("output")) { nextOutput = name; if (isPseudoOutput(name)) nextScratchpad = true; } else if (type == QLatin1String("workspace")) { nextWorkspace = name; nextScratchpad = isScratchpadWorkspace(name); if (!nextScratchpad) { WorkspaceInfo info; info.id = name; info.name = name; info.number = node.value(QStringLiteral("num")).toInt(-1); info.focused = node.value(QStringLiteral("focused")).toBool(); info.visible = node.value(QStringLiteral("visible")).toBool(); info.output = node.value(QStringLiteral("output")).toString(output); const QJsonObject rect = node.value(QStringLiteral("rect")).toObject(); info.x = rect.value(QStringLiteral("x")).toInt(); info.y = rect.value(QStringLiteral("y")).toInt(); info.width = rect.value(QStringLiteral("width")).toInt(); info.height = rect.value(QStringLiteral("height")).toInt(); workspaces->append(info); } } if (nodeLooksLikeView(node) && !isShellChrome(node)) { WindowInfo window; window.id = jsonId(node.value(QStringLiteral("id"))); window.title = name; window.appId = node.value(QStringLiteral("app_id")).toString(); const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject(); window.windowClass = properties.value(QStringLiteral("class")).toString(); if (window.title.isEmpty()) window.title = properties.value(QStringLiteral("title")).toString(); window.workspace = nextWorkspace; window.output = nextOutput; window.focused = node.value(QStringLiteral("focused")).toBool(); window.floating = nodeIsFloating(node); window.fullscreen = node.value(QStringLiteral("fullscreen_mode")).toInt() > 0; window.minimized = nextScratchpad; const QJsonObject rect = node.value(QStringLiteral("rect")).toObject(); window.x = rect.value(QStringLiteral("x")).toInt(); window.y = rect.value(QStringLiteral("y")).toInt(); window.width = rect.value(QStringLiteral("width")).toInt(); window.height = rect.value(QStringLiteral("height")).toInt(); const QJsonObject deco = node.value(QStringLiteral("deco_rect")).toObject(); window.decoX = deco.value(QStringLiteral("x")).toInt(); window.decoY = deco.value(QStringLiteral("y")).toInt(); window.decoWidth = deco.value(QStringLiteral("width")).toInt(); window.decoHeight = deco.value(QStringLiteral("height")).toInt(); windows->append(window); } const QJsonArray nodes = node.value(QStringLiteral("nodes")).toArray(); for (const QJsonValue &child : nodes) collect(child.toObject(), nextWorkspace, nextOutput, nextScratchpad, windows, workspaces); const QJsonArray floating = node.value(QStringLiteral("floating_nodes")).toArray(); for (const QJsonValue &child : floating) collect(child.toObject(), nextWorkspace, nextOutput, nextScratchpad, windows, workspaces); }