feat(packaging): the plugin runner becomes a default component

WP6.1 of design/library-scanner-plugins-implementation-plan.md.

The library is a flagship surface and cannot depend on an opt-in subsystem
(design D9, closing G9): once the scanners are plugins, a host whose runner is
off comes up with an empty library and no obvious reason why. The security
posture for on-by-default was already built and shipped — LocalService on
Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane.

Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on
a FRESH install, and left to the existing restore path on an upgrade. The
distinction is a new TaskExists probe taken before StopBunRuntimes disables
anything — TaskEnabled alone cannot tell a fresh install from an operator who
deliberately turned the runner off, and defaulting to "on" would silently switch
it back on for them.

deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first
install only so an upgrade never undoes a mask. `--global` because a maintainer
script has no user session to act on, and it is the only mechanism that makes a
--user unit on-by-default for everyone.

sysext: RPM scriptlets never run from a sysext image, so the enablement symlink
is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it
the runner would ship present-but-off on exactly the platform where an operator
is least likely to go looking for it.

Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not
`disable`, since a plain disable cannot remove a symlink under /etc or /usr. The
unit comment, both package descriptions, and the docs-site plugins page all say
so; the page also gains the Windows equivalent.

Not gated on hardware: none of this is verifiable from a Mac. The .iss change
needs an installer run (fresh + upgrade, and an upgrade with the task
deliberately disabled), and the deb/rpm/sysext changes need a package build.
This commit is contained in:
2026-08-05 10:08:11 +02:00
parent bd383f1820
commit ce8f3e9eaf
6 changed files with 137 additions and 25 deletions
+12 -6
View File
@@ -108,10 +108,16 @@ the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add p
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
task) — the console does that restart for you as part of installing.
The runner is **on by default** on a new install — your game sources are plugins, so a host without
it would show an empty library. (On a host that predates this, it stays however you left it; turn it
on with `punktfunk-host plugins enable`, which you only need once.) The runner discovers plugins
when it starts, so one installed later needs a restart to come up
(`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` task) — the
console does that restart for you as part of installing.
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
you just lose plugin-provided game sources and any automation.
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
@@ -301,8 +307,8 @@ host's, on one timeline, with the same search and download. Each is tagged `plug
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
plugin, restarting a crashed one, refusing an unsafe file).
An empty Plugins view almost always means the runner isn't running — it is a separate service, and
opt-in on Linux. Check with `punktfunk-host plugins status`.
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
with `punktfunk-host plugins status`.
<Callout>
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
+17
View File
@@ -98,6 +98,23 @@ if [ -n "$GAMESCOPE" ]; then
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
fi
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
# symlink straight into the image.
#
# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global
# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would
# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least
# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an
# unenabled runner means an empty library.
#
# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own
# ~/.config/systemd/user takes precedence over anything under /usr.
if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then
install -d "$STAGE/usr/lib/systemd/user/default.target.wants"
ln -sf ../punktfunk-scripting.service \
"$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service"
fi
# Self-update: the helper rides inside the image.
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
+23 -7
View File
@@ -114,20 +114,36 @@ Description: punktfunk plugin/script runner (Effect SDK on bun)
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
Bundles its own bun runtime (no system nodejs/bun dependency).
.
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
ON BY DEFAULT: the systemd --user unit is enabled for every user (systemctl --global). The runner is
inert until you add scripts or plugins, and the game-library scanners now ship AS plugins — so a
host without the runner has an empty library and no obvious reason why. A plugin auto-wires to the
host's mgmt token + identity cert on the same box — no env editing.
Opt out per user with: systemctl --user mask punktfunk-scripting
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
# `--global`, not `--user`: a maintainer script has no user session to act on, and this is the
# only mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
# /etc/systemd/user/…wants/). The library's scanners are plugins now, so the runner is a default
# component rather than an add-on (design D9) — but installing it stays opt-OUT, and the opt-out
# is `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a
# global symlink.
#
# Only on FIRST configure ($2 empty): re-running it on every upgrade would silently undo the
# mask of anyone who turned it off.
if [ -z "$2" ] && command -v systemctl >/dev/null 2>&1; then
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || true
fi
echo "punktfunk-scripting installed and enabled for all users."
echo "It runs your automation — game-library sources, scripts in"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner for your user:"
echo " systemctl --user enable --now punktfunk-scripting"
echo "and plugins under ~/.config/punktfunk/plugins/."
echo "It starts with your next login; start it now with:"
echo " systemctl --user start punktfunk-scripting"
echo "Don't want it? systemctl --user mask punktfunk-scripting"
fi
exit 0
EOF
+19 -6
View File
@@ -191,9 +191,10 @@ The plugin/script runner for a punktfunk streaming host: it discovers loose scri
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
identity cert on the same box no env editing. Bundles its own bun runtime. OPT-IN: the systemd
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
`systemctl --user enable --now punktfunk-scripting`.
identity cert on the same box no env editing. Bundles its own bun runtime. ON BY DEFAULT: the
systemd --user unit is enabled for every user (systemctl --global). The game-library scanners ship
as plugins, so a host without the runner has an empty library. Opt out per user with
`systemctl --user mask punktfunk-scripting`.
%endif
%prep
@@ -590,10 +591,22 @@ echo "Then open https://<host-ip>:47992"
%if %{with scripting}
%post scripting
echo "punktfunk-scripting installed. It runs your automation add scripts to"
# `--global`, not `--user`: a scriptlet has no user session to act on, and this is the only
# mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
# /etc/systemd/user/…wants/). The game-library scanners are plugins now, so the runner is a default
# component rather than an add-on (design D9); it stays opt-OUT via
# `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a global
# symlink. $1 == 1 is a first INSTALL — on an upgrade ($1 > 1) this must not undo an operator's mask.
if [ "$1" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || :
fi
echo "punktfunk-scripting installed and enabled for all users."
echo "It runs your automation game-library sources, scripts in"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
echo "and plugins under ~/.config/punktfunk/plugins/."
echo "It starts with your next login; start it now with:"
echo " systemctl --user start punktfunk-scripting"
echo "Don't want it? systemctl --user mask punktfunk-scripting"
%endif
%changelog
+56 -3
View File
@@ -329,9 +329,8 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
; converges tasks an older installer registered as SYSTEM.
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
Filename: "powershell.exe"; Parameters: "{code:ScriptingRegisterParams}"; \
StatusMsg: "Registering the Punktfunk script runner..."; Flags: runhidden waituntilterminated
#endif
#if defined(WithWeb) || defined(WithScripting)
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
@@ -619,6 +618,12 @@ end;
it disabled would switch it off for everyone who had it on. }
var
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
{ Did PunktfunkScripting exist AT ALL before this install (enabled or not)? That is what
distinguishes a FRESH scripting install — where the runner is now registered enabled by default
(design D9: the library moves into plugins, and a flagship surface cannot depend on an opt-in
subsystem, or a fresh box would come up with an empty library) — from an UPGRADE, where the
operator's own choice is the only thing that may decide it. }
ScriptingTaskExisted: Boolean;
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
@@ -643,6 +648,22 @@ begin
Result := ResultCode = 1;
end;
{ Is the task registered at all, whatever its state? Distinct from TaskEnabled: an operator who
deliberately DISABLED the runner must keep it disabled across an upgrade, which is indistinguishable
from a fresh install if you only ask "was it enabled". }
function TaskExists(TaskName: String): Boolean;
var
ResultCode: Integer;
begin
Result := False;
if Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
'if($t){exit 1}; exit 0"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
Result := ResultCode = 1;
end;
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
user hit updating to 0.22.1.
@@ -664,6 +685,9 @@ var
begin
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
{ Probed BEFORE the Disable below, which would otherwise make every upgrade look like a fresh
install to the registration entry. }
ScriptingTaskExisted := TaskExists('PunktfunkScripting');
Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
@@ -689,6 +713,35 @@ end;
DELETED the legacy task (the console runs under the host service now), so Enable-ScheduledTask
hits nothing and no-ops under SilentlyContinue. If the user cancels mid-install, though,
DeinitializeSetup runs this same restore and puts the old (task-owned) world back intact. }
{ Register PunktfunkScripting, and decide whether it comes up ENABLED.
`Register-ScheduledTask` registers enabled, so the state is decided by what follows:
* FRESH install (the task did not exist) -> leave it enabled and start it now, so the runner is
live without waiting for a reboot. Since the library's scanners become plugins (design D9),
shipping this opt-in would mean a fresh box comes up with an empty library and no obvious
reason why.
* UPGRADE (the task existed) -> disable here and let RestoreTasksParams put the operator's own
state back. That order is deliberate: this entry cannot know what they chose, and defaulting
to "on" here would silently switch the runner on for everyone who had turned it off.
It remains opt-OUT: `punktfunk-host plugins disable`, or the task's own Disable, still wins and
survives every later upgrade through exactly this path. }
function ScriptingRegisterParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
'$a=New-ScheduledTaskAction -Execute ''' +
PsLiteral(ExpandConstant('{app}\scripting\scripting-run.cmd')) + '''; ' +
'$t=New-ScheduledTaskTrigger -AtStartup; ' +
'$p=New-ScheduledTaskPrincipal -UserId ''LocalService'' -LogonType ServiceAccount; ' +
'$s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ' +
'-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; ' +
'Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p ' +
'-Settings $s -Force | Out-Null; ';
if ScriptingTaskExisted then
Result := Result + 'Disable-ScheduledTask -TaskName PunktfunkScripting | Out-Null"'
else
Result := Result + 'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null"';
end;
function RestoreTasksParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
+10 -3
View File
@@ -6,9 +6,16 @@
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
# exit (clean deregister / preset release) — hence the generous stop timeout below.
#
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
# you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting
# ON BY DEFAULT — the packages enable this for every user (`systemctl --global enable` from the
# .deb/.rpm scriptlets; a baked-in default.target.wants symlink in the sysext image). It used to be
# opt-in, on the reasoning that the runner does nothing until you add scripts or plugins. That
# stopped being true when the game-library scanners became plugins: the library is a flagship
# surface, and a host whose runner is off now comes up with an empty library and no obvious reason
# why (design/library-scanner-plugins.md D9).
#
# It remains opt-OUT, per user:
# systemctl --user mask punktfunk-scripting
# (`mask`, not `disable` — a plain disable cannot remove a symlink that lives in /etc or /usr.)
#
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env