309 lines
12 KiB
C++
309 lines
12 KiB
C++
#include "ActionDispatcher.h"
|
|
|
|
#include <cstdio>
|
|
|
|
#include <QApplication>
|
|
#include <QDBusConnection>
|
|
#include <QDBusMessage>
|
|
#include <QDesktopServices>
|
|
#include <QProcess>
|
|
#include <QStandardPaths>
|
|
#include <QString>
|
|
#include <QStringLiteral>
|
|
#include <QUrl>
|
|
#include <QVariant>
|
|
#include <QVariantMap>
|
|
|
|
#include "../app/GhosttyApp.h"
|
|
#include "../config/Config.h"
|
|
#include "../GhosttySurface.h"
|
|
#include "../MainWindow.h"
|
|
#include "../Util.h"
|
|
|
|
namespace actions {
|
|
|
|
// Drive the taskbar progress bar via the Unity LauncherEntry D-Bus API
|
|
// (honored by the KDE task manager), keyed to ghastty.desktop.
|
|
//
|
|
// Unity LauncherEntry does not have first-class ERROR / PAUSE /
|
|
// INDETERMINATE states. We approximate per progress-style:
|
|
// - REMOVE: progress-visible=false
|
|
// - SET / ERROR / PAUSE: progress-visible=true, progress=fraction;
|
|
// ERROR + PAUSE additionally flag urgent=true so the launcher
|
|
// marks attention (KDE/Plasma renders this as a bouncing icon).
|
|
// - INDETERMINATE: progress-visible=true with fraction=0 — Unity
|
|
// has no indeterminate phase, so a 0 progress is the closest
|
|
// we can do. Plasma renders this as an empty bar; better than
|
|
// dropping the state entirely.
|
|
static void postProgress(ghostty_action_progress_report_state_e state,
|
|
double fraction) {
|
|
QDBusMessage msg = QDBusMessage::createSignal(
|
|
QStringLiteral("/com/canonical/unity/launcherentry/ghastty"),
|
|
QStringLiteral("com.canonical.Unity.LauncherEntry"),
|
|
QStringLiteral("Update"));
|
|
QVariantMap props;
|
|
const bool visible = state != GHOSTTY_PROGRESS_STATE_REMOVE;
|
|
if (state == GHOSTTY_PROGRESS_STATE_INDETERMINATE) fraction = 0.0;
|
|
props[QStringLiteral("progress")] = fraction;
|
|
props[QStringLiteral("progress-visible")] = visible;
|
|
if (state == GHOSTTY_PROGRESS_STATE_ERROR ||
|
|
state == GHOSTTY_PROGRESS_STATE_PAUSE) {
|
|
props[QStringLiteral("urgent")] = true;
|
|
}
|
|
msg.setArguments(
|
|
{QStringLiteral("application://ghastty.desktop"), QVariant(props)});
|
|
QDBusConnection::sessionBus().send(msg);
|
|
}
|
|
|
|
// Open a URL through the desktop, routed by libghostty's open_url
|
|
// kind. The default `QDesktopServices::openUrl` for `text` payloads
|
|
// (e.g. the config file) lands in whatever the user has registered
|
|
// for `.txt`, which on most Linux desktops is a browser. xdg-open
|
|
// `--type=text` doesn't exist, but we can resolve the user's
|
|
// preferred text editor via `xdg-mime query default text/plain`,
|
|
// fall back to `$VISUAL` / `$EDITOR`, and finally let
|
|
// QDesktopServices try.
|
|
static void openUrlByKind(const QString &url,
|
|
ghostty_action_open_url_kind_e kind) {
|
|
if (kind != GHOSTTY_ACTION_OPEN_URL_KIND_TEXT) {
|
|
QDesktopServices::openUrl(
|
|
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile));
|
|
return;
|
|
}
|
|
// Try to launch a registered text/plain handler. xdg-mime returns
|
|
// a `.desktop` file id; gtk-launch (Debian) or dex (KDE) executes
|
|
// it. If that fails, fall through to the env-editor path.
|
|
const QString path =
|
|
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile).toLocalFile();
|
|
const QString target = path.isEmpty() ? url : path;
|
|
QProcess mime;
|
|
mime.start(QStringLiteral("xdg-mime"),
|
|
{QStringLiteral("query"), QStringLiteral("default"),
|
|
QStringLiteral("text/plain")});
|
|
mime.waitForFinished(500);
|
|
const QString desktopId =
|
|
QString::fromUtf8(mime.readAllStandardOutput()).trimmed();
|
|
if (!desktopId.isEmpty()) {
|
|
if (QProcess::startDetached(QStringLiteral("gtk-launch"),
|
|
{desktopId, target}))
|
|
return;
|
|
if (QProcess::startDetached(QStringLiteral("dex"),
|
|
{desktopId, target}))
|
|
return;
|
|
}
|
|
// $VISUAL / $EDITOR fall-back, but only if it's a GUI editor: a
|
|
// tty-only `vi` would steal the controlling terminal. We can't
|
|
// know for certain, so try a curated list (mate-, gedit, kate,
|
|
// gnome-text-editor, code) before bailing to QDesktopServices.
|
|
static const char *kGuiEditors[] = {
|
|
"gnome-text-editor", "gedit", "kate", "kwrite",
|
|
"code", "mousepad", "leafpad", nullptr};
|
|
for (const char **e = kGuiEditors; *e; ++e) {
|
|
if (QStandardPaths::findExecutable(QString::fromLatin1(*e)).isEmpty())
|
|
continue;
|
|
if (QProcess::startDetached(QString::fromLatin1(*e), {target})) return;
|
|
}
|
|
QDesktopServices::openUrl(
|
|
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile));
|
|
}
|
|
|
|
bool handleSystem(const Context &ctx, const ghostty_action_s &action) {
|
|
MainWindow *win = ctx.win;
|
|
GhosttySurface *src = ctx.src;
|
|
QPointer<MainWindow> winp = ctx.winp;
|
|
QPointer<GhosttySurface> srcp = ctx.srcp;
|
|
|
|
switch (action.tag) {
|
|
case GHOSTTY_ACTION_RING_BELL:
|
|
post(win, [winp, srcp]() {
|
|
if (winp) winp->ringBell(srcp);
|
|
});
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: {
|
|
const ghostty_action_desktop_notification_s n =
|
|
action.action.desktop_notification;
|
|
const QString title = QString::fromUtf8(n.title ? n.title : "");
|
|
const QString body = QString::fromUtf8(n.body ? n.body : "");
|
|
// Suppress notifications from the focused surface — the user
|
|
// is already looking at it and the popup just doubles up.
|
|
// macOS does the same gate; GTK gates on surface focus too.
|
|
// App-target (no `src`) always fires.
|
|
post(qApp, [title, body, srcp]() {
|
|
if (srcp && srcp->hasFocus()) return;
|
|
postNotification(title, body);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
case GHOSTTY_ACTION_COMMAND_FINISHED: {
|
|
// libghostty fires this for every command end; the apprt is
|
|
// responsible for the notify-on-command-finish gate.
|
|
if (!src) return true;
|
|
const int code = action.action.command_finished.exit_code;
|
|
const uint64_t duration = action.action.command_finished.duration;
|
|
post(src, [srcp, winp, code, duration]() {
|
|
if (!srcp || !winp) return;
|
|
// The per-command "armed via context menu" path overrides
|
|
// the never/unfocused gate (matches GTK's setup-menu).
|
|
const bool armed = srcp->consumeCommandNotify();
|
|
// notify-on-command-finish enum (string).
|
|
const QString mode = config::string("notify-on-command-finish");
|
|
bool fire = armed;
|
|
if (!fire) {
|
|
if (mode == QLatin1String("always")) fire = true;
|
|
else if (mode == QLatin1String("unfocused") && !srcp->hasFocus())
|
|
fire = true;
|
|
}
|
|
if (!fire) return;
|
|
// -after Duration; default 5s. Duration isn't decodable via
|
|
// ghostty_config_get (non-extern non-packed struct), so parse
|
|
// from the on-disk config.
|
|
const uint64_t afterNs = config::durationNs(
|
|
"notify-on-command-finish-after",
|
|
5ULL * 1000 * 1000 * 1000);
|
|
if (duration < afterNs) return;
|
|
// -action: NotifyOnCommandFinishAction = packed struct
|
|
// { bell: bool = true, notify: bool = false }. Bit 0 = bell,
|
|
// bit 1 = notify. Fallback (config::bitfield read failure)
|
|
// is bell=true so the feature still works.
|
|
const unsigned int actBits =
|
|
config::bitfield("notify-on-command-finish-action", 0x1);
|
|
const bool actBell = (actBits & 0x1) != 0;
|
|
const bool actNotify = (actBits & 0x2) != 0;
|
|
if (actBell) winp->ringBell(srcp);
|
|
if (actNotify || armed) {
|
|
QString title;
|
|
if (code < 0) title = QStringLiteral("Command Finished");
|
|
else if (code == 0) title = QStringLiteral("Command Succeeded");
|
|
else title = QStringLiteral("Command Failed");
|
|
const QString body = code >= 0
|
|
? QStringLiteral("Exited with code %1").arg(code)
|
|
: QStringLiteral("The command completed.");
|
|
postNotification(title, body);
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
case GHOSTTY_ACTION_PROGRESS_REPORT: {
|
|
// Honor `progress-style`: when false, OSC 9;4 progress
|
|
// sequences are silently ignored (no taskbar entry). The gate
|
|
// is process-wide (a config setting, not a per-window setting).
|
|
if (!config::boolean("progress-style", true)) return true;
|
|
const ghostty_action_progress_report_s p = action.action.progress_report;
|
|
const ghostty_action_progress_report_state_e state = p.state;
|
|
const double fraction = p.progress >= 0 ? p.progress / 100.0 : 0.0;
|
|
post(qApp,
|
|
[state, fraction]() { postProgress(state, fraction); });
|
|
return true;
|
|
}
|
|
|
|
case GHOSTTY_ACTION_OPEN_URL: {
|
|
const ghostty_action_open_url_s u = action.action.open_url;
|
|
if (!u.url || !u.len) return true;
|
|
const QString s = QString::fromUtf8(u.url, static_cast<int>(u.len));
|
|
const ghostty_action_open_url_kind_e kind = u.kind;
|
|
post(qApp, [s, kind]() { openUrlByKind(s, kind); });
|
|
return true;
|
|
}
|
|
|
|
case GHOSTTY_ACTION_OPEN_CONFIG: {
|
|
// ghostty_config_open_path creates the config file if missing
|
|
// and returns its path; opening it is the apprt's job. Route
|
|
// through the text-kind opener so the user's configured editor
|
|
// (not a browser via "text/plain → .txt") gets the file.
|
|
ghostty_string_s path = ghostty_config_open_path();
|
|
if (path.ptr && path.len) {
|
|
const QString p =
|
|
QString::fromUtf8(path.ptr, static_cast<int>(path.len));
|
|
post(qApp, [p]() {
|
|
openUrlByKind(p, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT);
|
|
});
|
|
}
|
|
ghostty_string_free(path);
|
|
return true;
|
|
}
|
|
|
|
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
|
// Reload is app-scoped (the config is process-wide). Post to
|
|
// qApp instead of the originating window so the reload still
|
|
// happens if the window that issued the action is closed
|
|
// between the dispatch and the queued slot.
|
|
post(qApp, []() { MainWindow::reloadConfigGlobal(); });
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
|
// A notification: libghostty already holds the new config
|
|
// (this often fires as the echo of our own
|
|
// ghostty_app_update_config). Re-pushing it would loop, so just
|
|
// refresh window chrome.
|
|
post(qApp, []() { MainWindow::refreshChrome(); });
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_SHOW_CHILD_EXITED: {
|
|
if (!src) return false;
|
|
const ghostty_surface_message_childexited_s ce =
|
|
action.action.child_exited;
|
|
// Suppress the banner for fast-exiting children (e.g. an
|
|
// intentional `exit 0` after a quick command). Match the macOS
|
|
// gate: only show when runtime_ms is at least the configured
|
|
// abnormal threshold (default 250ms). Banner = "the process
|
|
// died unexpectedly," not "the process exited."
|
|
uint32_t threshold = 250;
|
|
config::get(&threshold, "abnormal-command-exit-runtime");
|
|
if (ce.runtime_ms < threshold) return true;
|
|
const int code = static_cast<int>(ce.exit_code);
|
|
post(src, [srcp, code]() {
|
|
if (srcp) srcp->showChildExited(code);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
case GHOSTTY_ACTION_UNDO:
|
|
post(qApp, []() { MainWindow::undoLastClose(); });
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_REDO:
|
|
post(qApp, []() { MainWindow::redoLastClose(); });
|
|
return true;
|
|
|
|
// ---- no-op acks ----
|
|
|
|
case GHOSTTY_ACTION_READONLY:
|
|
// Read-only mode: libghostty itself drops keystrokes; we have
|
|
// no UI affordance (e.g. a padlock icon) so just acknowledge.
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_SECURE_INPUT:
|
|
// Secure-input: macOS-only enable_secure_event_input() that
|
|
// hides keystrokes from other apps. Wayland has no equivalent
|
|
// (the compositor mediates input), so this is a documented
|
|
// platform gap; acknowledge so the keybind isn't reported as
|
|
// unhandled.
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
|
|
// No in-app updater on Linux (distros / package managers
|
|
// handle updates). Acknowledge so the keybind isn't unhandled.
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_SHOW_GTK_INSPECTOR:
|
|
// GTK-only debug action; no analogue.
|
|
return true;
|
|
|
|
case GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD:
|
|
// libghostty defines this for iOS / on-screen-keyboard
|
|
// apprts. Linux desktops have a system-managed virtual
|
|
// keyboard (e.g. KDE's vkbd, GNOME's caribou) that the user
|
|
// toggles out-of-band; nothing for the apprt to do.
|
|
// Acknowledge so the keybind isn't reported as unhandled.
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
} // namespace actions
|