feat(nixpak): sandbox six desktop apps (#497)

* feat(nixpak): sandbox six desktop apps

* chore(nixpak): trim OBS plugin set

* fix(nixpak): enable OBS on aarch64

* fix(nixpak): let imv read the full home directory

* fix(home): drop redundant freerdp package

* fix(nixpak): grant OBS access to V4L2 camera devices

* fix(nixpak): expose V4L2 sysfs to OBS

* fix(nixpak): expose NVIDIA encode library to OBS

* docs(nixpak): record OBS interactive validation

* docs(nixpak): record OBS RTMP validation

* fix(nixpak): run MuseScore via XWayland and PulseAudio

* docs(nixpak): explain app sandboxing rationale

* fix(nixpak): route MuseScore ALSA through PipeWire
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-01 01:24:47 -05:00
1 parent 5e5ceae108
commit c277c81cd6
25 files changed
+884 -70

No files matched your search

+50 -16
View File
@@ -3,13 +3,23 @@
Deployed and interactively validated on the Linux desktop host (2026-10-31). Policy and limits:
[SECURITY.md](../SECURITY.md).
## Why these apps are sandboxed
The packaging scope is driven by blast radius and input exposure, not by treating the applications
as malicious. These apps parse complex local or remote content, load third-party plugins (OBS), or
hold powerful network/session privileges (Remmina); a parser, plugin or session compromise would
otherwise inherit the user's home, session D-Bus, devices and network. Each wrapper grants only the
ports, devices, sockets, writable directories and desktop portals needed by that app's documented
workflow.
## Shared boundaries
- Cleared environment; filtered session D-Bus; private `/dev/shm`; per-app `/tmp`. Chromium-family
browsers share an app-specific `/tmp` so external URLs reuse one browser instance.
- Read-only `/nix/store`; GPU and display; fonts and themes; LAN and Internet.
- Portals for file chooser, URI opening, notifications, input methods and document exports.
- No whole host home, system D-Bus, SSH agent, USB/serial, raw camera or raw PipeWire socket.
- No whole host home, system D-Bus, SSH agent, USB/serial or raw camera. Raw PipeWire is granted
only to MuseScore for its ALSA-via-PipeWire audio path; see [Limits](#limits).
- Host loopback only for Firefox/Chromium; all other apps use pasta-isolated loopback. Chromium is
an intentional development exception. Brave Origin and Edge are backup browsers and receive no
media grants.
@@ -19,21 +29,27 @@ Deployed and interactively validated on the Linux desktop host (2026-10-31). Pol
`Media` means audio playback, microphone, Camera and ScreenCast portals. Writable paths are listed
explicitly; other files use the document portal. Every app also has private writable state.
| App | Network | Writable paths | Grants |
| ------------ | ------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle; camera/screen sharing intentionally unavailable |
| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, v12 Secret |
| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, v12 Secret |
| Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret |
| Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret |
| QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility |
| Telegram | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility, Ayatana indicators |
| Zoom | Isolated | `~/.zoom`, `zoomus.conf`, `zoom.conf`, Downloads, `Documents/Zoom`, private app state | Media, X11/XWayland, FHS, SNI tray, idle, `/dev/video0-1`, read-only `/sys/class/drm` |
| WeChat | Isolated | Documents, Downloads, app-scoped `wechat` data, private app state | Media, Wayland, X11 fallback, SNI tray, file chooser portal |
| Foliate | None | Documents/Downloads/Pictures read-only, app-scoped state | File chooser, OpenURI, notifications, IME, accessibility |
| Krita | None | Documents/Downloads/Pictures writable, app-scoped state | File chooser, OpenURI, print, inhibit, notifications, IME, accessibility |
| Bambu Studio | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, PulseAudio, cloud/LAN network, file chooser, notifications, IME, accessibility |
| OrcaSlicer | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, cloud/LAN network, file chooser, notifications, IME, accessibility |
| App | Network | Writable paths | Grants |
| ------------ | ------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle; camera/screen sharing intentionally unavailable |
| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, v12 Secret |
| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, v12 Secret |
| Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret |
| Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret |
| QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility |
| Telegram | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility, Ayatana indicators |
| Zoom | Isolated | `~/.zoom`, `zoomus.conf`, `zoom.conf`, Downloads, `Documents/Zoom`, private app state | Media, X11/XWayland, FHS, SNI tray, idle, `/dev/video0-1`, read-only `/sys/class/drm` |
| WeChat | Isolated | Documents, Downloads, app-scoped `wechat` data, private app state | Media, Wayland, X11 fallback, SNI tray, file chooser portal |
| Foliate | None | Documents/Downloads/Pictures read-only, app-scoped state | File chooser, OpenURI, notifications, IME, accessibility |
| Krita | None | Documents/Downloads/Pictures writable, app-scoped state | File chooser, OpenURI, print, inhibit, notifications, IME, accessibility |
| Bambu Studio | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, PulseAudio, cloud/LAN network, file chooser, notifications, IME, accessibility |
| OrcaSlicer | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, cloud/LAN network, file chooser, notifications, IME, accessibility |
| Remmina | Isolated | `~/.config/remmina`, `~/.local/share/remmina`, `~/.remmina`, Downloads | PulseAudio, SSH agent, file chooser, OpenURI, notifications, IME, accessibility |
| OBS Studio | Isolated | `~/.config/obs-studio`, Videos/Pictures/Downloads | ScreenCast, V4L2 camera (`/dev/video0-1`), PulseAudio, tray, notifications, IME, accessibility |
| Blender | Isolated | `~/.config/blender`, Documents/Downloads/Pictures/Videos | X11/XWayland, PulseAudio, notifications, IME, accessibility |
| MuseScore | Isolated | `~/.config/MuseScore`, `~/.local/share/MuseScore`, Documents/Downloads/Music/Pictures | ALSA via PipeWire, ALSA sequencer, X11/XWayland, print, notifications, IME, accessibility |
| mpv | Isolated | `~/.config/mpv`, `~/.cache/mpv`, `~/.local/state/mpv`; media dirs read-only | MPRIS, file chooser, OpenURI |
| imv | None | `$HOME` read-only | File chooser, OpenURI |
All browsers get read-only NSS certificates, printing, native notifications and accessibility.
@@ -65,6 +81,18 @@ All browsers get read-only NSS certificates, printing, native notifications and
- Bambu Studio's nixpkgs build omits the `device_page` frontend bundle, so filament management is
unavailable. Its MakerWorld sidebar entry also requires the upstream `firstguide.finish = "true"`
workaround; the startup splash may render black under XWayland.
- Remmina's SSH Tunnel support receives a read-only SSH agent socket and SSH client config. A
compromised Remmina can use the agent to sign or decrypt while the agent is running.
- OBS, Blender, MuseScore and mpv use pasta-isolated networking. Host loopback services are not
reachable unless the app is changed to a host-loopback exception.
- MuseScore receives the raw PipeWire socket so its ALSA driver can use the host `pcm.!default`
PipeWire plugin. PipeWire is a general media graph, so this is broader than a single audio stream:
a compromised MuseScore could enumerate other user-visible PipeWire media nodes. The alternative,
direct `/dev/snd`, confines the media graph but bypasses PipeWire device selection, per-app volume
and mixing.
- OBS on Linux defaults its recording path to `$HOME`. The sandbox intentionally binds only the
declared home subdirectories, so recording output must target `Videos`, `Pictures` or `Downloads`;
a file written to the home root exists only in the sandbox's transient mount namespace.
## Validation
@@ -72,6 +100,12 @@ All browsers get read-only NSS certificates, printing, native notifications and
- Interactive validation passed for Firefox, Chrome, Chromium, Brave Origin, Edge, QQ, Telegram,
Zoom, Bambu Studio and OrcaSlicer. Chromium's host-loopback/personal-file exception and the
Brave/Edge no-media policy are intentional.
- OBS Studio passed interactive validation for PipeWire screen capture, V4L2 camera capture,
desktop/microphone audio, local NVENC H.264 MP4 recording and local RTMP streaming. External
provider authentication and browser sources remain unvalidated.
- Remmina, Blender, mpv and imv build and pass the policy suite; interactive validation is still
pending. MuseScore passed launch, XWayland and ALSA-to-PipeWire routing checks; audible playback
and user workflows still need confirmation.
- Chromium smoke test passed: renderer sandbox, environment clearing, bind scope, disposable-profile
persistence and loopback policy.
- WeChat uses the native nixpkgs-patched package with nixpak as the only bwrap layer. Build/policy
+54
View File
@@ -0,0 +1,54 @@
{
lib,
blender,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
...
}:
let
appId = "org.blender.Blender";
wrapped = mkNixPak {
config = {
imports = [ ./modules/blender-policy.nix ];
app = {
package = blender;
binPath = "bin/blender";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "blender-desktop-assets" { } ''
mkdir -p $out/share
ln -s ${blender}/share/icons $out/share/icons
'';
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "blender";
};
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = "blender";
desktopName = "Blender";
genericName = "3D Creation Suite";
comment = "3D modeling, animation and rendering";
exec = "${exe} %f";
icon = "blender";
terminal = false;
startupNotify = true;
startupWMClass = "Blender";
categories = [
"Graphics"
"3DGraphics"
];
mimeTypes = [ "application/x-blender" ];
extraConfig.X-Flatpak = appId;
})
];
}
+7
View File
@@ -3,6 +3,7 @@
pkgs-master,
pkgs-patched,
pkgs-stable,
pkgs-blender,
nixpak,
...
}:
@@ -32,6 +33,12 @@ in
bambu-studio = wrapper pkgs-master ./bambu-studio.nix;
orca-slicer = wrapper pkgs-master ./orca-slicer.nix;
zoom = wrapper super ./zoom.nix;
remmina = wrapper super ./remmina.nix;
obs-studio = wrapper super ./obs-studio.nix;
blender = wrapper pkgs-blender ./blender.nix;
musescore = wrapper pkgs-stable ./musescore.nix;
mpv = wrapper super ./mpv.nix;
imv = wrapper super ./imv.nix;
wechat = wrapper pkgs-patched ./wechat.nix;
};
})
+71
View File
@@ -0,0 +1,71 @@
{
lib,
pkgs,
imv,
mkNixPak,
buildEnv,
makeDesktopItem,
...
}:
let
appId = "com.github.exec64.imv";
wrapped = mkNixPak {
config = {
imports = [ ./modules/image-viewer-policy.nix ];
app = {
package = imv;
binPath = "bin/imv";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
# Match the upstream imv-dir helper: one file opens its containing directory;
# multiple arguments are passed through to imv unchanged.
imvDir = pkgs.writeShellScript "imv-dir" ''
if [ "$#" -ne 1 ]; then
exec ${exe} "$@"
fi
exec ${exe} -n "$1" "$(${pkgs.coreutils}/bin/dirname "$1")"
'';
desktopItem =
name: desktopName: exec:
makeDesktopItem {
inherit name desktopName exec;
genericName = "Image Viewer";
comment = "View images";
icon = "multimedia-photo-viewer";
terminal = false;
startupNotify = true;
startupWMClass = "imv";
categories = [
"Graphics"
"2DGraphics"
"Viewer"
];
mimeTypes = [
"image/avif"
"image/bmp"
"image/gif"
"image/heif"
"image/jpeg"
"image/jxl"
"image/png"
"image/svg+xml"
"image/tiff"
"image/webp"
];
extraConfig.X-Flatpak = appId;
};
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "imv";
};
paths = [
wrapped.config.script
(desktopItem "imv" "imv" "${exe} %F")
(desktopItem "imv-dir" "imv-dir" "${imvDir} %F")
];
}
@@ -0,0 +1,40 @@
# Blender needs GPU access, audio for the sequencer and network for extensions;
# user files are shared through common document directories and the portal.
{
sloth,
...
}:
{
imports = [
./desktop-base.nix
./network.nix
];
pasta.mode = "isolate";
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
inputMethod = true;
};
desktopIntegration = {
notifications = true;
accessibility = true;
};
bubblewrap = {
sockets = {
x11 = true;
pulse = true;
};
bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.config/blender"))
sloth.xdgDocumentsDir
sloth.xdgDownloadDir
sloth.xdgPicturesDir
sloth.xdgVideosDir
];
env = {
DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
};
};
}
@@ -0,0 +1,17 @@
# Minimal local image viewer. The whole home is read-only so images can be
# opened from any user directory; network, audio and devices stay unavailable.
{
sloth,
...
}:
{
imports = [ ./desktop-base.nix ];
portalGroups = {
fileChooser = true;
openUri = true;
};
bubblewrap = {
sockets.pulse = false;
bind.ro = [ sloth.homeDir ];
};
}
+34
View File
@@ -0,0 +1,34 @@
# mpv policy: media directories are read-only, playback state and user config
# remain writable, and MPRIS is the only desktop service it owns.
{
sloth,
...
}:
{
imports = [
./desktop-base.nix
./network.nix
];
pasta.mode = "isolate";
portalGroups = {
fileChooser = true;
openUri = true;
};
desktopIntegration.mpris = true;
bubblewrap = {
sockets.pulse = true;
bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.config/mpv"))
(sloth.mkdir (sloth.concat' sloth.homeDir "/.cache/mpv"))
(sloth.mkdir (sloth.concat' sloth.homeDir "/.local/state/mpv"))
];
bind.ro = [
sloth.xdgDocumentsDir
sloth.xdgDownloadDir
sloth.xdgMusicDir
sloth.xdgPicturesDir
sloth.xdgVideosDir
(sloth.concat' sloth.homeDir "/.config/yt-dlp")
];
};
}
@@ -0,0 +1,58 @@
# MuseScore policy: scores and exports use common user directories. Audio uses
# ALSA via the host PipeWire plugin, MIDI uses the ALSA sequencer, and the UI
# runs through XWayland.
{
lib,
sloth,
...
}:
{
imports = [
./desktop-base.nix
./network.nix
];
pasta.mode = "isolate";
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
print = true;
inputMethod = true;
};
desktopIntegration = {
notifications = true;
accessibility = true;
};
bubblewrap = {
sockets = {
# MuseScore 4's nixpkgs launcher selects Qt's XCB backend. Niri provides
# that through XWayland, so expose the X11 socket and credentials.
x11 = true;
# MuseScore speaks ALSA; the host ALSA configuration forwards its
# default PCM to PipeWire, so expose the PipeWire socket for audio.
pulse = lib.mkForce false;
pipewire = lib.mkForce true;
};
bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.config/MuseScore"))
(sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/MuseScore"))
sloth.xdgDocumentsDir
sloth.xdgDownloadDir
sloth.xdgMusicDir
sloth.xdgPicturesDir
];
# Use the host's ALSA configuration, which routes the default PCM
# through PipeWire. Only the sequencer node is needed for MIDI.
bind.ro = [
"/etc/alsa"
"/etc/static/alsa"
];
bind.dev = [ "/dev/snd/seq" ];
env = {
DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
QT_QPA_PLATFORM = "xcb";
MUSESCORE_FORCE_ALSA = "1";
};
};
}
+60
View File
@@ -0,0 +1,60 @@
# OBS capture policy: screen capture stays on the XDG portal, audio goes
# through PulseAudio/PipeWire-Pulse, and network access is pasta-isolated.
# OBS's V4L2 source opens camera device nodes directly instead of using the
# Camera portal, so grant the same raw video devices as Zoom.
{
sloth,
...
}:
{
imports = [
./desktop-base.nix
./network.nix
];
pasta.mode = "isolate";
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
screenCast = true;
camera = true;
inhibit = true;
inputMethod = true;
};
desktopIntegration = {
notifications = true;
tray = true;
accessibility = true;
};
bubblewrap = {
sockets = {
x11 = true;
pulse = true;
};
bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.config/obs-studio"))
sloth.xdgVideosDir
sloth.xdgPicturesDir
sloth.xdgDownloadDir
];
# OBS enumerates DRM connectors when building its encoder list, and its
# V4L2 source discovers cameras by reading /sys/class/video4linux before
# opening the corresponding /dev/video* nodes.
bind.ro = [
"/sys/class/drm"
"/sys/class/video4linux"
];
bind.dev = [
"/dev/video0"
"/dev/video1"
];
env = {
DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
# OBS's NVENC plugin dlopen()s libnvidia-encode.so.1 by soname after
# startup. Nixpak exposes the host driver under /run/opengl-driver but
# clears the host environment, so add it to the loader search path.
LD_LIBRARY_PATH = "/run/opengl-driver/lib";
};
};
}
@@ -0,0 +1,48 @@
# Shared policy for graphical remote-desktop clients. No raw camera, video or
# PipeWire access; SSH tunnels use the user's agent socket explicitly.
{
config,
lib,
sloth,
...
}:
let
cfg = config.remoteDesktop;
in
{
imports = [
./desktop-base.nix
./network.nix
];
options.remoteDesktop.sshAgent = lib.mkEnableOption "the user's SSH agent and SSH client config";
config = {
pasta.mode = "isolate";
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
inputMethod = true;
};
desktopIntegration = {
notifications = true;
accessibility = true;
};
bubblewrap = {
sockets.pulse = true;
bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.config/remmina"))
(sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/remmina"))
(sloth.mkdir (sloth.concat' sloth.homeDir "/.remmina"))
sloth.xdgDownloadDir
];
bind.ro = lib.optionals cfg.sshAgent [
(sloth.concat' sloth.homeDir "/.ssh/config")
(sloth.concat' sloth.homeDir "/.ssh/known_hosts")
(sloth.concat' sloth.runtimeDir "/ssh-agent")
];
env = lib.optionalAttrs cfg.sshAgent {
SSH_AUTH_SOCK = sloth.concat' sloth.runtimeDir "/ssh-agent";
};
};
};
}
+64
View File
@@ -0,0 +1,64 @@
{
lib,
mpv,
mpvScripts,
mkNixPak,
buildEnv,
makeDesktopItem,
...
}:
let
appId = "io.mpv.Mpv";
package = mpv.override { scripts = [ mpvScripts.mpris ]; };
wrapped = mkNixPak {
config = {
imports = [ ./modules/mpv-policy.nix ];
app = {
inherit package;
binPath = "bin/mpv";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "mpv";
};
paths = [
wrapped.config.script
(makeDesktopItem {
name = "mpv";
desktopName = "mpv Media Player";
genericName = "Media Player";
comment = "Play audio and video files";
exec = "${exe} --player-operation-mode=pseudo-gui -- %U";
icon = "mpv";
terminal = false;
startupNotify = true;
startupWMClass = "mpv";
categories = [
"AudioVideo"
"Audio"
"Video"
"Player"
];
mimeTypes = [
"application/ogg"
"application/x-matroska"
"audio/flac"
"audio/mpeg"
"audio/ogg"
"audio/wav"
"video/mp4"
"video/quicktime"
"video/webm"
"video/x-matroska"
"video/x-msvideo"
];
extraConfig.X-Flatpak = appId;
})
];
}
+64
View File
@@ -0,0 +1,64 @@
{
lib,
musescore,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
...
}:
let
appId = "org.musescore.MuseScore";
wrapped = mkNixPak {
config = {
imports = [ ./modules/musescore-policy.nix ];
app = {
package = musescore;
binPath = "bin/mscore";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "musescore-desktop-assets" { } ''
mkdir -p $out/share
ln -s ${musescore}/share/icons $out/share/icons
'';
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "mscore";
};
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = appId;
desktopName = "MuseScore Studio";
genericName = "Music Notation";
comment = "Create, play and print sheet music";
exec = "${exe} %U";
icon = "mscore";
terminal = false;
startupNotify = true;
startupWMClass = "MuseScore4";
categories = [
"AudioVideo"
"Audio"
"Graphics"
"Midi"
"Music"
];
mimeTypes = [
"application/x-musescore"
"application/x-musescore+xml"
"application/vnd.recordare.musicxml"
"application/vnd.recordare.musicxml+xml"
"audio/midi"
"x-scheme-handler/musescore"
];
extraConfig.X-Flatpak = appId;
})
];
}
+70
View File
@@ -0,0 +1,70 @@
{
lib,
obs-studio,
obs-studio-plugins,
wrapOBS,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
...
}:
let
appId = "com.obsproject.Studio";
package = wrapOBS.override { inherit obs-studio; } {
plugins = with obs-studio-plugins; [
# Wayland screen-capture fallback, per-application audio, and game capture.
wlrobs
obs-pipewire-audio-capture
obs-vkcapture
# Widely used scene, overlay and streaming plugins.
input-overlay
obs-multi-rtmp
obs-shaderfilter
obs-move-transition
obs-backgroundremoval
advanced-scene-switcher
];
};
wrapped = mkNixPak {
config = {
imports = [ ./modules/obs-policy.nix ];
app = {
inherit package;
binPath = "bin/obs";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "obs-studio-desktop-assets" { } ''
mkdir -p $out/share
ln -s ${package}/share/icons $out/share/icons
'';
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "obs";
};
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = appId;
desktopName = "OBS Studio";
genericName = "Screen Recorder";
comment = "Record and stream the desktop";
exec = "${exe} %U";
icon = appId;
terminal = false;
startupNotify = true;
startupWMClass = "obs";
categories = [
"AudioVideo"
"Recorder"
];
extraConfig.X-Flatpak = appId;
})
];
}
+63
View File
@@ -0,0 +1,63 @@
{
lib,
remmina,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
...
}:
let
appId = "org.remmina.Remmina";
wrapped = mkNixPak {
config = {
imports = [ ./modules/remote-desktop-policy.nix ];
app = {
package = remmina;
binPath = "bin/remmina";
};
flatpak = { inherit appId; };
# Preserve the SSH Tunnel tab used for localhost VNC access.
remoteDesktop.sshAgent = true;
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "remmina-desktop-assets" { } ''
mkdir -p $out/share
ln -s ${remmina}/share/icons $out/share/icons
'';
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "remmina";
};
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = appId;
desktopName = "Remmina";
genericName = "Remote Desktop Client";
comment = "Connect to RDP, VNC, SPICE and SSH sessions";
exec = "${exe} %U";
icon = appId;
terminal = false;
startupNotify = true;
startupWMClass = appId;
categories = [
"GTK"
"Network"
];
mimeTypes = [
"application/x-remmina"
"x-scheme-handler/rdp"
"x-scheme-handler/spice"
"x-scheme-handler/vnc"
"x-scheme-handler/ssh"
"x-scheme-handler/remmina"
];
extraConfig.X-Flatpak = appId;
})
];
}
+4 -4
View File
@@ -72,11 +72,11 @@ let
throw "unknown network mode for ${app.flatpak.appId}: ${mode}";
mediaCheck =
app: expected:
app: expected: allowPipewire:
if expected then
app.portalGroups.camera && app.portalGroups.screenCast
else
!app.bubblewrap.sockets.pipewire
(!app.bubblewrap.sockets.pipewire || allowPipewire)
&& !app.portalGroups.camera
&& !app.portalGroups.screenCast
&& !(hasDbusFragment app "org.freedesktop.portal.Camera")
@@ -113,7 +113,7 @@ let
checks = {
"${name}/clearEnv" = app.bubblewrap.clearEnv;
"${name}/dbus" = app.dbus.enable;
"${name}/noRawPipewire" = !app.bubblewrap.sockets.pipewire;
"${name}/noRawPipewire" = !app.bubblewrap.sockets.pipewire || (spec.allowPipewire or false);
"${name}/noForbiddenDbus" = !lib.any forbiddenDbus app.dbus.args;
"${name}/noBroadKeyring" =
!lib.any (argument: argument == "--talk=org.freedesktop.secrets") app.dbus.args;
@@ -123,7 +123,7 @@ let
"${name}/privateTmp" = hasPrivateTmp app;
"${name}/network" = networkCheck app spec.network;
"${name}/x11" = app.bubblewrap.sockets.x11 == (spec.x11 or false);
"${name}/media" = mediaCheck app (spec.media or false);
"${name}/media" = mediaCheck app (spec.media or false) (spec.allowPipewire or false);
"${name}/pulse" = app.bubblewrap.sockets.pulse == (spec.pulse or (spec.media or false));
"${name}/mpris" = app.desktopIntegration.mpris == (spec.mpris or false);
"${name}/notifications" = app.desktopIntegration.notifications == (spec.notifications or true);
+21
View File
@@ -0,0 +1,21 @@
{
load,
mkAppPolicy,
...
}:
let
app = load "blender";
in
mkAppPolicy "blender" {
network = "pasta";
media = false;
pulse = true;
x11 = true;
notifications = true;
extraChecks = {
fileChooser = app.portalGroups.fileChooser;
openUri = app.portalGroups.openUri;
inputMethod = app.portalGroups.inputMethod;
accessibility = app.desktopIntegration.accessibility;
};
}
+20
View File
@@ -0,0 +1,20 @@
{
load,
mkAppPolicy,
...
}:
let
app = load "imv";
in
mkAppPolicy "imv" {
network = "none";
media = false;
pulse = false;
x11 = false;
notifications = false;
extraChecks = {
fileChooser = app.portalGroups.fileChooser;
openUri = app.portalGroups.openUri;
noAudio = !app.bubblewrap.sockets.pulse;
};
}
+20
View File
@@ -0,0 +1,20 @@
{
load,
mkAppPolicy,
...
}:
let
app = load "mpv";
in
mkAppPolicy "mpv" {
network = "pasta";
media = false;
pulse = true;
x11 = false;
mpris = true;
notifications = false;
extraChecks = {
fileChooser = app.portalGroups.fileChooser;
openUri = app.portalGroups.openUri;
};
}
+29
View File
@@ -0,0 +1,29 @@
{
load,
mkAppPolicy,
...
}:
let
app = load "musescore";
in
mkAppPolicy "musescore" {
network = "pasta";
media = false;
pulse = false;
allowPipewire = true;
x11 = true;
notifications = true;
extraChecks = {
fileChooser = app.portalGroups.fileChooser;
print = app.portalGroups.print;
inputMethod = app.portalGroups.inputMethod;
accessibility = app.desktopIntegration.accessibility;
qtXcb = app.bubblewrap.env.QT_QPA_PLATFORM == "xcb";
alsaSequencer = builtins.elem "/dev/snd/seq" app.bubblewrap.bind.dev;
hostAlsaConfig =
builtins.elem "/etc/alsa" app.bubblewrap.bind.ro
&& builtins.elem "/etc/static/alsa" app.bubblewrap.bind.ro;
pipewireAudio = app.bubblewrap.sockets.pipewire;
forceAlsa = app.bubblewrap.env.MUSESCORE_FORCE_ALSA == "1";
};
}
+34
View File
@@ -0,0 +1,34 @@
{
lib,
load,
mkAppPolicy,
...
}:
let
app = load "obs-studio";
in
mkAppPolicy "obs-studio" {
network = "pasta";
media = true;
pulse = true;
x11 = true;
allowedVideoDevices = [
"/dev/video0"
"/dev/video1"
];
notifications = true;
tray = true;
extraChecks = {
fileChooser = app.portalGroups.fileChooser;
screenCast = app.portalGroups.screenCast;
camera = app.portalGroups.camera;
inhibit = app.portalGroups.inhibit;
accessibility = app.desktopIntegration.accessibility;
videoDevices = lib.all (dev: lib.elem dev app.bubblewrap.bind.dev) [
"/dev/video0"
"/dev/video1"
];
video4linuxSysfs = lib.elem "/sys/class/video4linux" app.bubblewrap.bind.ro;
nvencLibraryPath = app.bubblewrap.env.LD_LIBRARY_PATH == "/run/opengl-driver/lib";
};
}
+22
View File
@@ -0,0 +1,22 @@
{
load,
mkAppPolicy,
...
}:
let
app = load "remmina";
in
mkAppPolicy "remmina" {
network = "pasta";
media = false;
pulse = true;
x11 = false;
notifications = true;
extraChecks = {
fileChooser = app.portalGroups.fileChooser;
openUri = app.portalGroups.openUri;
inputMethod = app.portalGroups.inputMethod;
accessibility = app.desktopIntegration.accessibility;
sshAgent = app.remoteDesktop.sshAgent;
};
}
+22
View File
@@ -26,6 +26,12 @@ Nixpak wrappers are accepted in three layers:
| Krita | Pass | Interactive validation pending |
| Bambu Studio | Pass | Interactive pass; device_page/filament UI unavailable in nixpkgs |
| OrcaSlicer | Pass | Interactive pass; blank WebKit home page fixed |
| Remmina | Pass | Interactive validation pending |
| OBS Studio | Pass | Interactive pass; NVENC recording and local RTMP streaming work |
| Blender | Pass | Interactive validation pending |
| MuseScore | Pass | Interactive launch pass; ALSA routed to default PipeWire sink |
| mpv | Pass | Interactive validation pending |
| imv | Pass | Interactive validation pending |
Intentional exceptions:
@@ -45,6 +51,10 @@ Known packaging limitations:
`BambuStudio.conf`; the upstream guide writes `"1"` while the web-view code only accepts `"true"`.
- **Bambu Studio:** the startup splash can render black under XWayland. This is cosmetic; the main
UI renders normally.
- **MuseScore:** uses Qt's XCB backend, so it runs through XWayland. Its ALSA driver uses the host
`pcm.!default` configuration, which forwards to PipeWire; this requires the raw PipeWire socket.
Launch and audio routing pass, but audible playback and score/print/export workflows still need
user confirmation.
## Browser smoke test
@@ -85,6 +95,18 @@ Zoom keeps its upstream FHS runtime inside the outer nixpak boundary. The nested
shared-display trust boundary: ScreenCast portal permission does not prevent capture through the X11
connection. Interactive login, audio, camera, screen sharing and file workflows passed.
## OBS
Interactive validation covered PipeWire screen capture, V4L2 camera capture, desktop/microphone
audio, NVENC H.264 Hybrid MP4 recording and RTMP publishing. Streaming was tested against a
temporary MediaMTX server on the host's LAN address; the OBS pasta namespace cannot reach host
loopback. Bilibili or another external provider still needs a real account-specific stream key to
validate provider authentication and CDN ingest.
OBS records to the path configured under output settings. In Simple mode that is
`SimpleOutput.FilePath`; Advanced mode uses `AdvOut.RecFilePath` and `AdvOut.FFFilePath`. These must
point at a writable bind such as `~/Videos`, not the sandbox's transient home root.
## Commands
```bash
+8 -45
View File
@@ -1,49 +1,12 @@
{ pkgs, ... }:
{
pkgs,
pkgs-stable,
pkgs-blender,
...
}:
{
home.packages = with pkgs-stable; [
home.packages = with pkgs; [
# creative
pkgs.nixpaks.krita # digital painting
musescore # music notation
pkgs.nixpaks.orca-slicer # 3d printer slicer app
pkgs.nixpaks.bambu-studio # bambu 3d printer slicer app
pkgs-blender.blender # 3d modeling
nixpaks.krita # digital painting
nixpaks.musescore # music notation
nixpaks.orca-slicer # 3d printer slicer app
nixpaks.bambu-studio # bambu 3d printer slicer app
nixpaks.blender # 3d modeling
nixpaks.obs-studio # screen recording and streaming; plugins are bundled by the wrapper
];
programs = {
# live streaming
obs-studio = {
enable = pkgs.stdenv.hostPlatform.isx86_64;
plugins = with pkgs.obs-studio-plugins; [
# screen capture
wlrobs
# obs-ndi
# obs-nvfbc
# obs-teleport
# obs-hyperion
# droidcam-obs
# obs-vkcapture
obs-gstreamer
input-overlay
obs-multi-rtmp
obs-source-clone
# obs-shaderfilter
# obs-source-record
# obs-livesplit-one
# looking-glass-obs
# obs-vintage-filter
# obs-command-source
# obs-move-transition
# obs-backgroundremoval
# advanced-scene-switcher
obs-pipewire-audio-capture
obs-vaapi
# obs-3d-effect
];
};
};
}
+2 -2
View File
@@ -12,7 +12,7 @@
pavucontrol
playerctl
pulsemixer
imv # simple image viewer
pkgs.nixpaks.imv # simple image viewer
# music
go-musicfox # TUI NetEase Cloud Music client; plays through mpv (configured below)
@@ -30,8 +30,8 @@
programs.mpv = {
enable = true;
package = pkgs.nixpaks.mpv;
defaultProfiles = [ "gpu-hq" ];
scripts = [ pkgs.mpvScripts.mpris ];
};
services = {
+2 -3
View File
@@ -9,9 +9,8 @@
# do not support .pdf
nixpaks.foliate
# remote desktop(rdp connect)
remmina
freerdp # required by remmina
# remote desktop (RDP/VNC/SPICE); FreeRDP is a Remmina runtime dependency
nixpaks.remmina
# my custom hardened packages
nixpaks.qq