Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Unknown keys are ignored, so a manifest written for a later Folio still loads.
| `view` | A view of all the plugin's items in the main pane (see [The view](#the-view)). |
| `actions` | Entries in the right-click menu, in the menu itself or under Share (see [Actions](#actions)). |
| `details` | Rows in the details pane about a file or folder (see [Details](#details)). |
| `watch` | `{"command": [...]}`: a command that stays running and says when something changed, so Folio asks again at once (see [Watching](#watching)). |

## Running commands

Expand Down Expand Up @@ -265,6 +266,7 @@ How long a command may take depends on what it's for:
waits until the first is done. Closing Folio ends it.
- An action with `"detach": true` is started and forgotten, for programs
that open a window of their own.
- A **watch** runs for as long as Folio does (see [Watching](#watching)).

### Text

Expand Down Expand Up @@ -341,6 +343,43 @@ row, the one whose id sorts first is shown.
Leave it out and every action is available. A plugin with a poll offers no
actions while its poll is failing.

## Watching

```json
"watch": { "command": ["./watch"] }
```

A poll asks on a timer, and a file's details are asked for once and kept, so
a plugin whose state changes on its own (a sync finishing, a machine coming
online) shows it late, or not until F5. A watch says when: Folio starts the
command when the plugins are read and leaves it running, and every line it
prints means something changed. Folio then runs the plugin's poll at once,
whatever its `every`, and asks its details command again about the file the
details pane shows.

```sh
#!/bin/sh
# Says so whenever something in the notes folder changes.
exec inotifywait -m -q -r -e close_write,move,create,delete --format changed "$HOME/Notes"
```

What a line says isn't read, only that it came, so print whatever helps when
you run the command yourself. The first line is acted on at once; lines that
follow within half a second are acted on together when it's over, so a
command may print one for every little change without Folio polling for
each. A change that comes while a poll is running has the poll run once more
after it.

The command is started like any other (in the plugin's folder, with
`FOLIO_PLUGIN_DIR` and `FOLIO_LANGUAGE`, and its standard error going to
Folio's), but with no time limit. It ends, with everything it started, when
Folio closes or the plugin goes away; F5 and a change of language leave a
running one be, unless its manifest's `watch` changed. When it ends by
itself (what it watches isn't running), Folio starts it again 5 seconds
later, and again every 5 seconds until it stays, saying so on its standard
error once. A plugin works without its watch, only later: keep the poll, and
let the watch make it timely.

## The sidebar section

```json
Expand Down Expand Up @@ -474,9 +513,9 @@ is fine when there's nothing to say.
The rows show below Folio's own, plugins in id order. Folio keeps them for
the file as it was last modified, so going through a folder and back runs
nothing again. It asks again about a file changed since, about everything
after F5, and about every file once the plugin's poll prints something new,
since a plugin's state can change how a file stands without changing the
file (Dropbox finishing a sync).
after F5, and about every file once the plugin's poll prints something new
or its [watch](#watching) prints a line, since a plugin's state can change
how a file stands without changing the file (Dropbox finishing a sync).

The command runs in the background, never holding up the window, and has 2
seconds. One that fails, takes longer or prints something other than a list
Expand Down Expand Up @@ -589,7 +628,8 @@ file in `~/Nextcloud` stands.
{ "id": "share", "label": "Nextcloud…", "menu": "share", "command": ["./share"],
"under": ["~/Nextcloud/"], "single": true }
],
"details": { "command": ["./filestatus"], "under": ["~/Nextcloud/"] }
"details": { "command": ["./filestatus"], "under": ["~/Nextcloud/"] },
"watch": { "command": ["./watch"] }
}
```

Expand All @@ -603,7 +643,10 @@ it syncs (no status: the client shows its own) and prints `"actions":
`share` sends the client `SHARE`, for
it to open its share options in a window of its own. `filestatus` prints `[["Nextcloud", status]]` with `up to date`,
`syncing`, `ignored` or `error`, then `, shared` for what's shared, and `[]`
when the client isn't running or doesn't sync the file. A manifest's `under`
when the client isn't running or doesn't sync the file. `watch` stays on the
socket with `socat` and passes on what the client says, a line whenever
something in a folder it syncs changed, so the details row keeps up with a
sync as it goes; with no client running it ends, and Folio starts it again. A manifest's `under`
can't follow the client's settings, so the Share entry and the details row
are for the usual `~/Nextcloud` only.

Expand Down
3 changes: 1 addition & 2 deletions plugins/nextcloud/ask
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
# path asked about. A state is OK, SYNC, NEW, IGNORE, ERROR or NOP (not the
# client's), with +SWM after it when shared. Fails when no client is running.

# Without XDG_RUNTIME_DIR the client falls back as Qt does.
socket="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}/runtime-$USER}/Nextcloud/socket"
. "${0%/*}/socket.sh"
[ -S "$socket" ] || exit 1
# From the plugin's folder, so its path needs no quoting for socat.
cd "${0%/*}" || exit 1
Expand Down
3 changes: 2 additions & 1 deletion plugins/nextcloud/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
{ "id": "share", "label": "Nextcloud…", "menu": "share", "command": ["./share"],
"under": ["~/Nextcloud/"], "single": true }
],
"details": { "command": ["./filestatus"], "under": ["~/Nextcloud/"] }
"details": { "command": ["./filestatus"], "under": ["~/Nextcloud/"] },
"watch": { "command": ["./watch"] }
}
3 changes: 3 additions & 0 deletions plugins/nextcloud/socket.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Where the Nextcloud client listens, for `ask` and `watch` to read in.
# Without XDG_RUNTIME_DIR the client falls back as Qt does.
socket="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}/runtime-$USER}/Nextcloud/socket"
7 changes: 7 additions & 0 deletions plugins/nextcloud/watch
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh
# Stays on the Nextcloud client's socket and passes on what it says: a line
# (UPDATE_VIEW) whenever something in a folder it syncs changed, which is
# when Folio should ask again how files stand. It ends when the client isn't
# running, or quits; Folio starts it again.
. "${0%/*}/socket.sh"
exec socat -u "UNIX-CONNECT:$socket" -
167 changes: 167 additions & 0 deletions src/plugins.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ Plugins::Plugins(const QString &language, const QStringList &folders, const QStr

Plugins::~Plugins() {
// Closing Folio ends every command still running, with all it started.
for (const QString &plugin : m_watches.keys())
endWatch(plugin);
*m_stop = true;
m_pool.waitForDone();
m_actions.waitForDone();
Expand Down Expand Up @@ -278,6 +280,13 @@ std::optional<Plugins::Manifest> Plugins::parseManifest(const QByteArray &json,
m.detailsUnder = *under;
m.detailsTypes = *types;
}

if (root.contains("watch")) {
const auto command = commandOf(root.value("watch").toObject().value("command"));
if (!command)
return fail("\"watch\" has no command");
m.watch = *command;
}
return m;
}

Expand Down Expand Up @@ -533,6 +542,43 @@ void Plugins::apply(const QList<Manifest> &manifests) {
}
// What plugins that went away printed goes with them.
m_outputs.removeIf([&](const auto &it) { return !polled.contains(it.key()); });

// A watch that's as it was carries on, so F5 or another language doesn't
// start it over; one that couldn't be started gets another go.
QSet<QString> watched;
for (const Manifest &m : std::as_const(m_manifests)) {
if (m.watch.isEmpty())
continue;
watched.insert(m.id);
const auto it = m_watches.constFind(m.id);
if (it != m_watches.constEnd() && it->command == m.watch && it->dir == m.dir
&& (it->process || it->restart->isActive()))
continue;
endWatch(m.id);
Watch watch;
watch.command = m.watch;
watch.dir = m.dir;
watch.settle = new QTimer(this);
watch.settle->setSingleShot(true);
watch.settle->setInterval(watchSettle);
connect(watch.settle, &QTimer::timeout, this, [this, id = m.id] {
const auto held = m_watches.find(id);
if (held == m_watches.end() || !held->pending)
return;
held->pending = false;
held->settle->start();
askAgain(id);
});
watch.restart = new QTimer(this);
watch.restart->setSingleShot(true);
watch.restart->setInterval(watchRestart);
connect(watch.restart, &QTimer::timeout, this, [this, id = m.id] { startWatch(id); });
m_watches.insert(m.id, watch);
startWatch(m.id);
}
for (const QString &id : m_watches.keys())
if (!watched.contains(id))
endWatch(id);
for (const QString &id : polled)
if (m_pollAll || !before.contains(id))
poll(id);
Expand Down Expand Up @@ -569,6 +615,10 @@ void Plugins::poll(const QString &plugin) {
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, plugin, dir = m->dir, command = m->poll] {
watcher->deleteLater();
m_polling.remove(plugin);
// Its watch said something changed meanwhile, which this poll may
// have been too early to see.
if (m_pollAgain.remove(plugin))
QTimer::singleShot(0, this, [this, plugin] { poll(plugin); });
const Manifest *now = manifest(plugin);
if (!now)
return;
Expand Down Expand Up @@ -606,6 +656,123 @@ void Plugins::poll(const QString &plugin) {
}));
}

void Plugins::startWatch(const QString &plugin) {
const Manifest *m = manifest(plugin);
const auto watch = m_watches.find(plugin);
if (!m || watch == m_watches.end() || watch->process)
return;
// Unlike the other commands this one isn't waited for, so it runs here
// and not in a pool: its lines come in as events.
auto *process = new QProcess(this);
if (!prepare(*process, *m, m->watch, environment(*m))) {
delete process;
return;
}
// A group of its own, so ending it ends everything it started too.
process->setChildProcessModifier([] { ::setpgid(0, 0); });
// Whether this is still the plugin's watch, and not one ended since.
auto current = [this, plugin, process] {
const auto it = m_watches.find(plugin);
return it != m_watches.end() && it->process == process ? &*it : nullptr;
};
// What a line says isn't read, only that it came.
connect(process, &QProcess::readyReadStandardOutput, this, [this, plugin, process] {
if (process->readAllStandardOutput().contains('\n'))
noticed(plugin);
});
// Standard error goes to Folio's, a line at a time, as other commands'.
connect(process, &QProcess::readyReadStandardError, this, [plugin, process, current] {
Watch *watch = current();
const QList<QByteArray> pieces = process->readAllStandardError().split('\n');
for (qsizetype i = 0; watch && i < pieces.size(); ++i) {
watch->error += pieces[i].left(std::max<qsizetype>(0, maxErrorLine - watch->error.size()));
if (i + 1 == pieces.size())
break;
const QString said = QString::fromUtf8(watch->error).trimmed();
watch->error.clear();
if (!said.isEmpty())
complain(plugin, said);
}
});
// One that can't be started isn't tried again until the plugins are
// read again; one that ends (what it watches went away) is, in a while.
connect(process, &QProcess::errorOccurred, this, [plugin, process, current](QProcess::ProcessError error) {
Watch *watch = current();
if (!watch || error != QProcess::FailedToStart)
return;
complain(plugin, process->program() + " couldn't be started: " + process->errorString());
watch->process = nullptr;
process->deleteLater();
});
connect(process, &QProcess::finished, this, [this, plugin, process, current] {
Watch *watch = current();
if (!watch)
return;
// Whatever it left running in the background goes with it.
if (watch->group > 0)
::kill(-pid_t(watch->group), SIGKILL);
// Said once each time the watch goes from working to ending.
if (!m_failingWatch.contains(plugin))
complain(plugin, QString("its watch ended, and is started again every %1 seconds").arg(watchRestart / 1000));
m_failingWatch.insert(plugin);
watch->process = nullptr;
watch->group = 0;
watch->error.clear();
process->deleteLater();
watch->restart->start();
});
watch->process = process;
process->start();
watch->group = process->processId();
}

void Plugins::endWatch(const QString &plugin) {
const Watch watch = m_watches.take(plugin);
m_failingWatch.remove(plugin);
delete watch.settle;
delete watch.restart;
if (!watch.process)
return;
watch.process->disconnect(this);
if (watch.group > 0)
::kill(-pid_t(watch.group), SIGKILL);
watch.process->kill();
watch.process->waitForFinished(1000);
delete watch.process;
}

void Plugins::noticed(const QString &plugin) {
const auto watch = m_watches.find(plugin);
if (watch == m_watches.end())
return;
m_failingWatch.remove(plugin);
// The first line is acted on at once; those that follow close behind
// (a sync going through a folder) are acted on once, when the timer
// rings.
if (watch->settle->isActive()) {
watch->pending = true;
return;
}
watch->settle->start();
askAgain(plugin);
}

void Plugins::askAgain(const QString &plugin) {
const Manifest *m = manifest(plugin);
if (!m)
return;
if (!m->details.isEmpty()) {
forgetDetails(plugin);
emit detailsChanged();
}
// A poll already running may have been too early to see the change,
// so another follows it.
if (m_polling.contains(plugin))
m_pollAgain.insert(plugin);
else
poll(plugin);
}

QString Plugins::icon(const Manifest &plugin, const QString &name) const {
if (!name.endsWith(".svg"))
return name.isEmpty() ? QStringLiteral("file") : name;
Expand Down
Loading
Loading