WIP
This commit is contained in:
@@ -74,6 +74,9 @@ nix build .#nixosConfigurations.mercury-vm.config.system.build.vm -o result
|
|||||||
nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA
|
nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA
|
||||||
# end-to-end VM test of `deploy kexec-local` (~45s once the tarball is built)
|
# end-to-end VM test of `deploy kexec-local` (~45s once the tarball is built)
|
||||||
nix build .#checks.x86_64-linux.kexec-local -L
|
nix build .#checks.x86_64-linux.kexec-local -L
|
||||||
|
# VM test of luna's app hosting on mars (hosts/mars/luna-sites.nix): podman socket
|
||||||
|
# proxy, registry validation, caddy routes, reboot persistence
|
||||||
|
nix build .#checks.x86_64-linux.luna-sites -L
|
||||||
```
|
```
|
||||||
|
|
||||||
`checks.kexec-local` is the only way to exercise `kexec-local` at all: it jumps the
|
`checks.kexec-local` is the only way to exercise `kexec-local` at all: it jumps the
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services),
|
Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services),
|
||||||
`neptun` (netcup VPS: public reverse proxy, Authentik, headscale),
|
`neptun` (netcup VPS: public reverse proxy, Authentik, headscale),
|
||||||
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site,
|
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site:
|
||||||
single-purpose: Hermes Agent only).
|
Hermes Agent, plus the LAN web apps luna hosts at `http://mars.sol/<name>/`).
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -27,9 +27,12 @@ hosts/
|
|||||||
vm.nix # VirtualBox test image (jupiter-vbox)
|
vm.nix # VirtualBox test image (jupiter-vbox)
|
||||||
neptun/ # netcup public reverse proxy + tailnet node
|
neptun/ # netcup public reverse proxy + tailnet node
|
||||||
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
||||||
mars/ # on-site, single-purpose: Hermes Agent only
|
mars/ # on-site: Hermes Agent + luna's LAN web apps
|
||||||
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
||||||
hermes-agent.nix # Hermes Agent (moved here from jupiter)
|
hermes-agent.nix # Hermes Agent (moved here from jupiter)
|
||||||
|
luna-sites.nix # luna's apps: rootless podman + caddy, no nix edit per app
|
||||||
|
luna-sites-README.md # what luna is told (mounted into her container)
|
||||||
|
luna-sites-test.nix # VM test: nix build .#checks.x86_64-linux.luna-sites -L
|
||||||
secrets/ # age-encrypted sops files, one per host
|
secrets/ # age-encrypted sops files, one per host
|
||||||
scripts/ # deploy, edit_secrets
|
scripts/ # deploy, edit_secrets
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -99,16 +99,21 @@ on it, hence the index loops in `HyprChromeShell`.
|
|||||||
grid) shared by the rail and the polkit prompt; `Widgets/Bar/` holds the rail
|
grid) shared by the rail and the polkit prompt; `Widgets/Bar/` holds the rail
|
||||||
and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all
|
and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all
|
||||||
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
|
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
|
||||||
|
`Widgets/Launcher/` is the primary application launcher (`SUPER_L`);
|
||||||
`Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a
|
`Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a
|
||||||
single widget on the secondary monitor for eyeballing it in isolation.
|
single widget on the secondary monitor for eyeballing it in isolation.
|
||||||
|
|
||||||
|
The prompt and the launcher are MODALS: each raises the shared scrim, lands
|
||||||
|
on the focused monitor, and takes the keyboard off the rail. That is why the
|
||||||
|
shell instantiates them rather than `shell.qml` — see `modalOpen` there, which
|
||||||
|
is the one place a new modal has to be named.
|
||||||
- `widgets/bar/` — `DenseBar` and `StatusBarPanel`, the rail's predecessor. Not
|
- `widgets/bar/` — `DenseBar` and `StatusBarPanel`, the rail's predecessor. Not
|
||||||
instantiated by `shell.qml` any more; `StatusBarPanel` is still used by the
|
instantiated by `shell.qml` any more; `StatusBarPanel` is still used by the
|
||||||
launchers.
|
remaining launcher variants.
|
||||||
- `widgets/launcher/` — shared `AppModel` search/execution plus eleven launcher
|
- `widgets/launcher/` — shared `AppModel` search/execution plus the ten launcher
|
||||||
variants. `ApplicationLauncher` (variant 8, and the primary `SUPER` launcher)
|
variants still under evaluation, on `SUPER CTRL 1–11`. Variant 8 has moved to
|
||||||
keeps its visual core in the headlessly renderable
|
`HyprChrome/Widgets/Launcher/`; `AppModel.qml` is duplicated there so the
|
||||||
`ApplicationLauncherContent`; the rest are available on `SUPER CTRL 1–11` for
|
HyprChrome tree stands alone, and this copy goes when the variants do.
|
||||||
comparison.
|
|
||||||
- `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle.
|
- `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle.
|
||||||
- `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`).
|
- `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`).
|
||||||
- `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space.
|
- `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space.
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import qs.HyprChrome.Theme
|
||||||
|
import qs.HyprChrome.Widgets.Polkit
|
||||||
|
|
||||||
|
// GUI password prompt for `sudo -A`, reusing the polkit dialog.
|
||||||
|
//
|
||||||
|
// sudo does NOT speak polkit — it is setuid + PAM reading your tty, and no
|
||||||
|
// sudoers option bridges the two. What it does support is an ASKPASS helper: a
|
||||||
|
// program it runs to obtain the password, which prints it on stdout. So this is
|
||||||
|
// not the polkit agent serving sudo; it is a second, separate path that happens
|
||||||
|
// to render the same dialog.
|
||||||
|
//
|
||||||
|
// Flow, driven by the helper in home.nix (`qs-askpass`):
|
||||||
|
//
|
||||||
|
// sudo -A
|
||||||
|
// -> qs-askpass makes a 0600 fifo under XDG_RUNTIME_DIR
|
||||||
|
// -> qs ipc call askpass prompt "<prompt>" "<fifo>" (returns at once)
|
||||||
|
// -> this dialog opens, user types
|
||||||
|
// -> a one-line writer is started here, secret written to its STDIN
|
||||||
|
// -> qs-askpass reads the fifo and prints the secret on stdout
|
||||||
|
// -> sudo reads it
|
||||||
|
//
|
||||||
|
// The secret travels on a pipe the whole way. It is never an argument and never
|
||||||
|
// an environment variable, so it does not appear in /proc for any process — the
|
||||||
|
// fifo PATH is in argv, which is not secret. It does cross more process
|
||||||
|
// boundaries than the polkit path, where the password stays inside the PAM
|
||||||
|
// conversation; that is the inherent cost of askpass, not of this design.
|
||||||
|
//
|
||||||
|
// Cancelling answers with an empty line, so the helper reads nothing, exits
|
||||||
|
// non-zero, and sudo aborts rather than burning a retry on a blank password.
|
||||||
|
Scope {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Which output to appear on; the shell puts it on the focused monitor.
|
||||||
|
property var screen: null
|
||||||
|
|
||||||
|
// The fifo the helper is blocked reading. Non-empty means a request is in
|
||||||
|
// flight, which is exactly what "a prompt is open" means here.
|
||||||
|
property string fifoPath: ""
|
||||||
|
property string promptText: ""
|
||||||
|
property bool failed: false
|
||||||
|
|
||||||
|
readonly property bool active: root.fifoPath !== ""
|
||||||
|
|
||||||
|
// Held only between submit and the writer process actually starting: a
|
||||||
|
// Process cannot be written to before it is running.
|
||||||
|
property string pendingSecret: ""
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "askpass"
|
||||||
|
|
||||||
|
// Called by qs-askpass. Returns immediately — the helper blocks on the
|
||||||
|
// fifo, not on this call, because an IpcHandler function runs on the
|
||||||
|
// QML thread and blocking here would freeze the whole shell.
|
||||||
|
function prompt(message: string, fifo: string): string {
|
||||||
|
if (root.active)
|
||||||
|
return "busy";
|
||||||
|
|
||||||
|
root.promptText = message === "" ? "Password:" : message;
|
||||||
|
root.fifoPath = fifo;
|
||||||
|
root.failed = false;
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
// So a helper that times out can take the dialog down with it rather
|
||||||
|
// than leaving it on screen with nothing listening.
|
||||||
|
function cancel(): string {
|
||||||
|
root.dismiss();
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelling answers with an EMPTY line rather than by closing silently:
|
||||||
|
// the helper then reads zero bytes and exits non-zero, so sudo aborts
|
||||||
|
// instead of spending a retry on a blank password.
|
||||||
|
function dismiss() {
|
||||||
|
root.respond("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(secret) {
|
||||||
|
root.respond(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The writer reads ONE LINE and exits; it does not wait for EOF.
|
||||||
|
//
|
||||||
|
// The obvious version — `cat > fifo`, write the secret, then close stdin by
|
||||||
|
// setting stdinEnabled false — does not terminate. Measured: the secret
|
||||||
|
// arrives intact but `cat` never sees EOF, so the fifo is never closed and
|
||||||
|
// the helper blocks until its timeout. sudo would hang after you typed.
|
||||||
|
//
|
||||||
|
// A single `read` needs no EOF at all: the trailing newline ends it, the
|
||||||
|
// shell writes what it got and exits, and THAT close is what gives the
|
||||||
|
// helper its EOF. `IFS=` keeps leading and trailing whitespace, `-r` keeps
|
||||||
|
// backslashes, and the secret still travels on stdin rather than in argv.
|
||||||
|
function respond(secret) {
|
||||||
|
if (!root.active)
|
||||||
|
return;
|
||||||
|
|
||||||
|
root.pendingSecret = secret + "\n";
|
||||||
|
writer.command = ["sh", "-c", "IFS= read -r line; printf %s \"$line\" > \"$1\"", "sh", root.fifoPath];
|
||||||
|
writer.running = true;
|
||||||
|
root.fifoPath = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening a fifo for writing BLOCKS until a reader attaches, which is why
|
||||||
|
// this is a subprocess rather than a FileView: the helper's `cat` is that
|
||||||
|
// reader, and blocking the QML thread on it would freeze the shell.
|
||||||
|
Process {
|
||||||
|
id: writer
|
||||||
|
|
||||||
|
stdinEnabled: true
|
||||||
|
|
||||||
|
// Written on `started`, not at respond() time: a Process has no stdin
|
||||||
|
// to write to until it is actually running.
|
||||||
|
onStarted: {
|
||||||
|
writer.write(root.pendingSecret);
|
||||||
|
root.pendingSecret = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: win
|
||||||
|
|
||||||
|
screen: root.screen
|
||||||
|
visible: root.active
|
||||||
|
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
color: Theme.textAlpha(0)
|
||||||
|
|
||||||
|
anchors {
|
||||||
|
top: true
|
||||||
|
left: true
|
||||||
|
right: true
|
||||||
|
bottom: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// No click-off dismissal, for the same reason the polkit dialog has
|
||||||
|
// none: something is blocked waiting on the answer, and losing it to a
|
||||||
|
// stray click would leave sudo hanging with no visible cause.
|
||||||
|
|
||||||
|
PolkitPromptContent {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
y: Math.max(32, Math.round(parent.height / 3 - height / 2))
|
||||||
|
width: 520
|
||||||
|
|
||||||
|
// Deliberately the polkit dialog's own content component: this is a
|
||||||
|
// password prompt with the same shape, and keeping one means a
|
||||||
|
// restyle of PolkitPanel covers both. `identities` stays empty —
|
||||||
|
// sudo offers no choice of who authenticates — which hides the
|
||||||
|
// picker and the "AS" line on its own.
|
||||||
|
message: "Authentication is required to run a command as another user"
|
||||||
|
actionId: "sudo"
|
||||||
|
iconName: ""
|
||||||
|
showIcon: false
|
||||||
|
identities: []
|
||||||
|
|
||||||
|
responseRequired: true
|
||||||
|
inputPrompt: root.promptText
|
||||||
|
responseVisible: false
|
||||||
|
failed: root.failed
|
||||||
|
|
||||||
|
onSubmitted: value => root.submit(value)
|
||||||
|
onCancelled: root.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (win.visible) {
|
||||||
|
content.clearResponse();
|
||||||
|
content.focusInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import Quickshell.Wayland
|
|||||||
import qs.HyprChrome.Widgets.Bar
|
import qs.HyprChrome.Widgets.Bar
|
||||||
import qs.HyprChrome.Widgets
|
import qs.HyprChrome.Widgets
|
||||||
import qs.HyprChrome.Widgets.Polkit
|
import qs.HyprChrome.Widgets.Polkit
|
||||||
|
import qs.HyprChrome.Widgets.Launcher
|
||||||
|
import qs.HyprChrome.Widgets.Askpass
|
||||||
|
|
||||||
// The hyprchrome shell: owns everything the rail's surfaces have to agree on,
|
// The hyprchrome shell: owns everything the rail's surfaces have to agree on,
|
||||||
// and instantiates them.
|
// and instantiates them.
|
||||||
@@ -17,10 +19,11 @@ import qs.HyprChrome.Widgets.Polkit
|
|||||||
// * which monitor the shell lives on — every surface has to pick the same one
|
// * which monitor the shell lives on — every surface has to pick the same one
|
||||||
// * the density — the whole rail expands and collapses as one, so the toggle
|
// * the density — the whole rail expands and collapses as one, so the toggle
|
||||||
// and the shortcut that drives it belong to the shell, not to the bar
|
// and the shortcut that drives it belong to the shell, not to the bar
|
||||||
// * whether an authorization prompt is up — it raises the same scrim the rail
|
// * whether a MODAL is open — the polkit prompt, the launcher, or the sudo
|
||||||
// uses and freezes the density while it is open, so two surfaces read it.
|
// askpass dialog. Each raises the same scrim the rail uses, freezes the
|
||||||
// That is why the agent lives here rather than as a sibling of the
|
// density, lands on the focused monitor and takes the keyboard off the
|
||||||
// launchers in shell.qml
|
// rail, so several surfaces read it. That is why they live here rather than
|
||||||
|
// as siblings of the remaining launcher variants in shell.qml
|
||||||
// * the layer PAIR — the backdrop must sit exactly one layer below the bar in
|
// * the layer PAIR — the backdrop must sit exactly one layer below the bar in
|
||||||
// both densities. Two surfaces on the same layer stack by creation order,
|
// both densities. Two surfaces on the same layer stack by creation order,
|
||||||
// which is not something to rely on; one layer apart is a guarantee. Split
|
// which is not something to rely on; one layer apart is a guarantee. Split
|
||||||
@@ -96,24 +99,30 @@ Scope {
|
|||||||
// Frozen while a prompt is up, and dropped rather than queued: SUPER A
|
// Frozen while a prompt is up, and dropped rather than queued: SUPER A
|
||||||
// during a prompt does nothing at all, instead of arming a change that
|
// during a prompt does nothing at all, instead of arming a change that
|
||||||
// springs the rail open or shut the moment the dialog goes.
|
// springs the rail open or shut the moment the dialog goes.
|
||||||
if (polkit.prompting)
|
if (shell.modalOpen)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
shell.expanded = !shell.expanded;
|
shell.expanded = !shell.expanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the scrim is up, from EITHER cause. This is the fact the surfaces
|
// The surfaces that take over the screen: they dim EVERY output, land on the
|
||||||
// actually share — the rail's density is only one of the two things that
|
// focused one, and take the keyboard off the rail. Grouped because
|
||||||
// can raise it — so the backdrop and the layer pair below key off this
|
// everything below treats them alike, so a fourth one joins by being named
|
||||||
// rather than off `expanded`.
|
// here and nowhere else.
|
||||||
|
readonly property bool modalOpen: polkit.prompting || launcher.active || askpass.active
|
||||||
|
|
||||||
|
// Whether the scrim is up, from ANY cause. This is the fact the surfaces
|
||||||
|
// actually share — the rail's density is only one of the things that can
|
||||||
|
// raise it — so the backdrop and the layer pair below key off this rather
|
||||||
|
// than off `expanded`.
|
||||||
//
|
//
|
||||||
// One backdrop instance serves both. A prompt arriving over an already
|
// One backdrop instance serves all of them. A modal opening over an already
|
||||||
// expanded rail therefore changes nothing about the scrim: it is already up,
|
// expanded rail therefore changes nothing about the scrim on that monitor:
|
||||||
// already full height, and the dialog simply appears above it. A prompt over
|
// it is already up, already full height, and the modal simply appears above
|
||||||
// a COLLAPSED rail expands that same scrim from its bar-height band to the
|
// it. Over a COLLAPSED rail the same scrim expands from its bar-height band
|
||||||
// whole output, using the animation it already has, and the rail stays
|
// to the whole output, using the animation it already has, and the rail
|
||||||
// collapsed throughout.
|
// stays collapsed throughout.
|
||||||
readonly property bool scrimUp: shell.expanded || polkit.prompting
|
readonly property bool scrimUp: shell.expanded || shell.modalOpen
|
||||||
|
|
||||||
// Scrim up, the rail is over everything; scrim down, it drops below ordinary
|
// Scrim up, the rail is over everything; scrim down, it drops below ordinary
|
||||||
// windows. BOTTOM rather than BACKGROUND for the lowered bar: it is the
|
// windows. BOTTOM rather than BACKGROUND for the lowered bar: it is the
|
||||||
@@ -174,7 +183,7 @@ Scope {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
screen: modelData
|
screen: modelData
|
||||||
active: polkit.prompting
|
active: shell.modalOpen
|
||||||
wlrLayer: WlrLayer.Top
|
wlrLayer: WlrLayer.Top
|
||||||
barHeight: 0
|
barHeight: 0
|
||||||
}
|
}
|
||||||
@@ -196,7 +205,7 @@ Scope {
|
|||||||
// the rail. Withheld rather than left to the compositor to arbitrate
|
// the rail. Withheld rather than left to the compositor to arbitrate
|
||||||
// between two exclusive surfaces, which would decide by stacking and
|
// between two exclusive surfaces, which would decide by stacking and
|
||||||
// silently swap the order the day the layers change.
|
// silently swap the order the day the layers change.
|
||||||
grabsKeyboard: shell.expanded && !polkit.prompting
|
grabsKeyboard: shell.expanded && !shell.modalOpen
|
||||||
|
|
||||||
onDismissed: shell.expanded = false
|
onDismissed: shell.expanded = false
|
||||||
}
|
}
|
||||||
@@ -220,4 +229,23 @@ Scope {
|
|||||||
|
|
||||||
screen: shell.focusedScreen
|
screen: shell.focusedScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Primary application launcher — SUPER_L. Migrated out of
|
||||||
|
// widgets/launcher/; the ten remaining variants are still evaluation copies
|
||||||
|
// and stay in shell.qml. Declared after the bar for the same reason the
|
||||||
|
// prompt is: while it is open the bar is on Overlay too, and there is no
|
||||||
|
// layer above Overlay to escape to.
|
||||||
|
AppLauncher {
|
||||||
|
id: launcher
|
||||||
|
|
||||||
|
screen: shell.focusedScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUI password prompt for `sudo -A`. Not the polkit agent — sudo cannot use
|
||||||
|
// one — but it renders the same dialog. See the file for the flow.
|
||||||
|
AskpassPrompt {
|
||||||
|
id: askpass
|
||||||
|
|
||||||
|
screen: shell.focusedScreen
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-14
@@ -4,18 +4,41 @@ import Quickshell
|
|||||||
import Quickshell.Hyprland
|
import Quickshell.Hyprland
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.widgets.theme
|
import qs.HyprChrome.Theme
|
||||||
|
|
||||||
// Primary application launcher (variant 8). The full-screen layer-shell adapter
|
// Primary application launcher — the one on SUPER_L.
|
||||||
// owns focus, DesktopEntries and execution; ApplicationLauncherContent remains
|
//
|
||||||
// an Item so the complete visual state can be rendered headlessly.
|
// Migrated from widgets/launcher/ApplicationLauncher.qml. Two things changed in
|
||||||
|
// the move, both because HyprChromeShell now owns the state its surfaces share:
|
||||||
|
//
|
||||||
|
// * no scrim of its own. The shell raises the single ChromeBackdrop for any
|
||||||
|
// of its causes — expanded rail, polkit prompt, this — so opening the
|
||||||
|
// launcher over an already-expanded rail reuses the scrim that is there
|
||||||
|
// rather than laying a second dim on top of it.
|
||||||
|
// * `active` is read by the shell, which uses it to raise that scrim, to
|
||||||
|
// place this on the focused monitor, and to decide who gets the keyboard.
|
||||||
|
//
|
||||||
|
// The full-screen layer-shell adapter owns focus, DesktopEntries and execution;
|
||||||
|
// AppLauncherContent stays an Item so the whole visual state can be rendered
|
||||||
|
// headlessly (tests/AppLauncherHeadless.qml).
|
||||||
Scope {
|
Scope {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool active: false
|
property bool active: false
|
||||||
|
|
||||||
function toggle() { root.active = !root.active; }
|
// Which output to appear on. Driven by the shell, which puts it on the
|
||||||
|
// focused monitor — a launcher belongs where the user is looking, which is
|
||||||
|
// not necessarily where the rail lives.
|
||||||
|
property var screen: null
|
||||||
|
|
||||||
|
function toggle() { root.active = !root.active; }
|
||||||
|
function close() { root.active = false; }
|
||||||
|
|
||||||
|
// The name is legacy: this was "variant 8" of eleven, and both SUPER_L (via
|
||||||
|
// open_launcher.sh) and SUPER CTRL 8 still dispatch quickshell:launcher8.
|
||||||
|
// Renaming it means editing hosts/terra/home/hyprland.nix AND the script
|
||||||
|
// together, and neither takes effect until a deploy — so the shortcut would
|
||||||
|
// be dead in the running session in between. Kept as-is deliberately.
|
||||||
GlobalShortcut {
|
GlobalShortcut {
|
||||||
name: "launcher8"
|
name: "launcher8"
|
||||||
description: "Toggle dense application command index"
|
description: "Toggle dense application command index"
|
||||||
@@ -25,7 +48,9 @@ Scope {
|
|||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: win
|
id: win
|
||||||
|
|
||||||
|
screen: root.screen
|
||||||
visible: root.active
|
visible: root.active
|
||||||
|
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
@@ -71,19 +96,18 @@ Scope {
|
|||||||
search: content.query
|
search: content.query
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// Click-off dismissal. The scrim itself belongs to the shell and takes
|
||||||
anchors.fill: parent
|
// no input (its mask is empty), so the catcher lives here: a
|
||||||
color: Theme.surface
|
// transparent full-surface MouseArea UNDER the content, which is what
|
||||||
opacity: 0.72
|
// keeps clicks on the launcher itself from closing it.
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
onClicked: root.active = false
|
onClicked: root.close()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplicationLauncherContent {
|
AppLauncherContent {
|
||||||
id: content
|
id: content
|
||||||
|
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
width: 1080
|
width: 1080
|
||||||
height: 620
|
height: 620
|
||||||
@@ -95,7 +119,7 @@ Scope {
|
|||||||
onSelectionRequested: index => win.selectedIndex = win.clampSelection(index)
|
onSelectionRequested: index => win.selectedIndex = win.clampSelection(index)
|
||||||
onMoveRequested: delta => win.move(delta)
|
onMoveRequested: delta => win.move(delta)
|
||||||
onLaunchRequested: index => win.launch(index)
|
onLaunchRequested: index => win.launch(index)
|
||||||
onDismissRequested: root.active = false
|
onDismissRequested: root.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-9
@@ -5,12 +5,11 @@ import Quickshell.Widgets
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Shapes
|
import QtQuick.Shapes
|
||||||
import qs.widgets.bar
|
import qs.HyprChrome.Theme
|
||||||
import qs.widgets.theme
|
|
||||||
|
|
||||||
// Headlessly renderable visual core for the primary application launcher.
|
// Headlessly renderable visual core for the primary application launcher.
|
||||||
// Runtime concerns (DesktopEntries, layer shell, launching) stay in
|
// Runtime concerns (DesktopEntries, layer shell, focus, launching) stay in
|
||||||
// ApplicationLauncher.qml; this component only renders state and emits intent.
|
// AppLauncher.qml; this component only renders state and emits intent.
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusBarPanel {
|
LauncherPanel {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
panelId: "008"
|
panelId: "008"
|
||||||
title: "APPLICATION COMMAND INDEX"
|
title: "APPLICATION COMMAND INDEX"
|
||||||
@@ -78,7 +77,7 @@ Item {
|
|||||||
chamfer: 18
|
chamfer: 18
|
||||||
|
|
||||||
// Query module.
|
// Query module.
|
||||||
StatusBarPanel {
|
LauncherPanel {
|
||||||
id: queryPanel
|
id: queryPanel
|
||||||
x: 18
|
x: 18
|
||||||
y: 34
|
y: 34
|
||||||
@@ -184,7 +183,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search result table.
|
// Search result table.
|
||||||
StatusBarPanel {
|
LauncherPanel {
|
||||||
id: resultPanel
|
id: resultPanel
|
||||||
x: 18
|
x: 18
|
||||||
y: 118
|
y: 118
|
||||||
@@ -347,7 +346,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Selected application inspector.
|
// Selected application inspector.
|
||||||
StatusBarPanel {
|
LauncherPanel {
|
||||||
id: inspector
|
id: inspector
|
||||||
x: 700
|
x: 700
|
||||||
y: 34
|
y: 34
|
||||||
@@ -569,7 +568,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dense command footer.
|
// Dense command footer.
|
||||||
StatusBarPanel {
|
LauncherPanel {
|
||||||
x: 18
|
x: 18
|
||||||
y: 550
|
y: 550
|
||||||
width: 1044
|
width: 1044
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
// Non-visual, reusable app-search model shared by every launcher variant.
|
||||||
|
// Set `search`; read `apps` (a ranked, filtered list of DesktopEntry).
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string search: ""
|
||||||
|
|
||||||
|
// `keywords`/`categories` come through as string lists, so coerce every
|
||||||
|
// field to a string before matching (String([]) joins with commas).
|
||||||
|
function haystack(a) {
|
||||||
|
return (String(a.name || "") + " " + String(a.genericName || "") + " " + String(a.comment || "") + " " + String(a.keywords || "")).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var apps: {
|
||||||
|
const all = DesktopEntries.applications.values.filter(a => !a.noDisplay);
|
||||||
|
const q = root.search.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (q.length === 0)
|
||||||
|
return all.slice().sort((x, y) => String(x.name).localeCompare(String(y.name)));
|
||||||
|
|
||||||
|
const matches = all.filter(a => root.haystack(a).includes(q));
|
||||||
|
|
||||||
|
// Prefix matches on the visible name rank first, then alphabetical.
|
||||||
|
return matches.slice().sort((x, y) => {
|
||||||
|
const xs = String(x.name).toLowerCase().startsWith(q) ? 0 : 1;
|
||||||
|
const ys = String(y.name).toLowerCase().startsWith(q) ? 0 : 1;
|
||||||
|
if (xs !== ys)
|
||||||
|
return xs - ys;
|
||||||
|
return String(x.name).localeCompare(String(y.name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(index) {
|
||||||
|
const list = root.apps;
|
||||||
|
if (index >= 0 && index < list.length) {
|
||||||
|
list[index].execute();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Shapes
|
||||||
|
import qs.HyprChrome.Theme
|
||||||
|
|
||||||
|
// Chamfered panel chrome for the launcher: outline, corner accent lines, and
|
||||||
|
// the optional header strip (id chip / title / meta / tick marks). Content is
|
||||||
|
// supplied as children by the call site.
|
||||||
|
//
|
||||||
|
// A sibling of BarPanel rather than a use of it: BarPanel is almost entirely
|
||||||
|
// density machinery (summary slot, animated height, state pair, transitions)
|
||||||
|
// for a rail that expands and collapses, and the launcher has exactly one
|
||||||
|
// density. Same reasoning as PolkitPanel — see that file.
|
||||||
|
//
|
||||||
|
// Carried over from widgets/bar/StatusBarPanel.qml, which the legacy launcher
|
||||||
|
// variants still use. Restyle this one freely; it is read only by the launcher.
|
||||||
|
Item {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
property string panelId: ""
|
||||||
|
property string title: ""
|
||||||
|
property string meta: ""
|
||||||
|
property bool showHeader: true
|
||||||
|
property int chamfer: 13
|
||||||
|
property int offsetY: 2
|
||||||
|
property int accentLineThickness: 3
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: panelShape
|
||||||
|
anchors.fill: parent
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
fillColor: Theme.surface
|
||||||
|
strokeColor: Theme.hair
|
||||||
|
strokeWidth: 1
|
||||||
|
startX: 0; startY: panel.offsetY
|
||||||
|
PathLine { x: panelShape.width - panel.chamfer; y: panel.offsetY }
|
||||||
|
PathLine { x: panelShape.width; y: panel.chamfer }
|
||||||
|
PathLine { x: panelShape.width; y: panelShape.height }
|
||||||
|
PathLine { x: panel.chamfer; y: panelShape.height }
|
||||||
|
PathLine { x: 0; y: panelShape.height - panel.chamfer }
|
||||||
|
PathLine { x: 0; y: panel.offsetY }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upper left accent line
|
||||||
|
ShapePath {
|
||||||
|
fillColor: Theme.accent
|
||||||
|
strokeWidth: 0
|
||||||
|
startX: 0; startY: 0
|
||||||
|
PathLine { x: Math.min(49, panelShape.width / 3); y: 0 }
|
||||||
|
PathLine { x: Math.min(49, panelShape.width / 3); y: panel.accentLineThickness }
|
||||||
|
PathLine { x: 0; y: panel.accentLineThickness }
|
||||||
|
PathLine { x: 0; y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower right accent line
|
||||||
|
ShapePath {
|
||||||
|
fillColor: Theme.accent
|
||||||
|
strokeWidth: 0
|
||||||
|
startX: panelShape.width; startY: panelShape.height
|
||||||
|
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height }
|
||||||
|
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness }
|
||||||
|
PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness }
|
||||||
|
PathLine { x: panelShape.width; y: panelShape.height }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: panel.showHeader
|
||||||
|
x: 1; y: 22
|
||||||
|
width: parent.width - 2
|
||||||
|
height: 1
|
||||||
|
color: Theme.text
|
||||||
|
opacity: 0.12
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: panel.showHeader
|
||||||
|
x: 5; y: 7
|
||||||
|
width: panel.panelId.length > 2 ? 29 : 24
|
||||||
|
height: 11
|
||||||
|
color: Theme.accent
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: panel.panelId
|
||||||
|
color: Theme.surface
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 8
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: panel.showHeader
|
||||||
|
x: 40; y: 7
|
||||||
|
width: parent.width - 105
|
||||||
|
text: panel.title
|
||||||
|
color: Theme.text
|
||||||
|
font.family: Theme.displayFont
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.1
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inlined rather than reusing DenseBarContent's MicroText, which is an
|
||||||
|
// inline component and therefore not visible from another file.
|
||||||
|
Text {
|
||||||
|
visible: panel.showHeader && panel.meta.length > 0
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
y: 6
|
||||||
|
text: panel.meta
|
||||||
|
width: Math.min(80, parent.width / 4)
|
||||||
|
color: Theme.muted
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 6
|
||||||
|
font.letterSpacing: 0.7
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
visible: panel.showHeader
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
y: 14
|
||||||
|
spacing: 2
|
||||||
|
Repeater {
|
||||||
|
model: 5
|
||||||
|
Rectangle { required property int index; width: 4; height: 2; color: Theme.accent }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,11 @@ Scope {
|
|||||||
// Dense multi-monitor status rail; visual core is headlessly renderable.
|
// Dense multi-monitor status rail; visual core is headlessly renderable.
|
||||||
HyprChromeShell {}
|
HyprChromeShell {}
|
||||||
|
|
||||||
// App launcher variants — 1–11; variant 8 remains the primary HUD.
|
// App launcher variants still under evaluation, on SUPER CTRL 1–11.
|
||||||
|
// Variant 8 — the primary launcher on SUPER_L — has moved into
|
||||||
|
// HyprChrome/Widgets/Launcher and is instantiated by HyprChromeShell,
|
||||||
|
// because the shell owns the scrim, the focused monitor and the keyboard
|
||||||
|
// arbitration it now shares with the polkit prompt.
|
||||||
LauncherStack {} // 1 — left vertical list
|
LauncherStack {} // 1 — left vertical list
|
||||||
LauncherGrid {} // 2 — centered icon grid
|
LauncherGrid {} // 2 — centered icon grid
|
||||||
LauncherSpotlight {} // 3 — top-center command bar
|
LauncherSpotlight {} // 3 — top-center command bar
|
||||||
@@ -28,7 +32,6 @@ Scope {
|
|||||||
LauncherDock {} // 5 — deck rising from the bottom bar
|
LauncherDock {} // 5 — deck rising from the bottom bar
|
||||||
LauncherSlant {} // 6 — angular / sheared panel
|
LauncherSlant {} // 6 — angular / sheared panel
|
||||||
LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot)
|
LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot)
|
||||||
ApplicationLauncher {} // 8 — dense HUD command index (primary)
|
|
||||||
BladeLauncher {} // 9 — asymmetric blade matrix
|
BladeLauncher {} // 9 — asymmetric blade matrix
|
||||||
OrbitLauncher {} // 10 — radial targeting arena
|
OrbitLauncher {} // 10 — radial targeting arena
|
||||||
CyberDock {} // 11 — cyberpunk bottom cartridge dock
|
CyberDock {} // 11 — cyberpunk bottom cartridge dock
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.widgets.launcher
|
import qs.HyprChrome.Widgets.Launcher
|
||||||
|
|
||||||
ApplicationLauncherContent {
|
AppLauncherContent {
|
||||||
width: 1080
|
width: 1080
|
||||||
height: 620
|
height: 620
|
||||||
|
|
||||||
Generated
+40
-40
@@ -14,11 +14,11 @@
|
|||||||
"uv2nix": "uv2nix"
|
"uv2nix": "uv2nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787577519,
|
"lastModified": 1788820723,
|
||||||
"narHash": "sha256-YNAXQTgR26RJiX2vtYjk6OtBu2jMWeq4qUN6sUrt6Lc=",
|
"narHash": "sha256-NIqHasKysUniYrJozavASUF4MqpsyBg7lZZNtM+WrwI=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "authentik-nix",
|
"repo": "authentik-nix",
|
||||||
"rev": "30c37930450d7a5fefa8ffec613f037fc75c3071",
|
"rev": "fd34a5238314351ed92dd79d00f518b8a03e19cb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -30,16 +30,16 @@
|
|||||||
"authentik-src": {
|
"authentik-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784731584,
|
"lastModified": 1788268887,
|
||||||
"narHash": "sha256-/HdXzjjvuSW7zjbCNJKm3Fj8gvIwfrDf8mOYev0yuIg=",
|
"narHash": "sha256-069RXUkk4aSYVelezdmYM70TGIJM8KyTBrLW3Vv0XdM=",
|
||||||
"owner": "goauthentik",
|
"owner": "goauthentik",
|
||||||
"repo": "authentik",
|
"repo": "authentik",
|
||||||
"rev": "0c67ea476be6319f1b2a41cb0f5ed128af37b99b",
|
"rev": "b4de7336e903ef51febf42c0ff3b57c484866cdc",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "goauthentik",
|
"owner": "goauthentik",
|
||||||
"ref": "version/2026.5.6",
|
"ref": "version/2026.8.1",
|
||||||
"repo": "authentik",
|
"repo": "authentik",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -47,11 +47,11 @@
|
|||||||
"client-ts-generator-src": {
|
"client-ts-generator-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784638510,
|
"lastModified": 1787926240,
|
||||||
"narHash": "sha256-NfwEWQ/SRjgeUz+F/7uoWAMwk7OqdF2+686krhvJn2M=",
|
"narHash": "sha256-CNazk55jeMBdP/5cf9scshRGCiKALdERny8Oues1zcY=",
|
||||||
"owner": "goauthentik",
|
"owner": "goauthentik",
|
||||||
"repo": "client-ts",
|
"repo": "client-ts",
|
||||||
"rev": "5850af5867bef6fd4291731797d21b704c7f189d",
|
"rev": "26b3e23c928e22e4aa66223b5b996e9e68047f0f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -101,11 +101,11 @@
|
|||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785627969,
|
"lastModified": 1788450739,
|
||||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -142,11 +142,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787377438,
|
"lastModified": 1788642154,
|
||||||
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
|
"narHash": "sha256-sPpQFVaFTDqO/4vvCAhuAhqTgqN/ygu+9eJcs5eB0js=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
|
"rev": "fd0956c99c41ae3c13a73a638f1f7e963aebc4ab",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -270,11 +270,11 @@
|
|||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787728766,
|
"lastModified": 1788938537,
|
||||||
"narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=",
|
"narHash": "sha256-ooFA+3//Y9bpyFjVwTvPegsWngEoQ0pGj+2DJ5cVyJ0=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixos-anywhere",
|
"repo": "nixos-anywhere",
|
||||||
"rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e",
|
"rev": "9df41112343713520ba071674cf8e45e91c25845",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -291,11 +291,11 @@
|
|||||||
"nixos-unstable": "nixos-unstable"
|
"nixos-unstable": "nixos-unstable"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787826771,
|
"lastModified": 1789036642,
|
||||||
"narHash": "sha256-gWkyr3I/cg4SHWGkAoUo24+BQaqoi+S0T2JxDjsA+pw=",
|
"narHash": "sha256-ng2Ou1UrVCP98OAJiq8Mv6aDF/kjQWyaKm4PaxWtAu4=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixos-images",
|
"repo": "nixos-images",
|
||||||
"rev": "f6714acc84ce92df7286c89a571ad1e946057a5b",
|
"rev": "4fcaaefd02b5bc777f99afd620cb1a9aa1fb5b83",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -323,11 +323,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786862985,
|
"lastModified": 1788316716,
|
||||||
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
|
"narHash": "sha256-bc7rSpXIdn9QWGNqfWcPZWOhEVF8NoeAZkWq0XWnf/k=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
|
"rev": "3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -339,11 +339,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785031560,
|
"lastModified": 1788057806,
|
||||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -354,11 +354,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-unstable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787814960,
|
"lastModified": 1789073787,
|
||||||
"narHash": "sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ=",
|
"narHash": "sha256-xfX/toC2QV707s06GbP4II/TxYF0fNQj7s5/LClNDKc=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c27cdad491a991b11ed731760aa2ef8db0cb0410",
|
"rev": "aff8a0b28396750446e5537a96461bc4facdb287",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -370,11 +370,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787753485,
|
"lastModified": 1789009968,
|
||||||
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
"narHash": "sha256-GB16oaxpsrnGNnGIE+0uYBqMRzB9X6FYDUvjvnJAYew=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
"rev": "d58a46e3bc02d91ebe04667f8397752a749c0024",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -507,11 +507,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786629091,
|
"lastModified": 1788914643,
|
||||||
"narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=",
|
"narHash": "sha256-4GuMPW90JSxXWDPUB9M+1m7fYbe3H0apOd86/zBQ2Kw=",
|
||||||
"owner": "Mic92",
|
"owner": "Mic92",
|
||||||
"repo": "sops-nix",
|
"repo": "sops-nix",
|
||||||
"rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f",
|
"rev": "13616fff713a9f94055c66f15687ebdc17a335df",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -584,11 +584,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786615403,
|
"lastModified": 1788001239,
|
||||||
"narHash": "sha256-U++y7nM/6xiEcWI7q4fQoPZjPvRaTwkSqzBOVoEBjUE=",
|
"narHash": "sha256-AELmsXPI546MhbC/ZXC7WRUkCz7d4rqKTHUmliIgPpI=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "pyproject-nix",
|
||||||
"repo": "uv2nix",
|
"repo": "uv2nix",
|
||||||
"rev": "4b59abb2ae1896d2a0e1abfc47fbc9bf985ea730",
|
"rev": "7f9c6b613d2e749e54854b1d60ab6a2192db889e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -111,8 +111,8 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# mars — on-site x86_64 box, single-purpose: Hermes Agent only.
|
# mars — on-site x86_64 box: Hermes Agent, plus the LAN web apps luna
|
||||||
# See hosts/mars/*.
|
# hosts herself (hosts/mars/luna-sites.nix). See hosts/mars/*.
|
||||||
mars = nixpkgs.lib.nixosSystem {
|
mars = nixpkgs.lib.nixosSystem {
|
||||||
inherit system;
|
inherit system;
|
||||||
specialArgs = { inherit inputs; };
|
specialArgs = { inherit inputs; };
|
||||||
@@ -420,7 +420,8 @@
|
|||||||
# After the jump the test driver's backdoor is gone with the old kernel,
|
# After the jump the test driver's backdoor is gone with the old kernel,
|
||||||
# so the installer is driven over a forwarded ssh port instead (the same
|
# so the installer is driven over a forwarded ssh port instead (the same
|
||||||
# approach nixos-images uses in its own kexec test).
|
# approach nixos-images uses in its own kexec test).
|
||||||
checks.${system}.kexec-local =
|
checks.${system} = {
|
||||||
|
kexec-local =
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball;
|
tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball;
|
||||||
@@ -553,6 +554,13 @@
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# VM test for hosts/mars/luna-sites.nix (header of luna-sites-test.nix):
|
||||||
|
# nix build .#checks.x86_64-linux.luna-sites -L
|
||||||
|
luna-sites = import ./hosts/mars/luna-sites-test.nix {
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# `nix develop` — hot-reload loop for dotfiles/quickshell.
|
# `nix develop` — hot-reload loop for dotfiles/quickshell.
|
||||||
#
|
#
|
||||||
# hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`,
|
# hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
{ config, pkgs, ... }:
|
{ config, pkgs, ... }:
|
||||||
|
|
||||||
# mars — on-site x86_64 box, single-purpose: runs Hermes Agent only.
|
# mars — on-site x86_64 box for Hermes Agent (luna), plus the web apps she
|
||||||
# See hermes-agent.nix for what that is and why it moved here from jupiter.
|
# hosts herself. See hermes-agent.nix for what Hermes is and why it moved here
|
||||||
|
# from jupiter, and luna-sites.nix for the app hosting.
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./hardware-configuration.nix
|
./hardware-configuration.nix
|
||||||
./disk-config.nix # disko: OS-disk partitions + filesystems
|
./disk-config.nix # disko: OS-disk partitions + filesystems
|
||||||
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
|
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
|
||||||
./hermes-agent.nix
|
./hermes-agent.nix
|
||||||
|
./luna-sites.nix # luna's LAN web apps: http://mars.sol/<name>/
|
||||||
../../common.nix # shared base: user / ssh / nix / firewall
|
../../common.nix # shared base: user / ssh / nix / firewall
|
||||||
../../services/containers.nix
|
../../services/containers.nix
|
||||||
../../services/vpn/tailscale.nix
|
../../services/vpn/tailscale.nix
|
||||||
|
|||||||
@@ -49,8 +49,8 @@
|
|||||||
# (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a
|
# (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a
|
||||||
# trustedInterface, services/vpn/tailscale.nix). Public route: neptun's
|
# trustedInterface, services/vpn/tailscale.nix). Public route: neptun's
|
||||||
# hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this
|
# hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this
|
||||||
# over the tailnet. mars runs no Caddy of its own (single-purpose box), so
|
# over the tailnet. mars's own Caddy (luna-sites.nix) only serves luna's apps
|
||||||
# there is no LAN vhost — reach the dashboard directly via mars's tailnet
|
# and has no vhost for this — reach the dashboard directly via mars's tailnet
|
||||||
# name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging.
|
# name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging.
|
||||||
#
|
#
|
||||||
# Uses upstream's generic self-hosted OIDC plugin, same Authentik
|
# Uses upstream's generic self-hosted OIDC plugin, same Authentik
|
||||||
@@ -196,13 +196,31 @@ in
|
|||||||
# directly against the real instance during the first version of this
|
# directly against the real instance during the first version of this
|
||||||
# setup). Delete-then-add is idempotent either way and picks up a rotated
|
# setup). Delete-then-add is idempotent either way and picks up a rotated
|
||||||
# token for free.
|
# token for free.
|
||||||
|
#
|
||||||
|
# `tea logins add` is the ONLY step in here that touches the network, and
|
||||||
|
# ordering is what makes it survivable. switch-to-configuration restarts
|
||||||
|
# NetworkManager and starts this unit in the SAME pass: on 2026-09-11 the
|
||||||
|
# two landed in the same second, tea's connect went out over an interface
|
||||||
|
# that was still coming back, and the kernel spent 2m48s on SYN retries
|
||||||
|
# before reporting "connection timed out". That failed this unit, which
|
||||||
|
# podman-hermes-agent Requires=, so a five-second network blip took the
|
||||||
|
# whole container down and returned 4 from the deploy. Hence
|
||||||
|
# network-online.target below, the bounded reachability probe in the script,
|
||||||
|
# and TimeoutStartSec as the backstop — no single blocking call in here may
|
||||||
|
# outlive the deploy that started it.
|
||||||
systemd.services.hermes-agent-prepare-dirs = {
|
systemd.services.hermes-agent-prepare-dirs = {
|
||||||
description = "Create Hermes state dirs + luna's git/tea access before the container starts";
|
description = "Create Hermes state dirs + luna's git/tea access before the container starts";
|
||||||
before = [ "podman-hermes-agent.service" ];
|
before = [ "podman-hermes-agent.service" ];
|
||||||
wantedBy = [ "podman-hermes-agent.service" ];
|
wantedBy = [ "podman-hermes-agent.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
||||||
path = [ pkgs.git pkgs.tea ];
|
path = [ pkgs.git pkgs.tea pkgs.curl pkgs.coreutils ];
|
||||||
serviceConfig.Type = "oneshot";
|
serviceConfig.Type = "oneshot";
|
||||||
|
# Everything here is either local or bounded to ~30s by the probe loop, so
|
||||||
|
# anything past two minutes is a hang, not slowness. Failing at that point
|
||||||
|
# is strictly better than holding the deploy open.
|
||||||
|
serviceConfig.TimeoutStartSec = "120";
|
||||||
script = ''
|
script = ''
|
||||||
mkdir -p ${hermesHome}
|
mkdir -p ${hermesHome}
|
||||||
mkdir -p ${dropboxDir}
|
mkdir -p ${dropboxDir}
|
||||||
@@ -231,9 +249,43 @@ in
|
|||||||
git config --global user.name "luna"
|
git config --global user.name "luna"
|
||||||
git config --global user.email "luna@${giteaHost}"
|
git config --global user.email "luna@${giteaHost}"
|
||||||
|
|
||||||
|
# Probe before touching the login, with a hard per-attempt timeout: a
|
||||||
|
# bare TCP connect to an interface that is still coming up hangs for
|
||||||
|
# ~3 minutes on kernel SYN retries, and tea has no timeout flag of its
|
||||||
|
# own. /api/v1/version is unauthenticated, so this says "is gitea
|
||||||
|
# reachable", never "is the token good" — the token is the add's job.
|
||||||
|
#
|
||||||
|
# Probing FIRST (rather than retrying the add) is what protects the
|
||||||
|
# login that is already there. delete-then-add is not atomic: an add
|
||||||
|
# that fails because the network is down leaves luna with no login at
|
||||||
|
# all, strictly worse than the stale-but-working one we started with.
|
||||||
|
# Unreachable therefore means skip the refresh entirely and warn.
|
||||||
|
gitea_up=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if curl -fsS --max-time 5 -o /dev/null "https://${giteaHost}/api/v1/version"; then
|
||||||
|
gitea_up=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "${giteaHost} unreachable (attempt $attempt/3); retrying in 5s" >&2
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$gitea_up" = 1 ]; then
|
||||||
|
# Reachable but the add still fails == a real problem (revoked or
|
||||||
|
# under-scoped token, gitea rejecting the login), and that stays
|
||||||
|
# fatal: it is a config error, it will not fix itself on the next
|
||||||
|
# boot, and it should be loud.
|
||||||
tea logins delete luna 2>/dev/null || true
|
tea logins delete luna 2>/dev/null || true
|
||||||
GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \
|
GITEA_SERVER_TOKEN="$(cat "$token_file")" timeout 60 tea logins add \
|
||||||
--name luna --url "https://${giteaHost}" --no-version-check
|
--name luna --url "https://${giteaHost}" --no-version-check
|
||||||
|
else
|
||||||
|
# Deliberately not fatal. Every other thing this unit does is local,
|
||||||
|
# and podman-hermes-agent Requires= it — failing here would take
|
||||||
|
# Telegram and the dashboard down over a transient blip. luna keeps
|
||||||
|
# git (the credential helper above needs no network to be written)
|
||||||
|
# and loses only the tea CLI until the next start re-runs this.
|
||||||
|
echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
# Hand everything written above to the container's uid/gid. This does
|
# Hand everything written above to the container's uid/gid. This does
|
||||||
# NOT happen by itself: the image's cont-init only chowns hermesHome's
|
# NOT happen by itself: the image's cont-init only chowns hermesHome's
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Hosting your web apps on mars
|
||||||
|
|
||||||
|
You can run web apps as containers and publish them on the home network at
|
||||||
|
`http://mars.sol/<name>/`, without anyone changing mars's configuration.
|
||||||
|
Everything below takes effect immediately — no restart, no redeploy.
|
||||||
|
|
||||||
|
This file is mounted read-only and is rewritten on every restart. Save what
|
||||||
|
you need from it to your memory.
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
|
- `podman` in your shell does not run containers next to you. It talks,
|
||||||
|
through `$CONTAINER_HOST`, to a separate unprivileged account on mars
|
||||||
|
(`luna-apps`). Containers there keep running when you restart, and come
|
||||||
|
back after mars reboots if they were started with `--restart=always`.
|
||||||
|
- Caddy on mars routes `http://mars.sol/<name>/` to the port you name in
|
||||||
|
`/opt/data/sites/<name>.json`. A service on mars checks that file and
|
||||||
|
writes the outcome to `/opt/data/sites-status.txt`.
|
||||||
|
|
||||||
|
## Publish an app
|
||||||
|
|
||||||
|
1. Put the source under `/opt/data/apps/<name>/` with a `Containerfile` (or
|
||||||
|
`Dockerfile`), and build it. The directory is uploaded, so this works from
|
||||||
|
where you are:
|
||||||
|
|
||||||
|
podman build -t localhost/<name> /opt/data/apps/<name>
|
||||||
|
|
||||||
|
2. Run it. Publish its port on `127.0.0.1` only, using a host port between
|
||||||
|
@portMin@ and @portMax@ that no other app uses (`podman ps` shows the
|
||||||
|
taken ones):
|
||||||
|
|
||||||
|
podman run -d --name <name> --restart=always \
|
||||||
|
-p 127.0.0.1:20001:8080 localhost/<name>
|
||||||
|
|
||||||
|
3. Register it:
|
||||||
|
|
||||||
|
echo '{"port": 20001}' > /opt/data/sites/<name>.json
|
||||||
|
|
||||||
|
4. Check that it took, then fetch it:
|
||||||
|
|
||||||
|
cat /opt/data/sites-status.txt
|
||||||
|
curl -si http://127.0.0.1/<name>/
|
||||||
|
|
||||||
|
It is now at `http://mars.sol/<name>/` for anyone on the home network.
|
||||||
|
|
||||||
|
## Rules the registry enforces
|
||||||
|
|
||||||
|
- `<name>` is lowercase letters, digits and `-`, starts with a letter or
|
||||||
|
digit, at most 32 characters. The file is `/opt/data/sites/<name>.json`.
|
||||||
|
- The file holds exactly one JSON object, and only `port` is read.
|
||||||
|
- `port` is an integer from @portMin@ to @portMax@. Anything else is rejected
|
||||||
|
(that includes everything else already running on mars).
|
||||||
|
- A rejected entry never affects the others. `sites-status.txt` says why.
|
||||||
|
- If `sites-status.txt` starts with `ERROR`, that is a fault on mars's side,
|
||||||
|
not in your entry — tell darman.
|
||||||
|
|
||||||
|
## Writing apps that work under /<name>/
|
||||||
|
|
||||||
|
Caddy strips `/<name>` before the request reaches your app, so the app itself
|
||||||
|
sees `/`, `/style.css`, `/api/items`. The browser, however, is at
|
||||||
|
`http://mars.sol/<name>/`, so every link, asset URL and fetch() in the page must
|
||||||
|
keep that prefix:
|
||||||
|
|
||||||
|
- Prefer relative URLs: `style.css`, `./api/items` — not `/style.css`.
|
||||||
|
- Or set the framework's public base URL to `/<name>/` (e.g. Vite's `base`).
|
||||||
|
Avoid settings that ALSO expect the prefix on incoming requests (Next.js
|
||||||
|
`basePath`); the prefix has already been removed by then.
|
||||||
|
- The original prefix arrives in the `X-Forwarded-Prefix` header.
|
||||||
|
- `http://mars.sol/<name>` redirects to `http://mars.sol/<name>/`.
|
||||||
|
|
||||||
|
## Files and data
|
||||||
|
|
||||||
|
- `-v /opt/data/...:/somewhere` does not work: those paths exist only inside
|
||||||
|
your container, and `luna-apps` cannot see your files. Copy code into the
|
||||||
|
image in the `Containerfile`.
|
||||||
|
- Keep an app's state in a named volume: `-v <name>-data:/data`.
|
||||||
|
- Pulling public images works (`podman pull docker.io/library/nginx`).
|
||||||
|
- Do not copy tokens or anything else from `/opt/data` into an app. The apps
|
||||||
|
cannot read your files; keep it that way.
|
||||||
|
|
||||||
|
## Update, inspect, remove
|
||||||
|
|
||||||
|
- Update: rebuild, `podman rm -f <name>`, run it again on the same port. The
|
||||||
|
JSON file stays as it is.
|
||||||
|
- Inspect: `podman ps -a`, `podman logs <name>`, `cat /opt/data/sites-status.txt`.
|
||||||
|
- Remove: `rm /opt/data/sites/<name>.json`, then `podman rm -f <name>`, and
|
||||||
|
optionally `podman rmi localhost/<name>` and `podman volume rm <name>-data`.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- Home network only: plain `http://`, not reachable from the internet, not on
|
||||||
|
mgaction.town.
|
||||||
|
- There is no login in front of these apps. Anyone on the home network can
|
||||||
|
use them, so do not publish anything that would be a problem to expose there.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# VM test for luna-sites.nix. Run:
|
||||||
|
# nix build .#checks.x86_64-linux.luna-sites -L
|
||||||
|
#
|
||||||
|
# mars has no VM target, and nearly everything luna-sites does only exists at
|
||||||
|
# runtime: a rootless podman socket reached through a proxy from another
|
||||||
|
# container's uid, a path unit, a caddy reload, linger + podman-restart after
|
||||||
|
# a reboot. So this drives it the way luna does — every podman and registry
|
||||||
|
# command runs inside a stand-in for the Hermes container, as uid 986 — and
|
||||||
|
# checks that bad entries are refused without taking good ones down.
|
||||||
|
{ pkgs }:
|
||||||
|
let
|
||||||
|
# `contents` is symlinked into the image root and its closure ships as
|
||||||
|
# layers, so the app image is self-contained under luna-apps. The stand-in
|
||||||
|
# is NOT: hermes-agent mounts the host's /nix/store over the image's own,
|
||||||
|
# which is why the node adds busybox to the VM's store below.
|
||||||
|
busyboxImage = { name, extraCommands ? "", cmd }: pkgs.dockerTools.buildLayeredImage {
|
||||||
|
inherit name;
|
||||||
|
tag = "latest";
|
||||||
|
contents = [ pkgs.busybox ];
|
||||||
|
extraCommands = "mkdir -p tmp && chmod 1777 tmp\n" + extraCommands;
|
||||||
|
config.Cmd = cmd;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Stand-in for docker.io/nousresearch/hermes-agent: a shell and nothing else.
|
||||||
|
# The podman client comes from the store, mounted by luna-sites.nix exactly
|
||||||
|
# as on mars.
|
||||||
|
standin = busyboxImage {
|
||||||
|
name = "hermes-standin";
|
||||||
|
cmd = [ "/bin/sleep" "infinity" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# The "app" luna builds on top of. No network in the VM, so it is loaded
|
||||||
|
# from the store instead of pulled. Runs under luna-apps, which has no
|
||||||
|
# /nix/store mount — hence the closure inside the image.
|
||||||
|
app = busyboxImage {
|
||||||
|
name = "testapp";
|
||||||
|
extraCommands = "mkdir -p www && echo hello > www/index.html";
|
||||||
|
cmd = [ "/bin/httpd" "-f" "-p" "8080" "-h" "/www" ];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "luna-sites";
|
||||||
|
|
||||||
|
nodes.mars = {
|
||||||
|
imports = [ ./luna-sites.nix ];
|
||||||
|
|
||||||
|
virtualisation.memorySize = 2048;
|
||||||
|
virtualisation.diskSize = 4096;
|
||||||
|
environment.systemPackages = [ pkgs.curl ];
|
||||||
|
# The stand-in's /bin symlinks point into /nix/store, and the /nix/store
|
||||||
|
# mount below replaces the image's copy with the VM's, which only holds
|
||||||
|
# the system closure. Without this: "executable file `/bin/sleep` not
|
||||||
|
# found". (The real Hermes image is not nix-built, so mars never hits it.)
|
||||||
|
system.extraDependencies = [ pkgs.busybox ];
|
||||||
|
|
||||||
|
# What hermes-agent.nix provides, minus Hermes itself: same uid/gid, host
|
||||||
|
# networking, hermesHome at /opt/data, /nix/store read-only.
|
||||||
|
users.groups.hermes.gid = 983;
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d /var/lib/hermes 0750 root hermes -"
|
||||||
|
"d /var/lib/hermes/.hermes 0750 986 983 -"
|
||||||
|
];
|
||||||
|
virtualisation.oci-containers.containers.hermes-agent = {
|
||||||
|
image = "hermes-standin:latest";
|
||||||
|
imageFile = standin;
|
||||||
|
extraOptions = [ "--network=host" "--user=986:983" ];
|
||||||
|
volumes = [
|
||||||
|
"/var/lib/hermes/.hermes:/opt/data"
|
||||||
|
"/nix/store:/nix/store:ro"
|
||||||
|
];
|
||||||
|
environment = {
|
||||||
|
HERMES_UID = "986";
|
||||||
|
HERMES_GID = "983";
|
||||||
|
HOME = "/opt/data";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = /* python */ ''
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
status_file = "/var/lib/hermes/.hermes/sites-status.txt"
|
||||||
|
|
||||||
|
def luna(cmd):
|
||||||
|
"""Run cmd the way luna would: inside her container, as uid 986."""
|
||||||
|
return mars.succeed("podman exec hermes-agent sh -c " + shlex.quote(cmd))
|
||||||
|
|
||||||
|
def code(path):
|
||||||
|
return mars.succeed(
|
||||||
|
f"curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1{path}"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
def status_line(entry):
|
||||||
|
lines = mars.succeed(f"cat {status_file}").splitlines()
|
||||||
|
found = [l for l in lines if l.split(" ", 1)[0] == entry]
|
||||||
|
assert len(found) == 1, f"no single status line for {entry}:\n" + "\n".join(lines)
|
||||||
|
return found[0]
|
||||||
|
|
||||||
|
start_all()
|
||||||
|
mars.wait_for_unit("caddy.service")
|
||||||
|
mars.wait_for_unit("podman-hermes-agent.service")
|
||||||
|
|
||||||
|
with subtest("caddy starts with nothing registered"):
|
||||||
|
# The import glob matches no file on a fresh box; caddy must still run.
|
||||||
|
assert code("/") == "404"
|
||||||
|
|
||||||
|
with subtest("luna's podman is luna-apps's rootless podman"):
|
||||||
|
assert luna("id -u").strip() == "986"
|
||||||
|
assert luna("podman info --format '{{.Host.Security.Rootless}}'").strip() == "true"
|
||||||
|
readme = luna("cat /opt/data/sites-README.md")
|
||||||
|
assert "20000" in readme and "@port" not in readme, "README placeholders not substituted"
|
||||||
|
|
||||||
|
with subtest("build and run an app, as luna would"):
|
||||||
|
luna("podman load -i ${app}")
|
||||||
|
luna(
|
||||||
|
"mkdir -p /opt/data/apps/notes && "
|
||||||
|
"printf 'FROM localhost/testapp:latest\\nRUN echo built > /www/built.txt\\n' "
|
||||||
|
"> /opt/data/apps/notes/Containerfile"
|
||||||
|
)
|
||||||
|
luna("podman build -t localhost/notes /opt/data/apps/notes")
|
||||||
|
luna("podman run -d --name notes --restart=always -p 127.0.0.1:20001:8080 localhost/notes")
|
||||||
|
mars.wait_until_succeeds("curl -sf http://127.0.0.1:20001/built.txt")
|
||||||
|
# Container root maps to luna-apps on the host: not root, not uid 986.
|
||||||
|
mars.succeed("pgrep -u luna-apps -f 'httpd -f -p 8080'")
|
||||||
|
|
||||||
|
with subtest("registering routes /notes/ to it"):
|
||||||
|
luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""")
|
||||||
|
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built")
|
||||||
|
# httpd has no /www/notes/, so the 200 above also proves the prefix is stripped.
|
||||||
|
assert " ok " in status_line("notes.json")
|
||||||
|
out = mars.succeed(
|
||||||
|
"curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1/notes"
|
||||||
|
)
|
||||||
|
assert out.startswith("308 ") and out.endswith("/notes/"), out
|
||||||
|
mars.succeed("stat -c %U:%a /var/lib/luna-sites/live/notes.caddy | grep -qx root:644")
|
||||||
|
|
||||||
|
with subtest("bad entries are rejected one by one"):
|
||||||
|
luna("""echo '{"port": 9119}' > /opt/data/sites/dash.json""")
|
||||||
|
luna("echo nope > /opt/data/sites/broken.json")
|
||||||
|
luna(": > /opt/data/sites/empty.json")
|
||||||
|
luna("""echo '{"port": 20002}{"port": 20003}' > /opt/data/sites/two.json""")
|
||||||
|
luna("""echo '{"port": 20003.5}' > /opt/data/sites/frac.json""")
|
||||||
|
luna("""echo '{"port": "20004"}' > /opt/data/sites/str.json""")
|
||||||
|
luna("""echo '{"port": 20005}' > /opt/data/sites/Bad_Name.json""")
|
||||||
|
luna("ln -s /etc/shadow /opt/data/sites/link.json")
|
||||||
|
mars.wait_until_succeeds(f"grep -q '^link.json ' {status_file}")
|
||||||
|
for entry, why in [
|
||||||
|
("dash.json", "port 9119 is outside 20000-20999"),
|
||||||
|
("broken.json", "not valid JSON"),
|
||||||
|
("empty.json", "expected exactly one JSON object"),
|
||||||
|
("two.json", "expected exactly one JSON object"),
|
||||||
|
("frac.json", "port must be an integer"),
|
||||||
|
("str.json", "port must be an integer"),
|
||||||
|
("Bad_Name.json", "name must match"),
|
||||||
|
("link.json", "not a regular file"),
|
||||||
|
]:
|
||||||
|
line = status_line(entry)
|
||||||
|
assert " rejected " in line and why in line, line
|
||||||
|
assert " ok " in status_line("notes.json")
|
||||||
|
# A burst like the one above used to trip systemd's start limit, which
|
||||||
|
# fails the path unit for good and silently ignores every later entry.
|
||||||
|
mars.succeed("systemctl is-active luna-sites.path")
|
||||||
|
assert code("/notes/built.txt") == "200"
|
||||||
|
assert code("/dash/") == "404"
|
||||||
|
mars.succeed("test \"$(ls /var/lib/luna-sites/live)\" = notes.caddy")
|
||||||
|
# The status file is hers, and nothing root-written is left in her tree
|
||||||
|
# (bar the README's mountpoint, which podman itself creates).
|
||||||
|
mars.succeed(f"stat -c %u {status_file} | grep -qx 986")
|
||||||
|
mars.fail("find /var/lib/hermes/.hermes -user root ! -name sites-README.md | grep .")
|
||||||
|
|
||||||
|
with subtest("removing the entry removes the route"):
|
||||||
|
luna("rm /opt/data/sites/notes.json")
|
||||||
|
mars.wait_until_succeeds("test \"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/notes/built.txt)\" = 404")
|
||||||
|
|
||||||
|
with subtest("apps and routes come back after a reboot"):
|
||||||
|
luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""")
|
||||||
|
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt")
|
||||||
|
mars.shutdown()
|
||||||
|
mars.start()
|
||||||
|
mars.wait_for_unit("caddy.service")
|
||||||
|
# Nobody logs in: linger starts luna-apps's manager, podman-restart the container.
|
||||||
|
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built", timeout=180)
|
||||||
|
mars.wait_for_unit("podman-hermes-agent.service")
|
||||||
|
assert luna("podman ps --format '{{.Names}}'").split() == ["notes"]
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
{ config, pkgs, ... }:
|
||||||
|
|
||||||
|
# luna-sites — luna (the Hermes agent, hermes-agent.nix) hosts her own web apps
|
||||||
|
# on mars, LAN-only, at http://mars.sol/<name>/, with no nix edit per app.
|
||||||
|
#
|
||||||
|
# luna, inside hermes-agent (uid 986)
|
||||||
|
# │ podman … → $CONTAINER_HOST = /run/luna-podman/podman.sock (luna-apps:hermes 0660)
|
||||||
|
# ▼ systemd-socket-proxyd, running AS luna-apps
|
||||||
|
# luna-apps's rootless podman (its linger'd user manager) — her app containers
|
||||||
|
#
|
||||||
|
# /opt/data/sites/<name>.json {"port": N} hermesHome/sites, hers to write
|
||||||
|
# ▼ luna-sites.path → luna-sites.service (root): validate, caddy validate, reload
|
||||||
|
# /var/lib/luna-sites/live/<name>.caddy root-owned, imported by caddy
|
||||||
|
# /opt/data/sites-status.txt what was accepted, and why not
|
||||||
|
#
|
||||||
|
# Why a registry of {name, port} instead of letting her drop Caddyfile
|
||||||
|
# snippets: a snippet can proxy to anything on this box (the dashboard on
|
||||||
|
# 9119, the webhook listener on 8644, node-exporter) or file_server anything
|
||||||
|
# caddy can read, and one syntax error keeps caddy from coming up on the next
|
||||||
|
# boot. The generator only ever emits one fixed shape from a validated name
|
||||||
|
# and a port inside portMin..portMax, so none of that is expressible.
|
||||||
|
#
|
||||||
|
# Why paths, not <name>.mars.sol: mars has no fixed DHCP lease, and a wildcard
|
||||||
|
# needs one. `address=/…/` takes an IP, and pihole-FTL's dnsmasq skips
|
||||||
|
# wildcard --cname entries outside authoritative zones (cache_reload():
|
||||||
|
# `if (a->alias[1] != '*' …)`). Moving to subdomains later only changes the
|
||||||
|
# fragment the generator writes; the registry format stays.
|
||||||
|
#
|
||||||
|
# Why a podman socket instead of ssh: what she needs is long-running processes
|
||||||
|
# OUTSIDE her own container (anything started inside it dies with the
|
||||||
|
# container, and sits next to her Telegram/gitea tokens). The socket gives
|
||||||
|
# exactly that and no host shell. It is not a strong boundary on its own —
|
||||||
|
# rootless podman socket access is code execution as luna-apps, which can read
|
||||||
|
# whatever that user can — but luna-apps owns nothing and cannot enter
|
||||||
|
# /var/lib/hermes (0750 root:hermes), so the apps cannot reach her tokens.
|
||||||
|
#
|
||||||
|
# She learns all this from a read-only README mounted at
|
||||||
|
# /opt/data/sites-README.md (luna-sites-README.md). She self-manages her
|
||||||
|
# memories, so nothing in this file reaches her otherwise — see the dropped
|
||||||
|
# repo clone in hermes-agent.nix's header for what happens when it doesn't.
|
||||||
|
#
|
||||||
|
# VM test: nix build .#checks.x86_64-linux.luna-sites -L (luna-sites-test.nix)
|
||||||
|
let
|
||||||
|
user = "luna-apps";
|
||||||
|
# Pinned so the user manager's socket path below is known at build time.
|
||||||
|
uid = 1001;
|
||||||
|
userSocket = "/run/user/${toString uid}/podman/podman.sock";
|
||||||
|
|
||||||
|
hermes = config.virtualisation.oci-containers.containers.hermes-agent;
|
||||||
|
hermesUid = hermes.environment.HERMES_UID;
|
||||||
|
hermesGid = hermes.environment.HERMES_GID;
|
||||||
|
# hermes-agent.nix's hermesHome — the container sees it as /opt/data.
|
||||||
|
hermesHome = "/var/lib/hermes/.hermes";
|
||||||
|
sitesDir = "${hermesHome}/sites";
|
||||||
|
statusFile = "${hermesHome}/sites-status.txt";
|
||||||
|
|
||||||
|
stateDir = "/var/lib/luna-sites";
|
||||||
|
liveDir = "${stateDir}/live";
|
||||||
|
socketDir = "/run/luna-podman";
|
||||||
|
|
||||||
|
portMin = 20000;
|
||||||
|
portMax = 20999;
|
||||||
|
|
||||||
|
readme = pkgs.replaceVars ./luna-sites-README.md {
|
||||||
|
portMin = toString portMin;
|
||||||
|
portMax = toString portMax;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
../../services/containers.nix
|
||||||
|
../../services/network/caddy.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
# ---- luna-apps: the account her apps run as ----
|
||||||
|
users.users.${user} = {
|
||||||
|
isNormalUser = true;
|
||||||
|
inherit uid;
|
||||||
|
description = "luna's hosted web apps (rootless podman)";
|
||||||
|
# Nothing ever logs in as this user. Only its systemd user manager runs,
|
||||||
|
# kept up without a session by linger, which is what brings the podman
|
||||||
|
# socket and podman-restart back after a reboot.
|
||||||
|
linger = true;
|
||||||
|
autoSubUidGidRange = true; # rootless podman's user namespace
|
||||||
|
hashedPassword = "!";
|
||||||
|
shell = "${pkgs.shadow}/bin/nologin";
|
||||||
|
};
|
||||||
|
|
||||||
|
# `--restart=always` containers only come back after a reboot through this
|
||||||
|
# unit — rootless podman has no daemon to remember them. The podman module
|
||||||
|
# already enables podman.socket for every user's manager; this one is
|
||||||
|
# scoped to luna-apps.
|
||||||
|
systemd.user.services.podman-restart = {
|
||||||
|
wantedBy = [ "default.target" ];
|
||||||
|
unitConfig.ConditionUser = user;
|
||||||
|
};
|
||||||
|
|
||||||
|
# ---- the socket luna's container talks to ----
|
||||||
|
# luna-apps's own socket lives under /run/user/1001 (0700), which the
|
||||||
|
# container's uid cannot enter. This re-exposes it to group hermes, and the
|
||||||
|
# proxy behind it runs as luna-apps, so it holds no access beyond the socket
|
||||||
|
# it forwards to.
|
||||||
|
systemd.sockets.luna-apps-podman = {
|
||||||
|
wantedBy = [ "sockets.target" ];
|
||||||
|
listenStreams = [ "${socketDir}/podman.sock" ];
|
||||||
|
socketConfig = {
|
||||||
|
SocketUser = user;
|
||||||
|
SocketGroup = "hermes";
|
||||||
|
SocketMode = "0660";
|
||||||
|
DirectoryMode = "0755";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
systemd.services.luna-apps-podman = {
|
||||||
|
description = "Forward luna's podman socket to luna-apps's rootless podman";
|
||||||
|
requires = [ "user@${toString uid}.service" ];
|
||||||
|
after = [ "user@${toString uid}.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
User = user;
|
||||||
|
ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd ${userSocket}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ---- luna's side ----
|
||||||
|
# Merges into hermes-agent.nix's container definition.
|
||||||
|
virtualisation.oci-containers.containers.hermes-agent = {
|
||||||
|
volumes = [
|
||||||
|
# The directory, not the socket file: the socket is created by systemd
|
||||||
|
# at boot, and a file bind mount would pin whatever inode was there when
|
||||||
|
# the container started. Read-only still permits connect().
|
||||||
|
"${socketDir}:${socketDir}:ro"
|
||||||
|
"${config.virtualisation.podman.package}/bin/podman:/usr/local/bin/podman:ro"
|
||||||
|
"${readme}:/opt/data/sites-README.md:ro"
|
||||||
|
];
|
||||||
|
# Every podman command in there goes to luna-apps, never to the rootful
|
||||||
|
# podman the container itself runs under.
|
||||||
|
environment.CONTAINER_HOST = "unix://${socketDir}/podman.sock";
|
||||||
|
};
|
||||||
|
systemd.services.podman-hermes-agent = {
|
||||||
|
wants = [ "luna-apps-podman.socket" ];
|
||||||
|
after = [ "luna-apps-podman.socket" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# ---- caddy ----
|
||||||
|
# `:80` rather than http://mars.sol, so it answers whatever name the LAN
|
||||||
|
# used to get here (mars, mars.sol, the IP). Until the generator's first run
|
||||||
|
# the import glob matches nothing, which caddy only warns about.
|
||||||
|
services.caddy.virtualHosts.":80".extraConfig = ''
|
||||||
|
import ${liveDir}/*.caddy
|
||||||
|
handle {
|
||||||
|
respond "No app registered here. luna's apps live at /<name>/." 404
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# ---- registry → caddy ----
|
||||||
|
# Fires on create/delete/rename/close-after-write of entries in sitesDir.
|
||||||
|
# While sitesDir does not exist yet, systemd watches its parents instead.
|
||||||
|
systemd.paths.luna-sites = {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
pathConfig.PathChanged = sitesDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.luna-sites = {
|
||||||
|
description = "Turn luna's site registry into caddy routes";
|
||||||
|
# Also runs once at boot, for edits made while nothing was watching.
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
# After caddy, so the reload below never races caddy's own start. Nothing
|
||||||
|
# orders caddy after THIS unit, which is what keeps the blocking
|
||||||
|
# `systemctl reload caddy` from waiting on its own start job.
|
||||||
|
after = [ "caddy.service" ];
|
||||||
|
# No start rate limit. The default (5 starts in 10s) is hit by nothing
|
||||||
|
# more than a handful of quick writes — the VM test does exactly that —
|
||||||
|
# and when it is, systemd also fails luna-sites.path for good
|
||||||
|
# (unit-start-limit-hit): every later registration is silently ignored
|
||||||
|
# until someone runs reset-failed. Bursts are absorbed by the debounce at
|
||||||
|
# the top of the script instead.
|
||||||
|
startLimitIntervalSec = 0;
|
||||||
|
path = [ pkgs.jq pkgs.util-linux pkgs.diffutils config.services.caddy.package ];
|
||||||
|
# caddy validate wants somewhere to write its data/config dirs.
|
||||||
|
environment = {
|
||||||
|
HOME = "/tmp";
|
||||||
|
XDG_DATA_HOME = "/tmp";
|
||||||
|
XDG_CONFIG_HOME = "/tmp";
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
StateDirectory = "luna-sites";
|
||||||
|
StateDirectoryMode = "0755"; # caddy (User=caddy) reads live/
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
# "-": hermesHome does not exist on a box Hermes has never started on;
|
||||||
|
# the script checks for that itself.
|
||||||
|
ReadWritePaths = [ "-${hermesHome}" ];
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Everything that touches luna's tree runs as the container's uid, never
|
||||||
|
# as root: she controls every path under it, including swapping one for
|
||||||
|
# a symlink into /etc between a check here and its use.
|
||||||
|
as_luna() { setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups -- "$@"; }
|
||||||
|
|
||||||
|
if [ ! -d ${hermesHome} ]; then
|
||||||
|
echo "${hermesHome} does not exist yet; nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# mkdir -p leaves an existing dir untouched, so this does not re-fire
|
||||||
|
# the path unit on every run.
|
||||||
|
as_luna mkdir -p ${sitesDir}
|
||||||
|
rm -rf ${stateDir}/stage.*
|
||||||
|
|
||||||
|
report=$(mktemp)
|
||||||
|
|
||||||
|
reject() { printf '%-24s rejected %s\n' "$f" "$1" >> "$report"; }
|
||||||
|
|
||||||
|
# Written as her uid next to the target, then renamed into place, so
|
||||||
|
# she never reads a half-written file.
|
||||||
|
publish_report() {
|
||||||
|
local tmp
|
||||||
|
tmp=$(as_luna mktemp ${hermesHome}/.sites-status.XXXXXX)
|
||||||
|
{
|
||||||
|
printf '# luna-sites, %s. How this works: /opt/data/sites-README.md\n' "$(date -Is)"
|
||||||
|
if [ -n "''${1:-}" ]; then printf '%s\n' "$1"; fi
|
||||||
|
if [ -s "$report" ]; then cat "$report"; else echo "(no sites registered)"; fi
|
||||||
|
} | as_luna tee "$tmp" >/dev/null
|
||||||
|
as_luna mv -f "$tmp" ${statusFile}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries() {
|
||||||
|
as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f %s %T@\n' | sort
|
||||||
|
}
|
||||||
|
|
||||||
|
generate() {
|
||||||
|
local stage entry type f name verdict port
|
||||||
|
: > "$report"
|
||||||
|
stage=$(mktemp -d ${stateDir}/stage.XXXXXX)
|
||||||
|
chmod 0755 "$stage"
|
||||||
|
|
||||||
|
while IFS= read -r -d "" entry; do
|
||||||
|
type=''${entry%% *}
|
||||||
|
f=''${entry#* }
|
||||||
|
name=''${f%.json}
|
||||||
|
|
||||||
|
if ! [[ $name =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
|
||||||
|
reject "name must match [a-z0-9][a-z0-9-]{0,31}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Refused rather than followed. The read below happens as her uid
|
||||||
|
# either way, so this is about clear feedback, not safety.
|
||||||
|
if [ "$type" != f ]; then
|
||||||
|
reject "not a regular file"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
verdict=$(as_luna head -c 4096 -- ${sitesDir}/"$f" | jq -rs \
|
||||||
|
--argjson min ${toString portMin} --argjson max ${toString portMax} '
|
||||||
|
if length != 1 or (.[0] | type) != "object" then "expected exactly one JSON object"
|
||||||
|
else .[0].port as $p
|
||||||
|
| if ($p | type) != "number" or $p != ($p | floor) then "port must be an integer"
|
||||||
|
elif $p < $min or $p > $max then "port \($p) is outside \($min)-\($max)"
|
||||||
|
else "ok \($p | floor)" end
|
||||||
|
end
|
||||||
|
' 2>/dev/null) || verdict="not valid JSON"
|
||||||
|
|
||||||
|
case $verdict in
|
||||||
|
"ok "*) port=''${verdict#ok } ;;
|
||||||
|
*) reject "$verdict"; continue ;;
|
||||||
|
esac
|
||||||
|
if ! [[ $port =~ ^[0-9]+$ ]]; then
|
||||||
|
reject "port must be an integer"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The only shape that is ever generated. Stripping the prefix means
|
||||||
|
# the app sees `/`; X-Forwarded-Prefix tells it where it really is.
|
||||||
|
{
|
||||||
|
printf '# %s\n' "${sitesDir}/$f"
|
||||||
|
printf 'redir /%s /%s/ 308\n' "$name" "$name"
|
||||||
|
printf 'handle_path /%s/* {\n' "$name"
|
||||||
|
printf '\treverse_proxy 127.0.0.1:%s {\n' "$port"
|
||||||
|
printf '\t\theader_up X-Forwarded-Prefix /%s\n' "$name"
|
||||||
|
printf '\t}\n}\n'
|
||||||
|
} > "$stage/$name.caddy"
|
||||||
|
printf '%-24s ok http://mars.sol/%s/ -> 127.0.0.1:%s\n' "$f" "$name" "$port" >> "$report"
|
||||||
|
done < <(as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f\0' | sort -z)
|
||||||
|
|
||||||
|
# Nothing she controls reaches these files except a validated name and
|
||||||
|
# an integer, so a failure here is a bug in this unit, not her entry.
|
||||||
|
printf ':80 {\n\timport %s/*.caddy\n}\n' "$stage" > "$stage.Caddyfile"
|
||||||
|
if ! caddy validate --adapter caddyfile --config "$stage.Caddyfile"; then
|
||||||
|
rm -rf "$stage" "$stage.Caddyfile"
|
||||||
|
publish_report "ERROR: the generated routes failed caddy validate, so nothing changed. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$stage.Caddyfile"
|
||||||
|
|
||||||
|
if [ -d ${liveDir} ] && diff -r ${liveDir} "$stage" >/dev/null; then
|
||||||
|
rm -rf "$stage"
|
||||||
|
else
|
||||||
|
rm -rf ${stateDir}/previous
|
||||||
|
if [ -d ${liveDir} ]; then mv ${liveDir} ${stateDir}/previous; fi
|
||||||
|
mv "$stage" ${liveDir}
|
||||||
|
# caddy's reload is all-or-nothing: on failure it keeps serving the
|
||||||
|
# old routes, so put the old files back to match what is live.
|
||||||
|
if systemctl is-active --quiet caddy.service && ! systemctl reload caddy.service; then
|
||||||
|
rm -rf ${liveDir}
|
||||||
|
if [ -d ${stateDir}/previous ]; then mv ${stateDir}/previous ${liveDir}; fi
|
||||||
|
publish_report "ERROR: caddy refused the new routes, so the previous ones are still live. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf ${stateDir}/previous
|
||||||
|
fi
|
||||||
|
publish_report
|
||||||
|
}
|
||||||
|
|
||||||
|
# Debounce: writes usually come in bursts (several files, or an editor's
|
||||||
|
# write-then-rename), and every trigger that lands while this oneshot
|
||||||
|
# is still activating merges into this same start job instead of
|
||||||
|
# queuing another. One second collapses a burst into one run.
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# That merging also means an entry written mid-run would otherwise wait
|
||||||
|
# for the next unrelated change. Compare the registry before and after,
|
||||||
|
# and go again. Bounded, so a writer in a loop cannot pin the unit.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
before=$(entries)
|
||||||
|
generate
|
||||||
|
if [ "$before" = "$(entries)" ]; then exit 0; fi
|
||||||
|
echo "registry changed during run $attempt; regenerating"
|
||||||
|
done
|
||||||
|
echo "registry still changing after 5 runs; leaving the rest to the next trigger" >&2
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,6 +43,51 @@ in
|
|||||||
# https://nix.dev/permalink/stub-ld ----
|
# https://nix.dev/permalink/stub-ld ----
|
||||||
programs.nix-ld.enable = true;
|
programs.nix-ld.enable = true;
|
||||||
|
|
||||||
|
# The default set above is deliberately minimal and carries no X11,
|
||||||
|
# freetype, wayland or xkbcommon, so a prebuilt *graphical* binary dies
|
||||||
|
# before it draws anything. JetBrains IDEs installed through Toolbox are the
|
||||||
|
# case that surfaced this: their bundled JBR aborts with `libX11.so.6:
|
||||||
|
# cannot open shared object file` unless the Toolbox GUI — itself an FHS
|
||||||
|
# wrapper — is what launches them, which makes them unusable from a terminal
|
||||||
|
# or from a per-repo devShell. These are the libraries `ldd` reports missing
|
||||||
|
# across a JBR's own .so files, plus the three it resolves by dlopen rather
|
||||||
|
# than DT_NEEDED: fontconfig for font discovery, libGL, and libsecret for
|
||||||
|
# the credential store. Definitions merge, so this adds to the module's base
|
||||||
|
# list rather than replacing it (zlib is already there).
|
||||||
|
programs.nix-ld.libraries = with pkgs; [
|
||||||
|
freetype
|
||||||
|
fontconfig
|
||||||
|
libGL
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
libsecret
|
||||||
|
libx11
|
||||||
|
libxext
|
||||||
|
libxi
|
||||||
|
libxrender
|
||||||
|
libxtst
|
||||||
|
libxcursor
|
||||||
|
libxrandr
|
||||||
|
libxinerama
|
||||||
|
libxcb
|
||||||
|
|
||||||
|
# CLion Nova's C++ backend (the clion-radler plugin) is a .NET 10
|
||||||
|
# application bundling its own runtime, and .NET refuses to start
|
||||||
|
# without ICU: libSystem.Globalization.Native.so dlopens libicuuc.so
|
||||||
|
# and libicui18n.so, and failing that the IDE reports "Couldn't find a
|
||||||
|
# valid ICU package installed on the system" and comes up degraded.
|
||||||
|
icu
|
||||||
|
];
|
||||||
|
|
||||||
|
# ---- envfs: serves /bin and /usr/bin from the calling process's PATH ----
|
||||||
|
# NixOS ships only /bin/sh, but plenty of third-party tooling writes scripts
|
||||||
|
# with a hardcoded interpreter. JetBrains Toolbox is the standing example:
|
||||||
|
# it generates ~/.local/share/JetBrains/Toolbox/scripts/{clion,rider,...}
|
||||||
|
# with `#!/bin/bash`, so every one of those shims fails with `bad
|
||||||
|
# interpreter` in any shell. envfs resolves such shebangs against PATH,
|
||||||
|
# which fixes them all at once instead of per-IDE wrappers.
|
||||||
|
services.envfs.enable = true;
|
||||||
|
|
||||||
# ---- home-manager (user-level config for darman) ----
|
# ---- home-manager (user-level config for darman) ----
|
||||||
# Base settings (useGlobalPkgs/useUserPackages/backupFileExtension) and the
|
# Base settings (useGlobalPkgs/useUserPackages/backupFileExtension) and the
|
||||||
# shared zsh baseline now live in common.nix + home/common.nix, applied to
|
# shared zsh baseline now live in common.nix + home/common.nix, applied to
|
||||||
@@ -96,7 +141,15 @@ in
|
|||||||
# in VRAM alone, so ollama offloads the inactive experts to CPU RAM.
|
# in VRAM alone, so ollama offloads the inactive experts to CPU RAM.
|
||||||
# Sparse activation makes that far less painful than it'd be for a dense
|
# Sparse activation makes that far less painful than it'd be for a dense
|
||||||
# model this size, but still expect it to run slower than the two above.
|
# model this size, but still expect it to run slower than the two above.
|
||||||
loadModels = [ "gemma4:12b" "qwen3.6:35b-a3b" ];
|
# VladimirGav/qwen3.8-27B-14GB-IQ4: dense 27B at IQ4, ~14GB of weights —
|
||||||
|
# nominally fits the 6800 XT's 16G, but that leaves only ~2G for the KV
|
||||||
|
# cache and the compositor, so expect partial CPU offload as context grows
|
||||||
|
# (OLLAMA_CONTEXT_LENGTH below applies to every model on this server).
|
||||||
|
loadModels = [
|
||||||
|
"gemma4:12b"
|
||||||
|
"qwen3.6:35b-a3b"
|
||||||
|
"VladimirGav/qwen3.8-27B-14GB-IQ4"
|
||||||
|
];
|
||||||
# Ollama truncates context far below the model's real window unless
|
# Ollama truncates context far below the model's real window unless
|
||||||
# told otherwise (the OpenAI-compat /v1 route it's reached through has
|
# told otherwise (the OpenAI-compat /v1 route it's reached through has
|
||||||
# no way to set this per-request). 131072 chosen as the practical
|
# no way to set this per-request). 131072 chosen as the practical
|
||||||
|
|||||||
+55
-1
@@ -1,6 +1,53 @@
|
|||||||
{ pkgs, unstable, inputs, ... }:
|
{ pkgs, unstable, inputs, ... }:
|
||||||
let
|
let
|
||||||
tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; };
|
tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; };
|
||||||
|
|
||||||
|
# SUDO_ASKPASS helper: renders sudo's password prompt in the quickshell
|
||||||
|
# shell (HyprChrome/Widgets/Askpass) instead of on the terminal.
|
||||||
|
#
|
||||||
|
# sudo does NOT speak polkit — it is setuid + PAM reading the tty, and no
|
||||||
|
# sudoers option bridges the two — so this is the askpass mechanism, a
|
||||||
|
# separate path that happens to reuse the polkit dialog's look. `run0` is the
|
||||||
|
# polkit-native alternative if you want the agent itself.
|
||||||
|
#
|
||||||
|
# A package rather than a file in dotfiles/quickshell because SUDO_ASKPASS
|
||||||
|
# must point at something EXECUTABLE, and xdg.configFile copies keep their
|
||||||
|
# store mode — which is why open_launcher.sh has to be invoked as
|
||||||
|
# `bash <path>` rather than run directly.
|
||||||
|
#
|
||||||
|
# The secret comes back over a 0600 fifo, never in argv or the environment,
|
||||||
|
# so it is not visible in /proc to anything. Cancelling closes the fifo
|
||||||
|
# without writing: `cat` reads nothing, this exits non-zero, and sudo aborts
|
||||||
|
# instead of burning a retry on an empty password.
|
||||||
|
qs-askpass = pkgs.writeShellApplication {
|
||||||
|
name = "qs-askpass";
|
||||||
|
runtimeInputs = [ pkgs.quickshell pkgs.coreutils ];
|
||||||
|
text = ''
|
||||||
|
runtime="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
fifo="$(mktemp -u "$runtime/qs-askpass.XXXXXXXX")"
|
||||||
|
mkfifo -m 600 "$fifo"
|
||||||
|
trap 'rm -f "$fifo"' EXIT
|
||||||
|
|
||||||
|
# Returns immediately; the dialog is asynchronous and we block on the
|
||||||
|
# fifo, not on the IPC call.
|
||||||
|
if ! qs ipc call askpass prompt "''${1:-Password:}" "$fifo" >/dev/null 2>&1; then
|
||||||
|
echo "qs-askpass: quickshell is not running or has no askpass handler" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bounded, so a prompt nobody answers fails instead of wedging sudo for
|
||||||
|
# good. On timeout take the dialog down too, or it would sit there with
|
||||||
|
# nothing listening.
|
||||||
|
if ! secret="$(timeout 120 cat "$fifo")"; then
|
||||||
|
qs ipc call askpass cancel >/dev/null 2>&1 || true
|
||||||
|
echo "qs-askpass: timed out waiting for the prompt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$secret" ] || exit 1
|
||||||
|
printf '%s\n' "$secret"
|
||||||
|
'';
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# home.stateVersion, programs.home-manager.enable, programs.zsh.enable all
|
# home.stateVersion, programs.home-manager.enable, programs.zsh.enable all
|
||||||
@@ -34,6 +81,12 @@ in
|
|||||||
# it instead of the root /var/run/docker.sock.
|
# it instead of the root /var/run/docker.sock.
|
||||||
home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock";
|
home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock";
|
||||||
|
|
||||||
|
# Only sets WHICH helper sudo uses; it still only calls it when asked with
|
||||||
|
# `sudo -A` (or when there is no tty at all). Plain `sudo` keeps prompting on
|
||||||
|
# the terminal, deliberately: aliasing it wholesale would break every sudo in
|
||||||
|
# a TTY or over ssh, where there is no shell to draw the dialog.
|
||||||
|
home.sessionVariables.SUDO_ASKPASS = "${qs-askpass}/bin/qs-askpass";
|
||||||
|
|
||||||
xdg.userDirs = {
|
xdg.userDirs = {
|
||||||
enable = true;
|
enable = true;
|
||||||
};
|
};
|
||||||
@@ -46,13 +99,14 @@ in
|
|||||||
(pkgs.writeTextDir "share/mime/packages/application-x-ms-sln.xml"
|
(pkgs.writeTextDir "share/mime/packages/application-x-ms-sln.xml"
|
||||||
(builtins.readFile ../../dotfiles/mime/application-x-ms-sln.xml))
|
(builtins.readFile ../../dotfiles/mime/application-x-ms-sln.xml))
|
||||||
unstable.claude-code
|
unstable.claude-code
|
||||||
|
unstable.codex
|
||||||
pkgs.opencode
|
pkgs.opencode
|
||||||
pkgs.quickshell
|
pkgs.quickshell
|
||||||
|
qs-askpass
|
||||||
pkgs.github-cli
|
pkgs.github-cli
|
||||||
pkgs.tea
|
pkgs.tea
|
||||||
pkgs.docker-compose
|
pkgs.docker-compose
|
||||||
pkgs.hyprcursor
|
pkgs.hyprcursor
|
||||||
pkgs.bibata-cursors
|
|
||||||
pkgs.papirus-icon-theme
|
pkgs.papirus-icon-theme
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,19 @@
|
|||||||
let
|
let
|
||||||
lua = lib.generators.mkLuaInline;
|
lua = lib.generators.mkLuaInline;
|
||||||
|
|
||||||
|
# Cursor theme+size live in home.pointerCursor (theme.nix) so the name is
|
||||||
|
# in one place; hyprland.lua is what actually gets them into the graphical
|
||||||
|
# session's environment (hm-session-vars.sh is only sourced by login shells).
|
||||||
|
cursorName = config.home.pointerCursor.name;
|
||||||
|
cursorSize = toString config.home.pointerCursor.size;
|
||||||
|
|
||||||
# Wallpaper images aren't checked into this repo (binary blobs) — pulled
|
# Wallpaper images aren't checked into this repo (binary blobs) — pulled
|
||||||
# from the existing Wallhaven library on /mnt/hdd_01 instead. Picked once
|
# from the existing Wallhaven library on /mnt/hdd_01 instead. Picked once
|
||||||
# here rather than at runtime, since hyprpaper has no built-in "random"
|
# here rather than at runtime, since hyprpaper has no built-in "random"
|
||||||
# mode; re-pick and rebuild (or swap in real per-monitor selection) when
|
# mode; re-pick and rebuild (or swap in real per-monitor selection) when
|
||||||
# this stops being a placeholder.
|
# this stops being a placeholder.
|
||||||
wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png";
|
# wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png";
|
||||||
|
wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-mlwz78.png";
|
||||||
|
|
||||||
# Dispatchers → the new hl.dsp.* API (signatures verified against hyprland
|
# Dispatchers → the new hl.dsp.* API (signatures verified against hyprland
|
||||||
# 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp).
|
# 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp).
|
||||||
@@ -94,7 +101,7 @@ in
|
|||||||
settings = {
|
settings = {
|
||||||
# ---- colours (from colors.conf) ----
|
# ---- colours (from colors.conf) ----
|
||||||
fg_color = { _var = "rgba(eeeeeeff)"; };
|
fg_color = { _var = "rgba(eeeeeeff)"; };
|
||||||
fg_accent = { _var = "rgba(ffd063ff)"; };
|
fg_accent = { _var = "rgba(e8722aff)"; };
|
||||||
fg_accent_alt = { _var = "rgba(ff9d42ff)"; };
|
fg_accent_alt = { _var = "rgba(ff9d42ff)"; };
|
||||||
bg_color = { _var = "rgba(0f1012ff)"; };
|
bg_color = { _var = "rgba(0f1012ff)"; };
|
||||||
bg_accent = { _var = "rgba(963c38ff)"; };
|
bg_accent = { _var = "rgba(963c38ff)"; };
|
||||||
@@ -117,7 +124,7 @@ in
|
|||||||
debug.disable_logs = false;
|
debug.disable_logs = false;
|
||||||
|
|
||||||
general = {
|
general = {
|
||||||
border_size = 0;
|
border_size = 2;
|
||||||
col = {
|
col = {
|
||||||
inactive_border = lua "bg_accent";
|
inactive_border = lua "bg_accent";
|
||||||
active_border = {
|
active_border = {
|
||||||
@@ -182,10 +189,10 @@ in
|
|||||||
|
|
||||||
# ---- environment (environment.conf) ----
|
# ---- environment (environment.conf) ----
|
||||||
env = [
|
env = [
|
||||||
{ _args = [ "HYPRCURSOR_THEME" "Bibata-Modern-Classic" ]; }
|
{ _args = [ "HYPRCURSOR_THEME" cursorName ]; }
|
||||||
{ _args = [ "HYPRCURSOR_SIZE" "24" ]; }
|
{ _args = [ "HYPRCURSOR_SIZE" cursorSize ]; }
|
||||||
{ _args = [ "XCURSOR_THEME" "Bibata-Modern-Classic" ]; }
|
{ _args = [ "XCURSOR_THEME" cursorName ]; }
|
||||||
{ _args = [ "XCURSOR_SIZE" "24" ]; }
|
{ _args = [ "XCURSOR_SIZE" cursorSize ]; }
|
||||||
{ _args = [ "GDK_BACKEND" "wayland,x11" ]; }
|
{ _args = [ "GDK_BACKEND" "wayland,x11" ]; }
|
||||||
{ _args = [ "SDL_VIDEODRIVER" "wayland" ]; }
|
{ _args = [ "SDL_VIDEODRIVER" "wayland" ]; }
|
||||||
{ _args = [ "CLUTTER_BACKEND" "wayland" ]; }
|
{ _args = [ "CLUTTER_BACKEND" "wayland" ]; }
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The cursor theme. XCURSOR_THEME alone is not enough for Steam: the client
|
||||||
|
# UI (steamwebhelper) runs inside a pressure-vessel container that rebuilds
|
||||||
|
# /etc, so the /etc/profiles/per-user/darman/share/icons entry of
|
||||||
|
# XCURSOR_PATH does not exist in there and libXcursor finds no theme by
|
||||||
|
# that name — it falls back to the built-in core X11 cursor. $HOME and
|
||||||
|
# /nix are bind-mounted into the container, so the ~/.icons symlink that
|
||||||
|
# `dotIcons` (on by default) drops does resolve. Same class of problem as
|
||||||
|
# the ~/.themes/~/.icons flatpak workaround above.
|
||||||
|
home.pointerCursor = {
|
||||||
|
name = "Bibata-Modern-Classic";
|
||||||
|
package = pkgs.bibata-cursors;
|
||||||
|
size = 24;
|
||||||
|
gtk.enable = true;
|
||||||
|
hyprcursor.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
# Flatpak apps are sandboxed and can't see XDG_DATA_DIRS/nix-store theme
|
# Flatpak apps are sandboxed and can't see XDG_DATA_DIRS/nix-store theme
|
||||||
# paths, so the portal-reported GTK theme / icon theme names resolve to
|
# paths, so the portal-reported GTK theme / icon theme names resolve to
|
||||||
# nothing inside the sandbox and they fall back to Adwaita. Flatpak
|
# nothing inside the sandbox and they fall back to Adwaita. Flatpak
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
# at runtime with whatever's pulled (see loadModels in
|
# at runtime with whatever's pulled (see loadModels in
|
||||||
# hosts/terra/configuration.nix) — kept roughly in sync anyway
|
# hosts/terra/configuration.nix) — kept roughly in sync anyway
|
||||||
# so the UI has sane names before the first fetch completes.
|
# so the UI has sane names before the first fetch completes.
|
||||||
default = [ "gemma4:12b" "qwen3.6:35b-a3b" ];
|
default = [ "gemma4:12b" "qwen3.6:35b-a3b" "VladimirGav/qwen3.8-27B-14GB-IQ4:latest" ];
|
||||||
fetch = true; # pull the model list from ollama at startup
|
fetch = true; # pull the model list from ollama at startup
|
||||||
};
|
};
|
||||||
titleConvo = true;
|
titleConvo = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user