mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
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:
1 parent
5e5ceae108
commit
c277c81cd6
25 files changed
+884
-70
No files matched your search
+50
-16
@@ -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
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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 ];
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user