feat: entertainment hardening — notify_send delivery, plugin lib, last-run state, config edit; shared user-timers lib

This commit is contained in:
Your Name
2026-08-13 14:13:06 +00:00
parent d9316ec7f7
commit a4c025d236
20 changed files with 493 additions and 349 deletions
+2
View File
@@ -16,6 +16,8 @@ summary (newest last).
## Done
- **2026-08-13** — Entertainment-module hardening (approved Tier 1 + Tier 2): delivery moved to `notify_send` (platform follows `NOTIFY_PLATFORM`, default Telegram) via `lib/notify.sh` sourced by `bin/pos-entertainment-send`; a **last-run state** is recorded per plugin (`~/.local/share/linux_post_install/entertainment/last/<plugin>` — rc + timestamp) on every non-`--print` run and shown by `pos entertainment status`, which also lists installed-but-not-enabled plugins; a send that fails **while fired by a timer** (gated on `$INVOCATION_ID`) additionally notifies the configured platforms. New **message-safe plugin lib** `lib/entertainment-plugin-lib.sh` (defines only `plugin_*`, never writes stdout — the stdout contract stays "message only"): `plugin_load_config` (entertainment.env + env precedence), `plugin_have`, `plugin_require`, `plugin_err`, `plugin_http_json <url> [--key <jq>] [-H <header>]` (curl `--max-time 20 --retry 2`); `weather`/`joke`/`gold` refactored onto it. `pos entertainment config` gains `get|unset|ls|edit` (edit via the shared `pos config` UI — added to `INTERACTIVE_CMDS`). **Tier 2**: new shared lib `lib/user-timers-lib.sh` (only `ut_*`: `ut_interval_to_oncalendar`, `ut_interval_label`, `ut_unit_name`, `ut_write_unit_pair` incl. `TimeoutStopSec=5s` + `Persistent` + network-online deps, `ut_ensure_linger`, `USER_SYSTEMD_DIR`) dedupes the systemd user-timer machinery between `lib/entertainment-lib.sh` and `lib/scheduler-lib.sh` (the latter's `sched_*` duplicates deleted; both source it; collides-with-nothing). `install.sh` Phase 2 lib list += the two new libs; SCRIPTS.md/DEV.md/POS.md/howto/entertainment.md/AGENT_Context updated (hand-maintained lib rows: entertainment-lib 354→311, scheduler-lib 830→760, +112 user-timers-lib, +67 plugin lib). Verified: `bash -n` everywhere; smoke-tested in an isolated `HOME=/tmp/enttest` (status, config get/set/unset/ls, send path rc=0, failing plugin records rc=1, error-case message hygiene); `make gen && make check` green.
- **2026-08-13** — Fast pos-unit shutdown: every systemd unit a pos tool writes (or `systemd/` ships) now sets `TimeoutStopSec=5s` (+ `KillMode=control-group` on the daemons) so a stuck process can't stall a reboot for the 90s systemd default. Applied at all 7 template sites: `pos-communication-telegram-listener`, `pos-communication-matrix-listener` (also gained a `trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT` in `run_daemon` so stop returns sub-second), `pos-network-download` (aria2 + retry-healer units), `lib/scheduler-lib.sh` `sched_write_units`, `lib/entertainment-lib.sh` `write_units`, and `systemd/{ssh-agent,autostart,usb-automount}.service`. Legacy-unit cleanup: the repo no longer ships `pos-health.{service,timer}` / `pos-entertainment.service` (they were documented but postinstall never created them — found stale only on the live box, FAILED); removed their stale references from SYSTEMD.md (deleted the `pos-health.service` section + gating special-case, added a new **Stop behavior** section), POS.md, HOWTO.md, howto/system.md (now documents the `pos system schedule` job replacement + removal commands), AGENT_Context (tree, phase description, selfcontained table). DEV.md Best Practices gains a **Systemd units** convention (TimeoutStopSec=5s + TERM trap + regeneration caveat). Verified: `bash -n` on all edited scripts; `make gen && make check` green (filetable rows for the two listeners + network-download auto-regenerated, hand-maintained lib rows bumped 350→354 / 822→830). Live-box application is manual (this session was a Google Cloud Shell, not the real machine): regenerate units via `pos network download start`, `pos communication telegram listener --enable`, `pos system schedule enable <job>`, `pos entertainment enable <plugin>`, then `sudo systemctl disable --now pos-health.timer pos-health.service 2>/dev/null; sudo rm -f /etc/systemd/system/pos-health.{service,timer} && sudo systemctl daemon-reload`.
- **2026-08-13** — Bootstrap output transparency (`install.sh` / `preinstall.sh` / `postinstall.sh`): removed the redundant `apt update` (preinstall.sh owns it — install.sh previously ran it twice, showing two identical `OK apt update` lines); Phase 2 now names what it installs — libs line (`libs -> /usr/local/bin (644): common.sh flags.sh …`), plugin names in the count line, x64_bin names, per-feature `feature installed/overwritten` + `feature flag set` logs with a `N features installed: …` summary — and the misleading `"47 scripts + libs"` label is fixed to `47 scripts + 6 libs` (the 6 libs were outside the counter); preinstall prints `Installing N packages (apt install -y):` with the 40-name list wrapped at 80 cols; postinstall now logs silent skips — `config/authorized_keys is empty — nothing to add` (empty file previously looped zero times with no message), `schedule.d already exists, keeping it` (restructured the condition so the message is accurate when the dest exists vs config/schedule.d absent), and a per-service `service enabled: <name>` line. No output-layer changes (no `--verbose`, no log file — decided scope). Verified: `bash -n` + `--dry-run` smokes of phases 1/2/3 showing every new line (learned: `install.sh:19` hardcodes `export DRY_RUN=0`, so an env `DRY_RUN=1` is ignored — the flag `--dry-run` is required), hand-maintained filetable count rows bumped (install.sh 206→223, preinstall.sh 73→75, postinstall.sh 163→168), `make gen && make check` green. usb-automount left live (user choice).
+30 -25
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44189 |
| ## 3. Installation Flow | 190241 |
| ## 4. The `pos` CLI System | 242311 |
| ## 5. Shared Library — `lib/common.sh` | 312343 |
| ## 6. Docker Compose / ScaleTail | 344386 |
| ## 7. Optional Apps (`apps/`) | 387416 |
| ## 8. Entertainment Module | 417430 |
| ## 9. Systemd Services | 431442 |
| ## 10. Configuration Files | 443469 |
| ## 11. Coding Conventions | 470502 |
| ## 12. Development Workflow | 503555 |
| ## 13. Key File Quick Reference | 556613 |
| ## 14. Common Tasks for Agents | 614643 |
| ## 2. Directory Structure | 44192 |
| ## 3. Installation Flow | 193244 |
| ## 4. The `pos` CLI System | 245314 |
| ## 5. Shared Library — `lib/common.sh` | 315346 |
| ## 6. Docker Compose / ScaleTail | 347389 |
| ## 7. Optional Apps (`apps/`) | 390419 |
| ## 8. Entertainment Module | 420433 |
| ## 9. Systemd Services | 434445 |
| ## 10. Configuration Files | 446472 |
| ## 11. Coding Conventions | 473505 |
| ## 12. Development Workflow | 506558 |
| ## 13. Key File Quick Reference | 559618 |
| ## 14. Common Tasks for Agents | 619648 |
<!-- GEN:END docmap -->
## 1. Project Overview
@@ -53,7 +53,10 @@ Linux_post_install/
│ ├── common.sh # Shared library (colors, logging, spinner, timer, run, load_system_env)
│ ├── flags.sh # Feature flag store (flag_set/clear/is_set/value/list/status)
│ ├── notify.sh # Multi-platform alerting (notify_send) — sourced opt-in, silent-fails
── entertainment-lib.sh # Entertainment module lib (ENABLED list, scheduler sync)
── entertainment-lib.sh # Entertainment module lib (ENABLED list, last-run state, scheduler sync)
│ ├── entertainment-plugin-lib.sh # Message-safe helpers for plugins (config load, require, fetch+retry)
│ ├── scheduler-lib.sh # System scheduler lib (job parsing, notify policies, per-job user timers)
│ └── user-timers-lib.sh # Shared systemd **user** timer machinery (interval→OnCalendar, unit pair, linger)
├── bin/ # CLI tools — installed to /usr/local/bin/
│ ├── pos # Main dispatcher — smart arg matching to pos-* scripts
@@ -71,7 +74,7 @@ Linux_post_install/
│ ├── pos-entertainment-config # Show or edit the entertainment config (ENABLED auto-trigger list, weather location)
│ ├── pos-entertainment-disable # Disable a plugin's auto-trigger (remove it from ENABLED)
│ ├── pos-entertainment-enable # Enable an auto-trigger for a plugin on a schedule
│ ├── pos-entertainment-send # Run a public-API plugin and send its output via Telegram (default sender)
│ ├── pos-entertainment-send # Run a public-API plugin and send its output via the configured notify platforms
│ ├── pos-entertainment-status # Show enabled plugins and scheduler state
│ ├── pos-media-mp3 # Download audio as MP3 (yt-dlp)
│ ├── pos-media-mp4 # Download video as MP4 (smart/interactive format select)
@@ -271,7 +274,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
| entertainment | config | `pos-entertainment-config` | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
| entertainment | disable | `pos-entertainment-disable` | Disable a plugin's auto-trigger (remove it from ENABLED) |
| entertainment | enable | `pos-entertainment-enable` | Enable an auto-trigger for a plugin on a schedule |
| entertainment | send | `pos-entertainment-send` | Run a public-API plugin and send its output via Telegram (default sender) |
| entertainment | send | `pos-entertainment-send` | Run a public-API plugin and send its output via the configured notify platforms |
| entertainment | status | `pos-entertainment-status` | Show enabled plugins and scheduler state |
| media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) |
| media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) |
@@ -416,13 +419,13 @@ ScaleTail provides 119+ Docker Compose templates with a Tailscale sidecar patter
## 8. Entertainment Module
Public-API "entertainment" plugins (weather, joke, gold) that can auto-send their output to Telegram on a schedule.
Public-API "entertainment" plugins (weather, joke, gold) that can auto-send their output to the configured notify platforms (default Telegram) on a schedule.
- **CLI:** `pos entertainment {config|enable|disable|send|status}` — see the dispatch table in §4 and POS.md [entertainment](#entertainment).
- **Library:** `lib/entertainment-lib.sh` — config-file helpers, ENABLED-list parsing, plugin lookup, and scheduler sync (systemd user timers, crontab fallback).
- **Plugins:** `entertainment/*.sh` — standalone scripts that fetch a public API and **print the message to stdout** (what gets sent). Each declares its name with a `# POS_PLUGIN: <name>` header; a new plugin is auto-discovered.
- **Library:** `lib/entertainment-lib.sh` — config-file helpers, ENABLED-list parsing, plugin lookup, per-plugin last-run state, and scheduler sync. Timer machinery (interval→OnCalendar, unit pair writer, linger) is shared via `lib/user-timers-lib.sh` with the system scheduler.
- **Plugins:** `entertainment/*.sh` — standalone scripts that fetch a public API and **print the message to stdout** (what gets sent). Each declares its name with a `# POS_PLUGIN: <name>` header; a new plugin is auto-discovered. Plugins may source `lib/entertainment-plugin-lib.sh` (message-safe: no stdout chatter).
- **Config:** `~/.config/linux_post_install/entertainment.env` (ENABLED auto-trigger list, weather location). Template: `config/entertainment.env`, auto-installed by postinstall.
- **Sending:** `pos entertainment send <plugin> [--print] [--markdown]` runs the plugin and delivers via `pos communication telegram sender send`.
- **Sending:** `pos entertainment send <plugin> [--print] [--markdown]` runs the plugin and delivers via `notify_send` (follows `NOTIFY_PLATFORM`, default Telegram). Last-run rc/timestamp is recorded per plugin and shown by `status`; a timer-context failure also notifies.
- **Auto-trigger:** `pos entertainment enable <plugin> <interval>` writes the plugin into ENABLED and syncs a systemd user timer (allowed intervals: `5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly`, or `OnCalendar=…`); `disable` removes it.
- **Docs:** DEV.md "Adding an Entertainment Plugin" (§1 step list) and POS.md [entertainment](#entertainment).
@@ -563,8 +566,10 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `lib/common.sh` | 144 | Shared library (log/warn/err/run/spawn, dry-run aware, `load_system_env`) |
| `lib/flags.sh` | 60 | Feature flag store (set/clear/is_set/value/list/status) |
| `lib/notify.sh` | 76 | Multi-platform alerting (`notify_send`) — opt-in source, silent-fails |
| `lib/entertainment-lib.sh` | 354 | Entertainment module lib (ENABLED parsing, scheduler sync) |
| `lib/scheduler-lib.sh` | 830 | Scheduler lib (job parsing, notify policies, per-job user timers, legacy migrate) |
| `lib/entertainment-lib.sh` | 311 | Entertainment module lib (ENABLED parsing, last-run state, scheduler sync via user-timers-lib) |
| `lib/entertainment-plugin-lib.sh` | 67 | Message-safe helpers for plugins (config load, require, fetch+retry) — plugins MAY source it |
| `lib/scheduler-lib.sh` | 760 | Scheduler lib (job parsing, notify policies, per-job user timers via user-timers-lib, legacy migrate) |
| `lib/user-timers-lib.sh` | 112 | Shared systemd **user** timer machinery (interval→OnCalendar, unit pair writer, linger) |
| `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) |
| `bin/flag-set` | 21 | Set a flag (optionally with a value) |
| `bin/flag-clear` | 21 | Unset a flag |
@@ -582,11 +587,11 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-docker-health` | 110 | One-glance container health dashboard (exits 1 if unhealthy) |
| `bin/pos-docker-ps` | 128 | Enhanced container overview (health, IPs, ports, uptime) |
| `bin/pos-docker-vbox` | 158 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
| `bin/pos-entertainment-config` | 99 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
| `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
| `bin/pos-entertainment-disable` | 32 | Disable a plugin's auto-trigger (remove it from ENABLED) |
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
| `bin/pos-entertainment-send` | 93 | Run a public-API plugin and send its output via Telegram (default sender) |
| `bin/pos-entertainment-status` | 49 | Show enabled plugins and scheduler state |
| `bin/pos-entertainment-send` | 95 | Run a public-API plugin and send its output via the configured notify platforms |
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
| `bin/pos-media-mp3` | 80 | Download audio as MP3 (yt-dlp) |
| `bin/pos-media-mp4` | 126 | Download video as MP4 (smart/interactive format select) |
| `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
+18 -12
View File
@@ -20,7 +20,7 @@ How this repo works, how to add features, and what to keep in mind when editing.
| Phase | Script | Responsibility |
|-------|--------|----------------|
| Pre | `preinstall.sh` | System packages, apt repos, global binaries (yt-dlp) |
| Install | `install.sh` | Copies `bin/*``/usr/local/bin/` (chmod 755), `lib/common.sh` + `lib/flags.sh` + `lib/entertainment-lib.sh``/usr/local/bin/` (chmod 644) |
| Install | `install.sh` | Copies `bin/*``/usr/local/bin/` (chmod 755), all `lib/*.sh``/usr/local/bin/` (chmod 644) |
| Post | `postinstall.sh` | User config (SSH keys, PATH, bash completion), systemd services |
Each phase is independent and runs only if the corresponding script exists.
@@ -31,7 +31,7 @@ Each phase is independent and runs only if the corresponding script exists.
|-----------|---------|-------------|
| `bin/` | Daily-use CLI tools and wrappers | `/usr/local/bin/` |
| `apps/<category>/` | Optional desktop app installers | run on demand |
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `entertainment-lib.sh` (entertainment scheduling) | sourced at build time |
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI) | sourced at build time |
| `config/` | Gitignored user config files | `~/.config/<app>/` (via postinstall) |
| `entertainment/` | Public-API plugins for the entertainment module | `/usr/local/bin` (via install.sh Phase 2) |
| `compose/` | ScaleTail templates (git submodule) | `/usr/local/share/linux_post_install/scale-tail` |
@@ -203,11 +203,13 @@ Stub harnesses are **throwaway by design**: no `tests/` dir and no CI in this re
## Adding an Entertainment Plugin
The `entertainment` module routes public-API data to Telegram via the single runner `pos entertainment send <plugin>` (`bin/pos-entertainment-send`). Auto-triggering is config-driven: `ENABLED` in `entertainment.env` holds `plugin, interval` pairs; the tools `pos entertainment config|enable|disable|status` (`bin/pos-entertainment-*`) reconcile the schedule. All shared logic (ENABLED parsing, interval→schedule mapping, scheduler sync) lives in `lib/entertainment-lib.sh` — sourced by the `pos-entertainment-*` tools (never by plugins). The scheduler is **systemd user timers** — the only backend (requires a reachable user systemd manager).
The `entertainment` module routes public-API data to the configured notify platforms via the single runner `pos entertainment send <plugin>` (`bin/pos-entertainment-send`). Auto-triggering is config-driven: `ENABLED` in `entertainment.env` holds `plugin, interval` pairs; the tools `pos entertainment config|enable|disable|status` (`bin/pos-entertainment-*`) reconcile the schedule. Shared logic (ENABLED parsing, plugin lookup, last-run state, scheduler sync) lives in `lib/entertainment-lib.sh` — sourced by the `pos-entertainment-*` tools (never by plugins). The scheduler is **systemd user timers** — the only backend (requires a reachable user systemd manager); the timer machinery itself is shared with the system scheduler via `lib/user-timers-lib.sh`.
### 1. Create the plugin
Drop an executable script in `entertainment/<name>.sh` with a `# POS_PLUGIN: <name>` marker on line 3 (this is what makes it a plugin — the installed runner lists plugins by this marker, not by `.sh` files, since `/usr/local/bin` is shared with other tooling). Declare every config key the plugin reads with `# POS_KEYS: <KEY> <description> (required|optional)` lines right after it — `pos entertainment config` prints these in its Keys section and uses them to warn/not-warn on `config set`:
Drop an executable script in `entertainment/<name>.sh` with a `# POS_PLUGIN: <name>` marker (this is what makes it a plugin — the installed runner lists plugins by this marker, not by `.sh` files, since `/usr/local/bin` is shared with other tooling). Declare every config key the plugin reads with `# POS_KEYS: <KEY> <description> (required|optional)` lines right after it — `pos entertainment config` prints these in its Keys section and uses them to warn/not-warn on `config set`.
Plugins may source `lib/entertainment-plugin-lib.sh` — message-safe helpers (config load, dep guards, JSON fetch with retry) that never write to stdout. Template:
```bash
#!/usr/bin/env bash
@@ -215,23 +217,27 @@ set -euo pipefail
# POS_PLUGIN: myplugin
# POS_KEYS: MYPLUGIN_URL <feed url> (required)
# POS_KEYS: MYPLUGIN_TAG <filter tag> (optional)
err() { echo "ERROR: $*" >&2; exit 1; }
source "$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/entertainment-plugin-lib.sh"
command -v curl &>/dev/null || err "curl not found"
data="$(curl -fsS --max-time 20 https://api.example.com/foo)"
printf 'Title: %s\n' "$data"
plugin_load_config
plugin_require MYPLUGIN_URL
tag="${MYPLUGIN_TAG:-}"
json="$(plugin_http_json --key '.title' "${MYPLUGIN_URL}${tag:+/?tag=$tag}")"
printf 'Title: %s\n' "$json"
```
**Contract:** plugins are **self-contained** — do **not** source `lib/common.sh`. Its `log`/`warn`/`ok` helpers print to **stdout**, and the runner captures stdout as the message to send (helper chatter would be sent to Telegram). All stdout is the message; errors go to stderr and exit nonzero. Plugins must be non-interactive (no prompts) — the module is designed for systemd user timers.
**Contract:** plugins are **self-contained** — do **not** source `lib/common.sh` or `lib/entertainment-lib.sh`. Their `log`/`warn`/`ok` helpers print to **stdout**, and the runner captures stdout as the message to send (helper chatter would be sent to the notify platforms). All stdout is the message; errors go to stderr and exit nonzero. `lib/entertainment-plugin-lib.sh` is the *only* lib plugins may source (it defines just `plugin_*` and writes nothing to stdout). Plugins must be non-interactive (no prompts) — the module is designed for systemd user timers.
### 2. Config (if needed)
Read runtime values from `~/.config/linux_post_install/entertainment.env` (chmod 600, env precedence) — same pattern as `telegram.env`. Example: `weather.sh` uses `WEATHER_LAT`/`WEATHER_LON`. Declare each key with a `# POS_KEYS:` header line (see step 1) so `pos entertainment config` lists it and `config set` recognizes it.
Read runtime values from `~/.config/linux_post_install/entertainment.env` (chmod 600, env precedence) via `plugin_load_config` — same pattern as `telegram.env`. Example: `weather.sh` uses `WEATHER_LAT`/`WEATHER_LON`. Declare each key with a `# POS_KEYS:` header line (see step 1) so `pos entertainment config` lists it and `config set` recognizes it.
### 3. Deps
`curl` and `jq` are already in `preinstall.sh` PACKAGES. Anything else: guard with `command -v … || err "…"` and, if apt-available, add to PACKAGES.
`curl` and `jq` are already in `preinstall.sh` PACKAGES (and are what `plugin_have curl`/`plugin_have jq` check). Anything else: guard with `plugin_have <cmd>` and, if apt-available, add to PACKAGES.
### 4. Done
+12 -7
View File
@@ -325,19 +325,23 @@ The daemon long-polls `/sync` (30s timeout, per-sync `since` token, compact filt
### entertainment
**File:** `bin/pos-entertainment-send`
**Purpose:** run a public-API plugin and send its output to Telegram by default. Plugins are standalone scripts in `entertainment/` that fetch a public API and **print the message to stdout** — that stdout is what gets sent.
**Purpose:** run a public-API plugin and send its output via `notify_send` — the platform follows `NOTIFY_PLATFORM` (default Telegram, silent-fail when none configured). Plugins are standalone scripts in `entertainment/` that fetch a public API and **print the message to stdout** — that stdout is what gets sent.
| Command | Behavior |
|---------|----------|
| `pos entertainment send` | List available plugins + usage |
| `pos entertainment send <plugin> [--print] [--markdown] [args…]` | Run the plugin, send its output to Telegram (silent) |
| `pos entertainment send <plugin> [--print] [--markdown] [args…]` | Run the plugin, send its output via `notify_send` (silent) |
| `pos entertainment send <plugin> --print` | Print the output locally; do not send |
| `pos entertainment send <plugin> --markdown` | Send with `--parse-mode markdown` (via `pos communication telegram sender`) |
| `pos entertainment send <plugin> --markdown` | Send with Markdown parse_mode (via the notify senders) |
| `pos entertainment config` | Show the config file (`~/.config/linux_post_install/entertainment.env`) |
| `pos entertainment config get KEY` | Print one key's current value (`(not set)` if absent) |
| `pos entertainment config set KEY=VALUE…` | Set keys (any UPPER_SNAKE key; warns if no installed plugin uses it) and re-sync the schedule |
| `pos entertainment config unset KEY` | Remove a key from the config file |
| `pos entertainment config ls` | Declared keys with their current values, aligned |
| `pos entertainment config edit` | Interactive editor for the scope (via `pos config` UI) |
| `pos entertainment enable <plugin> [interval]` | Add plugin to `ENABLED` + schedule it as a systemd user timer |
| `pos entertainment disable <plugin>` | Remove plugin from `ENABLED` + remove its scheduled job |
| `pos entertainment status` | Enabled plugins + scheduler + schedule state |
| `pos entertainment status` | Enabled plugins (each with interval + last run), installed-but-not-enabled plugins, scheduler + timers |
**Plugin lookup order:** `$ENTERTAINMENT_DIR` → repo `entertainment/` → `/usr/local/bin/` (installed by `install.sh` Phase 2, beside the runner). A plugin name matches the file name with or without the `.sh` suffix.
@@ -351,7 +355,7 @@ Plugins:
**Config auto-install:** `postinstall.sh` copies the repo's `config/entertainment.env` (a commented template showing each key's syntax) to `~/.config/linux_post_install/entertainment.env` on install — but only if you haven't already created your own (no clobber), and prints the template so you can fill in your location. Fill in `WEATHER_LAT`/`WEATHER_LON` (and optionally `WEATHER_CITY`) to enable the weather plugin.
**Adding a plugin:** drop an executable script in `entertainment/` (e.g. `myfeed.sh`) with a `# POS_PLUGIN: <name>` marker on line 3 — the runner lists and validates plugins by this marker, so non-plugin `.sh` files in the shared `/usr/local/bin` are ignored. The plugin must be non-interactive and print the message to stdout; errors go to stderr (exit nonzero). If it needs coordinates/tokens, read them from `~/.config/linux_post_install/entertainment.env` (chmod 600, env precedence). No registration needed. Dependencies beyond `curl`/`jq` (both in `preinstall.sh` PACKAGES) should be guarded with `command -v … || exit 1`.
**Adding a plugin:** drop an executable script in `entertainment/` (e.g. `myfeed.sh`) with a `# POS_PLUGIN: <name>` marker — the runner lists and validates plugins by this marker, so non-plugin `.sh` files in the shared `/usr/local/bin` are ignored. The plugin must be non-interactive and print the message to stdout; errors go to stderr (exit nonzero). Source `lib/entertainment-plugin-lib.sh` for the standard helpers — `plugin_load_config` (reads `entertainment.env`, env precedence), `plugin_have <cmd>`, `plugin_require KEY <desc>`, `plugin_http_json <url> [--key <jq>] [-H <header>]` (curl, 2 retries, timeout) — it never writes to stdout, so the message stays clean. No registration needed. Dependencies beyond `curl`/`jq` (both in `preinstall.sh` PACKAGES) should be guarded with `plugin_have`.
**Declaring config keys (pattern):** document every key the plugin reads with one `# POS_KEYS:` line right after `# POS_PLUGIN:` — `KEY`, a description, and `(required)`/`(optional)`:
@@ -373,15 +377,16 @@ ENABLED="weather, 5m gold, 1h joke, daily"
- One **user timer** per enabled plugin (`~/.config/systemd/user/pos-entertainment-<plugin>.{service,timer}`), running `pos entertainment send <plugin>` as your user on that schedule (`OnCalendar` + `Persistent=true`). `pos entertainment enable` also tries `sudo loginctl enable-linger $USER` once so timers fire without login.
The job runs as you, so it reads your `$HOME` configs (weather location, Telegram token) natively — no `Environment=HOME=` hacks.
The job runs as you, so it reads your `$HOME` configs (weather location, notify platform) natively — no `Environment=HOME=` hacks.
Intervals: `5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly`, or a raw `OnCalendar=…` spec. Default when omitted: `daily`.
`pos entertainment status` shows the enabled plugins, the scheduler, and each plugin's interval + next fire time (`systemctl --user list-timers`).
`pos entertainment status` shows the enabled plugins (each with interval + last run), the installed-but-not-enabled plugins, the scheduler, and each plugin's next fire time (`systemctl --user list-timers`). Last run is recorded by `pos entertainment send` on every non-`--print` run (`~/.local/share/linux_post_install/entertainment/last/<plugin>`); a run that fails while fired by a timer also notifies the configured platforms.
Notes:
- The runner is headless/timer-friendly — no TTY prompts, exit 0 on success / 1 on failure.
- Scheduling is per-user for the user who runs `enable`; if you manage a different machine's user (e.g. via `runuser`/`sudo -u`), run the `enable`/`disable` commands as that user.
- The unit template (`TimeoutStopSec=5s`, `Persistent=true`, network-online deps) is shared with the system scheduler via `lib/user-timers-lib.sh`.
### flags
+10 -3
View File
@@ -38,7 +38,7 @@ The phases:
| # | Phase | Script/action |
|---|-------|----------------|
| 1 | preinstall | `preinstall.sh` — apt packages + yt-dlp |
| 2 | scripts | Copies `bin/*``/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh``/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
| 2 | scripts | Copies `bin/*``/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh``/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
| 3 | postinstall | `postinstall.sh` — PATH, completion, SSH keys, systemd |
| 4 | scalepoint | Shallow-clones ScaleTail templates to `/usr/local/share/linux_post_install/scale-tail` |
| 5 (opt) | apps | `apps/install.sh` when `--apps` (interactive) or `--full` (all, non-interactive) |
@@ -194,9 +194,16 @@ Platform selection: `~/.config/linux_post_install/notify.env` (`NOTIFY_PLATFORM=
## lib/entertainment-lib.sh — entertainment module
**File:** `lib/entertainment-lib.sh` (installed to `/usr/local/bin/entertainment-lib.sh`)
**Purpose:** shared logic for the `pos entertainment` tools — config (`entertainment.env`), `ENABLED` auto-trigger list parsing (`plugin, interval` pairs), plugin lookup by `# POS_PLUGIN:` marker, interval→schedule mapping, and scheduler reconciliation (systemd **user** timers — the only backend; requires a reachable user manager, `ensure_linger()` enables linger if needed).
**Purpose:** shared logic for the `pos entertainment` tools — config (`entertainment.env`), `ENABLED` auto-trigger list parsing (`plugin, interval` pairs), plugin lookup by `# POS_PLUGIN:` marker, per-plugin last-run state (`~/.local/share/linux_post_install/entertainment/last/<plugin>`), and scheduler reconciliation. Timer machinery (interval→OnCalendar, unit pair writer, linger) comes from `lib/user-timers-lib.sh`, shared with the system scheduler.
Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib/common.sh`). **Plugins must not source it** — their stdout is the sent message.
Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib/common.sh`). **Plugins must not source it** — their stdout is the sent message; they may instead source `lib/entertainment-plugin-lib.sh` (message-safe helpers: config load, `plugin_have`/`plugin_require`, `plugin_http_json` with retry).
---
## lib/user-timers-lib.sh — shared systemd user timers
**File:** `lib/user-timers-lib.sh` (installed to `/usr/local/bin/user-timers-lib.sh`)
**Purpose:** the one copy of the systemd **user** timer machinery used by both the entertainment module and the system scheduler — `ut_interval_to_oncalendar` (interval→`OnCalendar`, incl. raw `OnCalendar=…` passthrough), `ut_interval_label`, `ut_unit_name`, `ut_write_unit_pair` (oneshot service + `Persistent=true` timer, `TimeoutStopSec=5s`, `network-online` deps), and `ut_ensure_linger`. Sourced by `lib/entertainment-lib.sh` and `lib/scheduler-lib.sh`; defines only `ut_*` so it never collides with either.
---
+20 -10
View File
@@ -8,8 +8,8 @@ Tools: `config`, `enable`, `disable`, `send`, `status`.
| `pos entertainment send` | Fetch a plugin's message to stdout (dry-run) |
| `pos entertainment enable <plugin> [interval]` | Schedule auto-sends |
| `pos entertainment disable <plugin>` | Remove the schedule |
| `pos entertainment status` | List plugins + their active schedules |
| `pos entertainment config set|get` | Plugin keys in `entertainment.env` |
| `pos entertainment status` | List plugins + active schedules + last run |
| `pos entertainment config set|get|unset|ls|edit` | Plugin keys in `entertainment.env` |
### Plugins
@@ -19,8 +19,8 @@ Tools: `config`, `enable`, `disable`, `send`, `status`.
| `joke` | icanhazdadjoke (no key) | — |
| `gold` | goldprice.dev (no key) | — |
Interval (systemd time): `5m`, `10m`, `30m`, `1h`, `2h`, `6h`, `12h`, `daily`,
`weekly`.
Interval (systemd time): `5m`, `10m`, `15m`, `30m`, `45m`, `1h`, `2h`, `6h`,
`12h`, `daily`, `weekly`.
---
@@ -28,6 +28,7 @@ Interval (systemd time): `5m`, `10m`, `30m`, `1h`, `2h`, `6h`, `12h`, `daily`,
```bash
pos entertainment config set WEATHER_LAT=36.51 WEATHER_LON=40.75 WEATHER_CITY="Berlin"
pos entertainment config get WEATHER_CITY
pos entertainment send weather # stdout test — message for Telegram
pos entertainment send joke
@@ -37,38 +38,47 @@ pos entertainment enable weather daily # once a day
pos entertainment enable joke 2h # every 2 hours
pos entertainment disable gold
pos entertainment status # plugins + active schedules
pos entertainment status # plugins + schedules + last run
```
## How it works
- **Scheduling** uses systemd **user** timers only (unit
`pos-entertainment-<plugin>.timer` in `~/.config/systemd/user/`). Interval is
resolved through the same systemd-time parser used by `.timer` units —
invalid values are rejected with a clear message.
resolved through the same `OnCalendar` mapper the system scheduler uses
(`lib/user-timers-lib.sh`) — invalid values are rejected with a clear message.
- **Delivery** goes through `notify_send`, so the platform follows
`NOTIFY_PLATFORM` (default Telegram). If no platform is configured, enable
still works — the plugin fetches and tries to notify, silently no-ops when
unconfigured.
- **Last run** — every send (except `--print`) records the plugin's exit code
and timestamp to `~/.local/share/linux_post_install/entertainment/last/`;
`pos entertainment status` shows it per plugin, and a failed timer run also
notifies the configured platforms.
- **Interactive editing** — `pos entertainment config edit` opens the same
numbered-menu editor as `pos config` (already registered for `pos
entertainment`).
**Recipes:**
- **Morning weather + joke:** `enable weather daily`, `enable joke daily`; the
08:00 health digest plus these make a nice wake-up.
- **Add a plugin:** it's a `POS_PLUGIN` script in `entertainment/`; stdout is
the message, `POS_KEYS` lines declare config keys. See
the message, `POS_KEYS` lines declare config keys, and the message-safe
helpers in `lib/entertainment-plugin-lib.sh` remove the boilerplate. See
[DOC/DEV.md → "Adding a plugin"](../DEV.md).
**Troubleshooting:**
- `send weather` prints an error about coordinates → set `WEATHER_LAT`/
`WEATHER_LON` (required keys) via `config set`.
- `enable` fails on the interval → the value isn't valid systemd time; use one
of: `5m 10m 30m 1h 2h 6h 12h daily weekly`.
of: `5m 10m 15m 30m 45m 1h 2h 6h 12h daily weekly`.
- Nothing arrives even though `status` shows the timer → check `notify.env`
(`NOTIFY_PLATFORM`) and that `telegram.env` is configured (see
[communication](communication.md)); confirm the timer fired:
`systemctl --user list-timers pos-ent-*`.
- Public-API flakiness → the plugin outputs a clear failure; the notify call is
silent-fail by design.
silent-fail by design. `pos entertainment status` shows the last failed run
(rc ≠ 0) so a missed timer is visible.
---
+1 -1
View File
@@ -256,7 +256,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
# Commands that read from stdin interactively — only log invocation
INTERACTIVE_CMDS="system-firewall media-mp4 system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule config"
INTERACTIVE_CMDS="system-firewall media-mp4 system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config"
for ((i=n-1; i>=0; i--)); do
cmd="pos"
+48 -4
View File
@@ -27,10 +27,18 @@ keys_section() {
usage() {
cat <<EOF
Usage: pos entertainment config [set KEY=VALUE ...]
Usage: pos entertainment config [get KEY | set KEY=VALUE ... | unset KEY | ls | edit]
Show the entertainment config file, or set one or more keys.
After 'set', auto-trigger timers are re-synced from ENABLED.
Manage the entertainment config file.
Subcommands:
(none) Show the whole config file + the declared keys reference
get KEY Print the current value of one key ('(not set)' if absent)
set KEY=V... Set one or more keys (validated first, all-or-nothing)
unset KEY Remove a key from the file
ls Declared keys with their current values, aligned
edit Interactive editor for the entertainment scope
After 'set'/'unset'/'edit', auto-trigger timers are re-synced from ENABLED.
Config file: $CONFIG_FILE
@@ -41,9 +49,12 @@ Intervals: 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=...
Examples:
pos entertainment config
pos entertainment config set ENABLED="weather, 5m gold, 1h"
pos entertainment config ls
pos entertainment config get WEATHER_LAT
pos entertainment config set WEATHER_LAT=36.51 WEATHER_LON=40.75
pos entertainment config set ENABLED=""
pos entertainment config unset WEATHER_CITY
pos entertainment config edit
EOF
exit 0
}
@@ -54,6 +65,39 @@ key_known() {
case "${1:-}" in
-h|--help) usage ;;
get)
shift
[ $# -eq 1 ] || usage
v="$(config_value "$1")"
[ -n "$v" ] && printf '%s\n' "$v" || echo "(not set)"
;;
unset)
shift
[ $# -eq 1 ] || usage
write_config_key "$1" "-"
ok "$1 removed from $CONFIG_FILE"
sync_timers
;;
ls)
shift
maxw=8
while IFS= read -r line; do
IFS='|' read -r _plugin key _desc _req <<<"$line"
[ ${#key} -gt "$maxw" ] && maxw=${#key}
done <<< "$(config_keys "$(plugin_dir)")"
printf ' %-*s %s\n' "$maxw" "ENABLED" "\"$(config_value ENABLED)\""
while IFS= read -r line; do
[ -n "$line" ] || continue
IFS='|' read -r plugin key desc req <<<"$line"
v="$(config_value "$key")"
printf ' %-*s %s [%s] %s (%s)\n' "$maxw" "$key" "${v:-<not set>}" "$plugin" "$desc" "$req"
done <<< "$(config_keys "$(plugin_dir)")"
;;
edit)
shift
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
cfg_ui entertainment
;;
set)
shift
[ $# -ge 1 ] || usage
+1 -1
View File
@@ -40,7 +40,7 @@ interval="${2:-}"
resolve_plugin "$(plugin_dir)" "$plugin" >/dev/null
if [ -n "$interval" ]; then
interval_to_oncalendar "$interval" >/dev/null || \
ut_interval_to_oncalendar "$interval" >/dev/null || \
err "Invalid interval '$interval' (allowed: 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=...)"
fi
+12 -10
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: entertainment send — Run a public-API plugin and send its output via Telegram (default sender)
# POS: entertainment send — Run a public-API plugin and send its output via the configured notify platforms
# POS_FLAGS: --print --markdown
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/entertainment-lib.sh" 2>/dev/null || source "$(dirname "$0")/entertainment-lib.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
usage() {
local dir plugins
@@ -17,15 +18,16 @@ usage() {
cat <<EOF
Usage: pos entertainment send <plugin> [--print] [--markdown] [plugin args...]
Run an entertainment plugin, capture its output, and send it via Telegram.
Run an entertainment plugin, capture its output, and send it via the configured
notify platforms (NOTIFY_PLATFORM, default Telegram).
Plugins are scripts that fetch a public API and print the message to stdout —
that stdout is what gets sent.
Modes:
(default) Send the plugin output to Telegram (silent)
(default) Send the plugin output via notify_send (silent)
--print Print the output locally instead of sending
--markdown Send with Markdown parse_mode (via pos communication telegram sender)
--markdown Send with Markdown parse_mode (via the notify senders)
Available plugins:
$plugins
@@ -71,10 +73,12 @@ script="$(resolve_plugin "$dir" "$plugin")"
rc=0
output="$( "$script" "${plugin_args[@]}" )" || rc=$?
if [ "$rc" -ne 0 ]; then
[ -n "${INVOCATION_ID:-}" ] && notify_send "⚠️ entertainment '$plugin' failed (exit $rc)"
save_last_run "$plugin" "$rc" "$(printf '%s' "$output" | head -1)"
err "Plugin '$plugin' failed (exit $rc)"
fi
[ -n "$output" ] || { warn "Plugin '$plugin' produced no output — nothing to send"; exit 0; }
[ -n "$output" ] || { warn "Plugin '$plugin' produced no output — nothing to send"; save_last_run "$plugin" 0 ""; exit 0; }
# ── Route the output ────────────────────────────────────────────
if [ "$PRINT" -eq 1 ]; then
@@ -82,12 +86,10 @@ if [ "$PRINT" -eq 1 ]; then
exit 0
fi
telegram="$(dirname "$0")/pos-communication-telegram-sender"
[ -x "$telegram" ] || telegram="$(command -v pos-communication-telegram-sender 2>/dev/null || true)"
[ -n "$telegram" ] && [ -x "$telegram" ] || err "pos-communication-telegram-sender not found next to this script"
save_last_run "$plugin" 0 "$(printf '%s' "$output" | head -1)"
if [ "$MARKDOWN" -eq 1 ]; then
exec "$telegram" send --parse-mode markdown -- "$output"
notify_send --markdown "$output"
else
exec "$telegram" send -- "$output"
notify_send "$output"
fi
+20 -7
View File
@@ -24,20 +24,33 @@ esac
raw="$(config_value ENABLED)"
echo "Config: $CONFIG_FILE"
if [ -z "$raw" ]; then
echo "No plugins enabled."
echo "Enable one with: pos entertainment enable <plugin> [interval]"
exit 0
fi
parse_enabled "$raw"
echo "Enabled plugins:"
if [ ${#ENABLED_ENTRIES[@]} -eq 0 ]; then
echo " (none — enable one with: pos entertainment enable <plugin> [interval])"
fi
for entry in "${ENABLED_ENTRIES[@]}"; do
plugin="${entry%%,*}"; interval="${entry##*,}"
[ "$interval" = "$plugin" ] && interval="$DEFAULT_INTERVAL"
printf ' %-12s %s\n' "$plugin" "$(interval_label "$interval")"
printf ' %-12s %-16s last run: %s\n' "$plugin" "$(ut_interval_label "$interval")" "$(last_run_str "$plugin")"
done
not_enabled=()
for name in $(list_plugins "$(plugin_dir)"); do
found=0
for entry in "${ENABLED_ENTRIES[@]}"; do
[ "${entry%%,*}" = "$name" ] && found=1 && break
done
[ "$found" -eq 0 ] && not_enabled+=("$name")
done
if [ ${#not_enabled[@]} -gt 0 ]; then
echo
echo "Installed but not enabled:"
for name in "${not_enabled[@]}"; do
printf ' %s\n' "$name"
done
fi
echo
if systemctl --user show-environment >/dev/null 2>&1; then
echo "Scheduler: systemd user timers"
+2
View File
@@ -12,3 +12,5 @@
# WEATHER_CITY=<label> # optional, shown in the message, e.g. Berlin
#
# Manage auto-triggers with: pos entertainment enable|disable|status
# Inspect/edit keys with: pos entertainment config {get|set|unset|ls|edit}
# Last-run state per plugin: ~/.local/share/linux_post_install/entertainment/last/
+8 -8
View File
@@ -6,7 +6,10 @@ set -euo pipefail
# POS_PLUGIN: gold
# Contract: stdout is the message sent by 'pos entertainment send gold'.
err() { echo "ERROR: $*" >&2; exit 1; }
source "$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/entertainment-plugin-lib.sh"
case "${1:-}" in
-h|--help)
@@ -22,13 +25,10 @@ EOF
;;
esac
command -v curl &>/dev/null || err "curl not found"
command -v jq &>/dev/null || err "jq not found"
plugin_have curl
plugin_have jq
if ! json="$(curl -fsS --max-time 20 \
"https://api.goldprice.dev/v1/prices?symbol=XAU-USD-SPOT")"; then
err "Failed to fetch gold price from goldprice.dev"
fi
json="$(plugin_http_json "https://api.goldprice.dev/v1/prices?symbol=XAU-USD-SPOT")"
price="$(jq -r '.symbols[0].price' <<<"$json")"
bid="$(jq -r '.symbols[0].bid' <<<"$json")"
@@ -37,7 +37,7 @@ when="$(jq -r '.symbols[0].computed_at' <<<"$json")"
when="${when:0:19}"
when="${when/T/ }"
[ -n "$price" ] || err "No price received"
[ -n "$price" ] || plugin_err "No price received"
# XAU spot is quoted per troy ounce (31.1034768 g) — convert to USD/gram.
per_g="$(awk -v p="$price" -v o="31.1034768" 'BEGIN{printf "%.2f", p/o}')"
+8 -9
View File
@@ -5,7 +5,10 @@ set -euo pipefail
# POS_PLUGIN: joke
# Contract: stdout is the message sent by 'pos entertainment send joke'.
err() { echo "ERROR: $*" >&2; exit 1; }
source "$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/entertainment-plugin-lib.sh"
case "${1:-}" in
-h|--help)
@@ -21,13 +24,9 @@ EOF
;;
esac
command -v curl &>/dev/null || err "curl not found"
command -v jq &>/dev/null || err "jq not found"
plugin_have curl
plugin_have jq
if ! joke="$(curl -fsS --max-time 20 -H 'Accept: application/json' \
https://icanhazdadjoke.com/ | jq -r '.joke')"; then
err "Failed to fetch a joke from icanhazdadjoke.com"
fi
[ -n "$joke" ] || err "No joke received"
joke="$(plugin_http_json --key '.joke' -H 'Accept: application/json' "https://icanhazdadjoke.com/")"
[ -n "$joke" ] || plugin_err "No joke received"
printf '%s\n' "$joke"
+40 -57
View File
@@ -9,24 +9,48 @@ set -euo pipefail
# POS_KEYS: WEATHER_CITY <city label> (optional)
# Contract: stdout is the message sent by 'pos entertainment send weather'.
err() { echo "ERROR: $*" >&2; exit 1; }
source "$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/entertainment-plugin-lib.sh"
CONFIG_FILE="$HOME/.config/linux_post_install/entertainment.env"
case "${1:-}" in
-h|--help)
cat <<EOF
Usage: pos entertainment send weather
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
}
Current weather via Open-Meteo (no API key).
Config: $plugin_config_file (chmod 600)
WEATHER_LAT, WEATHER_LON Required — coordinates
WEATHER_CITY Optional label (e.g. "Berlin")
Example:
WEATHER_LAT=52.52 WEATHER_LON=13.41 pos entertainment send weather --print
EOF
exit 0
;;
esac
plugin_have curl
plugin_have jq
plugin_load_config
plugin_require WEATHER_LAT
plugin_require WEATHER_LON
label="${WEATHER_CITY:-$WEATHER_LAT,$WEATHER_LON}"
json="$(plugin_http_json \
"https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,is_day")"
temp="$(jq -r '.current.temperature_2m' <<<"$json")"
feels="$(jq -r '.current.apparent_temperature' <<<"$json")"
humidity="$(jq -r '.current.relative_humidity_2m' <<<"$json")"
code="$(jq -r '.current.weather_code' <<<"$json")"
wind="$(jq -r '.current.wind_speed_10m' <<<"$json")"
is_day="$(jq -r '.current.is_day' <<<"$json")"
unit_temp="$(jq -r '.current_units.temperature_2m' <<<"$json")"
unit_wind="$(jq -r '.current_units.wind_speed_10m' <<<"$json")"
# First arg: weather code; second arg: is_day (1=day). Prints the emoji.
wmo_emoji() {
@@ -61,47 +85,6 @@ wmo_desc() {
esac
}
case "${1:-}" in
-h|--help)
cat <<EOF
Usage: pos entertainment send weather
Current weather via Open-Meteo (no API key).
Config: $CONFIG_FILE (chmod 600)
WEATHER_LAT, WEATHER_LON Required — coordinates
WEATHER_CITY Optional label (e.g. "Berlin")
Example:
WEATHER_LAT=52.52 WEATHER_LON=13.41 pos entertainment send weather --print
EOF
exit 0
;;
esac
command -v curl &>/dev/null || err "curl not found"
command -v jq &>/dev/null || err "jq not found"
load_config
[ -n "${WEATHER_LAT:-}" ] || err "WEATHER_LAT not set — add it to $CONFIG_FILE"
[ -n "${WEATHER_LON:-}" ] || err "WEATHER_LON not set — add it to $CONFIG_FILE"
label="${WEATHER_CITY:-$WEATHER_LAT,$WEATHER_LON}"
if ! json="$(curl -fsS --max-time 20 \
"https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,is_day")"; then
err "Failed to fetch weather from Open-Meteo"
fi
temp="$(jq -r '.current.temperature_2m' <<<"$json")"
feels="$(jq -r '.current.apparent_temperature' <<<"$json")"
humidity="$(jq -r '.current.relative_humidity_2m' <<<"$json")"
code="$(jq -r '.current.weather_code' <<<"$json")"
wind="$(jq -r '.current.wind_speed_10m' <<<"$json")"
is_day="$(jq -r '.current.is_day' <<<"$json")"
unit_temp="$(jq -r '.current_units.temperature_2m' <<<"$json")"
unit_wind="$(jq -r '.current_units.wind_speed_10m' <<<"$json")"
emoji="$(wmo_emoji "$code" "$is_day")"
printf '%s %s · %s\n' "$emoji" "$(wmo_desc "$code")" "$label"
+1 -1
View File
@@ -115,7 +115,7 @@ if should_run 2 scripts; then
done
lib_count=0
lib_names=""
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh; do
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh; do
run sudo install -m 644 "lib/$lf" "/usr/local/bin/$lf"
lib_count=$((lib_count + 1))
lib_names+="$lf "
+54 -97
View File
@@ -4,7 +4,6 @@
CONFIG_DIR="$HOME/.config/linux_post_install"
CONFIG_FILE="$CONFIG_DIR/entertainment.env"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
TIMER_PREFIX="pos-entertainment"
DEFAULT_INTERVAL="daily"
@@ -14,6 +13,17 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
declare -F log >/dev/null || log() { echo "[+] $*"; }
# Shared systemd **user** timer machinery (interval→OnCalendar mapping, unit
# pair writer, linger bootstrap) — the same lib the system scheduler uses, so
# the two unit templates never drift apart. Defines USER_SYSTEMD_DIR + ut_*.
source "$(dirname "${BASH_SOURCE[0]}")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/user-timers-lib.sh"
# Per-plugin last-run state (rc + timestamp + first output line).
LAST_RUN_DIR="${LAST_RUN_DIR:-$HOME/.local/share/linux_post_install/entertainment/last}"
# ── Config file helpers (file is the source of truth, never sourced) ──
config_value() {
local k="$1" v
@@ -28,6 +38,14 @@ write_config_key() {
val="${val//$'\r'/}"
val="${val%%$'\n'*}"
mkdir -p "$CONFIG_DIR"
if [ "$val" = "-" ]; then
[ -f "$CONFIG_FILE" ] || return 0
tmp="$(mktemp)"
grep -v "^${key}=" "$CONFIG_FILE" >"$tmp" || true
mv "$tmp" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
return 0
fi
tmp="$(mktemp)"
grep -v "^${key}=" "$CONFIG_FILE" 2>/dev/null >"$tmp" || true
printf '%s="%s"\n' "$key" "$val" >>"$tmp"
@@ -200,96 +218,10 @@ enabled_remove() {
write_config_key ENABLED "$(render_enabled "${out[@]}")"
}
# ── Interval → systemd OnCalendar ─────────────────────────────────
interval_to_oncalendar() {
local i="$1"
case "$i" in
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
esac
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
printf '*:00/%s:00' "$n"; return 0
fi
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
printf '*-*-* 00/%s:00:00' "$n"; return 0
fi
case "$i" in
hourly) printf '*-*-* *:00:00' ;;
daily) printf '*-*-* 08:00:00' ;;
weekly) printf 'Mon *-*-* 08:00:00' ;;
*) return 1 ;;
esac
return 0
}
interval_label() {
case "$1" in
daily) echo "daily (08:00)" ;;
weekly) echo "weekly (Mon 08:00)" ;;
hourly) echo "hourly" ;;
OnCalendar=*) echo "${1#OnCalendar=}" ;;
*m|*h|*d) echo "every $1" ;;
*) echo "$1" ;;
esac
}
# ── systemd user timers ───────────────────────────────────────────
unit_name() { echo "${TIMER_PREFIX}-${1}"; }
write_units() {
local plugin="$1" oncal="$2"
local base="$USER_SYSTEMD_DIR/$(unit_name "$plugin")"
local runner
runner="$(command -v pos-entertainment-send 2>/dev/null || echo /usr/local/bin/pos-entertainment-send)"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$base.service" <<EOF
[Unit]
Description=pos entertainment send $plugin
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$runner $plugin
TimeoutStopSec=5s
[Install]
WantedBy=timers.target
EOF
cat >"$base.timer" <<EOF
[Unit]
Description=Schedule: pos entertainment send $plugin
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$base.service" "$base.timer"
}
ensure_linger() {
local user
user="$(id -un)"
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
command -v loginctl >/dev/null 2>&1 || return 0
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
return 0
fi
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
ok "enabled linger for $user (timers fire without login)"
else
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
fi
}
# Reconcile the auto-trigger schedule with the ENABLED list in the config.
# Backend: systemd user timers (requires a reachable user systemd manager).
# Interval → OnCalendar mapping, unit naming, the unit pair writer and linger
# bootstrap come from lib/user-timers-lib.sh (ut_*). Reconcile the auto-trigger
# schedule with the ENABLED list in the config.
sync_timers() {
if systemctl --user show-environment >/dev/null 2>&1; then
sync_systemd
@@ -312,24 +244,26 @@ sync_systemd() {
warn "plugin '$plugin' not installed — skipping"
continue
fi
if ! oncal="$(interval_to_oncalendar "$interval")"; then
if ! oncal="$(ut_interval_to_oncalendar "$interval")"; then
warn "invalid interval '$interval' for '$plugin' — skipping"
continue
fi
wanted["$plugin"]="$oncal"
done
local wrote=0 t n=0
local runner wrote=0 t n=0
runner="$(command -v pos-entertainment-send 2>/dev/null || echo /usr/local/bin/pos-entertainment-send)"
for plugin in "${!wanted[@]}"; do
write_units "$plugin" "${wanted[$plugin]}"
local base="$USER_SYSTEMD_DIR/$(ut_unit_name "$TIMER_PREFIX" "$plugin")"
ut_write_unit_pair "$base.service" "$base.timer" "pos entertainment send $plugin" "$runner $plugin" "${wanted[$plugin]}"
wrote=1; n=$((n + 1))
done
[ "$wrote" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
[ "$n" -gt 0 ] && ensure_linger
[ "$n" -gt 0 ] && ut_ensure_linger
for plugin in "${!wanted[@]}"; do
t="$(unit_name "$plugin").timer"
t="$(ut_unit_name "$TIMER_PREFIX" "$plugin").timer"
if systemctl --user is-enabled "$t" >/dev/null 2>&1; then
systemctl --user restart "$t" >/dev/null 2>&1 || true
else
@@ -343,8 +277,8 @@ sync_systemd() {
[ -f "$f" ] || continue
p="${f##*/}"; p="${p#${TIMER_PREFIX}-}"; p="${p%.timer}"
if [ -z "${wanted[$p]:-}" ]; then
systemctl --user disable --now "$(unit_name "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(unit_name "$p").timer" "$USER_SYSTEMD_DIR/$(unit_name "$p").service"
systemctl --user disable --now "$(ut_unit_name "$TIMER_PREFIX" "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(ut_unit_name "$TIMER_PREFIX" "$p").timer" "$USER_SYSTEMD_DIR/$(ut_unit_name "$TIMER_PREFIX" "$p").service"
log "removed timer for '$p'"
removed=1
fi
@@ -352,3 +286,26 @@ sync_systemd() {
[ "$removed" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
}
# ── Per-plugin last-run state ────────────────────────────────────
# Written by 'pos entertainment send' on every non---print run so 'status' can
# show whether a scheduled run succeeded. State dir is per-user, not tracked.
save_last_run() { # $1 = plugin, $2 = rc, $3 = message (first line)
local f="$LAST_RUN_DIR/$1"
mkdir -p "$LAST_RUN_DIR"
{
printf 'rc=%s\n' "$2"
printf 'ts=%s\n' "$(date +%s)"
printf 'msg=%s\n' "$3"
} >"$f"
chmod 600 "$f"
}
last_run_str() { # $1 = plugin → "rc=N (MM-DD HH:MM)" or "never"
local f="$LAST_RUN_DIR/$1" rc ts
[ -f "$f" ] || { echo "never"; return 0; }
rc="$(sed -n 's/^rc=//p' "$f" | tail -1)"
ts="$(sed -n 's/^ts=//p' "$f" | tail -1)"
[ -n "$rc" ] || { echo "never"; return 0; }
printf 'rc=%s (%s)' "$rc" "$(date -d "@$ts" '+%m-%d %H:%M' 2>/dev/null || echo '?')"
}
+67
View File
@@ -0,0 +1,67 @@
# lib/entertainment-plugin-lib.sh — message-safe helpers for entertainment
# plugins. Plugins MAY source this (unlike lib/common.sh, whose log/warn/ok
# chatter would leak into the sent message). Contract: this lib NEVER writes to
# stdout — helpers print errors to stderr and exit non-zero, so stdout stays the
# message. Defines only plugin_* names, so it can't collide with common.sh or a
# plugin's own helpers.
#
# Source pattern (works from the repo and from /usr/local/bin after install):
# source "$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
# || source "$(dirname "${BASH_SOURCE[0]}")/entertainment-plugin-lib.sh" 2>/dev/null \
# || source "$(dirname "$0")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
# || source "$(dirname "$0")/entertainment-plugin-lib.sh"
# Config file is the source of truth — read it like the pos tools do (never source it).
plugin_config_file="${PLUGIN_CONFIG_FILE:-$HOME/.config/linux_post_install/entertainment.env}"
plugin_err() { echo "ERROR: $*" >&2; exit 1; }
# Load KEY=VALUE pairs from entertainment.env into the environment.
# Env vars already set win (env precedence); quoted values are stripped;
# comments and blank lines skipped.
plugin_load_config() {
[ -f "$plugin_config_file" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$plugin_config_file" || true)
}
# Fail unless the command is available.
plugin_have() {
command -v "$1" >/dev/null 2>&1 || plugin_err "$1 not found — install it (see DOC/PREINSTALL.md or preinstall.sh PACKAGES)"
}
# Fail unless the config key is set (after plugin_load_config).
plugin_require() {
[ -n "${!1:-}" ] || plugin_err "$1 not set — add it to $plugin_config_file"
}
# Fetch a URL as JSON with curl, retrying twice on transient failures, then
# extract the given jq key (optional). Everything is silent on stdout; errors
# go to stderr and exit non-zero.
plugin_http_json() {
local key="" url="" json
local -a headers=()
while [ $# -gt 0 ]; do
case "$1" in
--key) key="$2"; shift 2 ;;
--header|-H) headers+=(-H "$2"); shift 2 ;;
*) url="$1"; shift ;;
esac
done
[ -n "$url" ] || plugin_err "plugin_http_json: no URL given"
json="$(curl -fsS --max-time 20 --retry 2 --retry-delay 2 "${headers[@]}" "$url")" || \
plugin_err "Failed to fetch $url"
if [ -n "$key" ]; then
json="$(jq -r "$key" <<<"$json")" || plugin_err "Failed to parse '$key' from $url"
fi
printf '%s' "$json"
}
+27 -97
View File
@@ -41,6 +41,14 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
declare -F log >/dev/null || log() { echo "[+] $*"; }
# Shared systemd **user** timer machinery (interval→OnCalendar mapping, unit
# pair writer, linger bootstrap) — the same lib the entertainment module uses,
# so the two unit templates never drift apart. Defines USER_SYSTEMD_DIR + ut_*.
source "$(dirname "${BASH_SOURCE[0]}")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/user-timers-lib.sh"
# ── Job discovery / naming ───────────────────────────────────────
sched_valid_name() {
[[ "$1" =~ ^[a-z0-9][a-z0-9_-]*$ ]]
@@ -341,92 +349,9 @@ sched_run_job() { # $1 = job name
[ "${DRY_RUN:-0}" -ne 1 ] && sched_save_run "$1" "$JOB_OUT" "$JOB_RC"
return 0
}
# ── Interval → systemd OnCalendar (same list as entertainment) ──
sched_interval_to_oncalendar() {
local i="$1"
case "$i" in
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
esac
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
printf '*:00/%s:00' "$n"; return 0
fi
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
printf '*-*-* 00/%s:00:00' "$n"; return 0
fi
case "$i" in
hourly) printf '*-*-* *:00:00' ;;
daily) printf '*-*-* 08:00:00' ;;
weekly) printf 'Mon *-*-* 08:00:00' ;;
*) return 1 ;;
esac
return 0
}
sched_interval_label() {
case "$1" in
daily) echo "daily (08:00)" ;;
weekly) echo "weekly (Mon 08:00)" ;;
hourly) echo "hourly" ;;
OnCalendar=*) echo "${1#OnCalendar=}" ;;
*m|*h) echo "every $1" ;;
*) echo "$1" ;;
esac
}
# ── systemd user timers (one pair per job) ──────────────────────
sched_unit() { printf '%s-%s\n' "$SCHED_PREFIX" "$1"; }
sched_ensure_linger() {
local user
user="$(id -un)"
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
command -v loginctl >/dev/null 2>&1 || return 0
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
return 0
fi
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
ok "enabled linger for $user (timers fire without login)"
else
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
fi
}
sched_write_units() { # $1 = job name, $2 = OnCalendar
local name="$1" oncal="$2"
local base="$USER_SYSTEMD_DIR/$(sched_unit "$name")"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$base.service" <<EOF
[Unit]
Description=pos system schedule — run job $name
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$SCHED_RUNNER run $name
TimeoutStopSec=5s
[Install]
WantedBy=timers.target
EOF
cat >"$base.timer" <<EOF
[Unit]
Description=Schedule: pos system schedule — $name
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$base.service" "$base.timer"
}
# Interval → OnCalendar mapping, unit naming, the unit pair writer and linger
# bootstrap come from lib/user-timers-lib.sh (ut_*).
sched_job_active() { # $1 = job name — valid AND not disabled
sched_read_job "$1" >/dev/null 2>&1 || return 1
[ "$JOB_ENABLED" = "false" ] && return 1
@@ -444,27 +369,32 @@ sched_sync() {
continue
fi
[ "$JOB_ENABLED" = "false" ] && continue # disabled: orphan cleanup drops its timer
if ! oncal="$(sched_interval_to_oncalendar "$JOB_INTERVAL")"; then
if ! oncal="$(ut_interval_to_oncalendar "$JOB_INTERVAL")"; then
warn "invalid interval '$JOB_INTERVAL' for '$name' — skipping"
continue
fi
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) write $(sched_unit "$name").timer (OnCalendar=$oncal)"
log "(dry-run) write $(ut_unit_name "$SCHED_PREFIX" "$name").timer (OnCalendar=$oncal)"
else
sched_write_units "$name" "$oncal"
ut_write_unit_pair \
"$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$name").service" \
"$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$name").timer" \
"pos system schedule — run job $name" \
"$SCHED_RUNNER run $name" \
"$oncal"
fi
wrote=1; n=$((n + 1))
done
[ "${DRY_RUN:-0}" -eq 1 ] && return 0
[ "$wrote" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
[ "$n" -gt 0 ] && sched_ensure_linger
[ "$n" -gt 0 ] && ut_ensure_linger
for name in "${jobs[@]}"; do
if ! sched_read_job "$name" >/dev/null 2>&1; then
continue
fi
[ "$JOB_ENABLED" = "false" ] && continue
sched_interval_to_oncalendar "$JOB_INTERVAL" >/dev/null 2>&1 || continue
local t="$(sched_unit "$name").timer"
ut_interval_to_oncalendar "$JOB_INTERVAL" >/dev/null 2>&1 || continue
local t="$(ut_unit_name "$SCHED_PREFIX" "$name").timer"
if systemctl --user is-enabled "$t" >/dev/null 2>&1; then
systemctl --user restart "$t" >/dev/null 2>&1 || true
else
@@ -483,8 +413,8 @@ sched_remove_orphans() {
[ -f "$f" ] || continue
p="${f##*/}"; p="${p#${SCHED_PREFIX}-}"; p="${p%.timer}"
if ! sched_job_active "$p"; then
systemctl --user disable --now "$(sched_unit "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(sched_unit "$p").timer" "$USER_SYSTEMD_DIR/$(sched_unit "$p").service"
systemctl --user disable --now "$(ut_unit_name "$SCHED_PREFIX" "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$p").timer" "$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$p").service"
log "removed timer for '$p'"
removed=1
fi
@@ -693,8 +623,8 @@ sched_status() {
local st="enabled" oncal next=""
[ "$JOB_ENABLED" = "false" ] && st="disabled"
if [ "$st" = "enabled" ]; then
if oncal="$(sched_interval_to_oncalendar "$JOB_INTERVAL")"; then
next="$(systemctl --user list-timers "$(sched_unit "$name").timer" --no-legend 2>/dev/null | awk '{print $1, $2}' | head -1)"
if oncal="$(ut_interval_to_oncalendar "$JOB_INTERVAL")"; then
next="$(systemctl --user list-timers "$(ut_unit_name "$SCHED_PREFIX" "$name").timer" --no-legend 2>/dev/null | awk '{print $1, $2}' | head -1)"
else
oncal="(invalid interval: $JOB_INTERVAL)"
fi
@@ -743,7 +673,7 @@ sched_editor_add() {
[ -f "$SCHEDULE_DIR/$name.env" ] && { warn "job '$name' already exists — use edit"; return; }
read -rp "interval [5m..59m | 1h..23h | hourly daily weekly | OnCalendar=…] (blank = $SCHED_DEFAULT_INTERVAL): " interval
interval="${interval:-$SCHED_DEFAULT_INTERVAL}"
sched_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
ut_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
read -rp "notify [always|onchange|onerror|threshold|never] (blank = onchange): " notify
notify="${notify:-onchange}"
case "$notify" in always|onchange|onerror|threshold|never) ;; *) warn "unknown policy '$notify' — job will run silently" ;; esac
@@ -775,7 +705,7 @@ sched_editor_edit() {
local interval notify msg rule cmd
read -rp "interval (blank keeps, current: $JOB_INTERVAL): " interval
interval="${interval:-$JOB_INTERVAL}"
sched_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
ut_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
read -rp "notify (blank keeps, current: $JOB_NOTIFY): " notify
notify="${notify:-$JOB_NOTIFY}"
read -rp "message (blank keeps, current: ${JOB_MSG:-<none>}): " msg
+112
View File
@@ -0,0 +1,112 @@
# lib/user-timers-lib.sh — shared systemd **user** timer machinery for the pos
# tools that schedule one-shot jobs: the entertainment module and the system
# scheduler. Sourced by lib/entertainment-lib.sh and lib/scheduler-lib.sh AFTER
# lib/common.sh. Defines only ut_* helpers so it never collides with either lib.
#
# Both libs previously re-implemented the same interval→OnCalendar mapping, unit
# pair writer and linger bootstrap (the two copies drifted — e.g. the
# TimeoutStopSec fix had to be applied to both). This is the single source for:
# ut_interval_to_oncalendar ut_interval_label ut_unit_name
# ut_write_unit_pair ut_ensure_linger
# common.sh helpers (guarded so the lib is safe if common.sh wasn't loaded)
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
declare -F log >/dev/null || log() { echo "[+] $*"; }
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
# ── Interval → systemd OnCalendar ─────────────────────────────────
# Named + numeric intervals shared by both schedulers, or a raw OnCalendar=…
# spec passed through unchanged. Returns non-zero for invalid values.
ut_interval_to_oncalendar() {
local i="$1"
case "$i" in
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
esac
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
printf '*:00/%s:00' "$n"; return 0
fi
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
printf '*-*-* 00/%s:00:00' "$n"; return 0
fi
case "$i" in
hourly) printf '*-*-* *:00:00' ;;
daily) printf '*-*-* 08:00:00' ;;
weekly) printf 'Mon *-*-* 08:00:00' ;;
*) return 1 ;;
esac
return 0
}
ut_interval_label() {
case "$1" in
daily) echo "daily (08:00)" ;;
weekly) echo "weekly (Mon 08:00)" ;;
hourly) echo "hourly" ;;
OnCalendar=*) echo "${1#OnCalendar=}" ;;
*m|*h|*d) echo "every $1" ;;
*) echo "$1" ;;
esac
}
# ── Unit naming / writer ──────────────────────────────────────────
ut_unit_name() { # $1 = prefix (e.g. pos-entertainment), $2 = name → prefix-name
printf '%s-%s\n' "$1" "$2"
}
# Write one service/timer pair (oneshot, network-online, Persistent).
# Usage: ut_write_unit_pair <service-file> <timer-file> <description> <execstart> <oncal>
ut_write_unit_pair() {
local svc="$1" timer="$2" desc="$3" execstart="$4" oncal="$5"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$svc" <<EOF
[Unit]
Description=$desc
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$execstart
TimeoutStopSec=5s
[Install]
WantedBy=timers.target
EOF
cat >"$timer" <<EOF
[Unit]
Description=Schedule: $desc
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$svc" "$timer"
}
# ── Linger bootstrap ──────────────────────────────────────────────
# Timers only fire without a login when the user has linger enabled; try once,
# warn with the exact command otherwise (never fails the caller).
ut_ensure_linger() {
local user
user="$(id -un)"
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
command -v loginctl >/dev/null 2>&1 || return 0
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
return 0
fi
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
ok "enabled linger for $user (timers fire without login)"
else
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
fi
}