feat(desktop): consolidate declarative workstation profile

Capture desktop-specific system, session, Noctalia, and Pi integration alongside shared laptop support. Retire obsolete X11 configuration and make package ownership explicit for repeatable Decman deployments.
This commit is contained in:
2026-08-24 20:20:58 +02:00
parent bfc5aa7933
commit ed063ab1fe
105 changed files with 1479 additions and 8826 deletions
@@ -0,0 +1,81 @@
# vim:set ft=sh
# MODULES
# The following modules are loaded before any boot hooks are
# run. Advanced users may wish to specify all system modules
# in this array. For instance:
# MODULES=(usbhid xhci_hcd)
MODULES=()
# BINARIES
# This setting includes any additional binaries a given user may
# wish into the CPIO image. This is run last, so it may be used to
# override the actual binaries included by a given hook
# BINARIES are dependency parsed, so you may safely ignore libraries
BINARIES=()
# FILES
# This setting is similar to BINARIES above, however, files are added
# as-is and are not parsed in any way. This is useful for config files.
FILES=()
# HOOKS
# This is the most important setting in this file. The HOOKS control the
# modules and scripts added to the image, and what happens at boot time.
# Order is important, and it is recommended that you do not change the
# order in which HOOKS are added. Run 'mkinitcpio -H <hook name>' for
# help on a given hook.
# 'base' is _required_ unless you know precisely what you are doing.
# 'udev' is _required_ in order to automatically load modules
# 'filesystems' is _required_ unless you specify your fs modules in MODULES
# Examples:
## This setup specifies all modules in the MODULES setting above.
## No RAID, lvm2, or encrypted root is needed.
# HOOKS=(base)
#
## This setup will autodetect all modules for your system and should
## work as a sane default
# HOOKS=(base udev autodetect modconf block filesystems fsck)
#
## This setup will generate a 'full' image which supports most systems.
## No autodetection is done.
# HOOKS=(base udev modconf block filesystems fsck)
#
## This setup assembles a mdadm array with an encrypted root file system.
## Note: See 'mkinitcpio -H mdadm_udev' for more information on RAID devices.
# HOOKS=(base udev modconf keyboard keymap consolefont block mdadm_udev encrypt filesystems fsck)
#
## This setup loads an lvm2 volume group.
# HOOKS=(base udev modconf block lvm2 filesystems fsck)
#
## This will create a systemd based initramfs which loads an encrypted root filesystem.
# HOOKS=(base systemd autodetect modconf kms keyboard sd-vconsole sd-encrypt block filesystems fsck)
#
## NOTE: If you have /usr on a separate partition, you MUST include the
# usr and fsck hooks.
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap encrypt lvm2 consolefont block filesystems resume fsck)
# COMPRESSION
# Use this to compress the initramfs image. By default, zstd compression
# is used for Linux ≥ 5.9 and gzip compression is used for Linux < 5.9.
# Use 'cat' to create an uncompressed image.
#COMPRESSION="zstd"
#COMPRESSION="gzip"
#COMPRESSION="bzip2"
#COMPRESSION="lzma"
#COMPRESSION="xz"
#COMPRESSION="lzop"
#COMPRESSION="lz4"
# COMPRESSION_OPTIONS
# Additional options for the compressor
#COMPRESSION_OPTIONS=()
# MODULES_DECOMPRESS
# Decompress loadable kernel modules and their firmware during initramfs
# creation. Switch (yes/no).
# Enable to allow further decreasing image size when using high compression
# (e.g. xz -9e or zstd --long --ultra -22) at the expense of increased RAM usage
# at early boot.
# Note that any compressed files will be placed in the uncompressed early CPIO
# to avoid double compression.
#MODULES_DECOMPRESS="no"
@@ -0,0 +1,749 @@
#
# refind.conf
# Configuration file for the rEFInd boot menu
#
# Timeout in seconds for the main menu screen. Setting the timeout to 0
# disables automatic booting (i.e., no timeout). Setting it to -1 causes
# an immediate boot to the default OS *UNLESS* a keypress is in the buffer
# when rEFInd launches, in which case that keypress is interpreted as a
# shortcut key. If no matching shortcut is found, rEFInd displays its
# menu with no timeout.
#
timeout 20
# Set the logging level. When set to 0, rEFInd does not log its actions.
# When set to 1 or above, rEFInd creates a file called refind.log in
# its home directory on the ESP and records information about what it's
# doing. Higher values record more information, up to a maximum of 4.
# This token should be left at the default of 0 except when debugging
# problems.
# Default value is 0
#
#log_level 1
# Normally, when the timeout period has passed, rEFInd boots the
# default_selection. If the following option is uncommented, though,
# rEFInd will instead attempt to shut down the computer.
# CAUTION: MANY COMPUTERS WILL INSTEAD HANG OR REBOOT! Macs and more
# recent UEFI-based PCs are most likely to work with this feature.
# Default value is true
#
#shutdown_after_timeout
# Whether to store rEFInd's rEFInd-specific variables in NVRAM (1, true,
# or on) or in files in the "vars" subdirectory of rEFInd's directory on
# disk (0, false, or off). Using NVRAM works well with most computers;
# however, it increases wear on the motherboard's NVRAM, and if the EFI
# is buggy or the NVRAM is old and worn out, it may not work at all.
# Storing variables on disk is a viable alternative in such cases, or
# if you want to minimize wear and tear on the NVRAM; however, it won't
# work if rEFInd is stored on a filesystem that's read-only to the EFI
# (such as an HFS+ volume), and it increases the risk of filesystem
# damage. Note that this option affects ONLY rEFInd's own variables,
# such as the PreviousBoot, HiddenTags, HiddenTools, and HiddenLegacy
# variables. It does NOT affect Secure Boot or other non-rEFInd
# variables.
# Default is true
#
use_nvram false
# Screen saver timeout; the screen blanks after the specified number of
# seconds with no keyboard input. The screen returns after most keypresses
# (unfortunately, not including modifier keys such as Shift, Control, Alt,
# or Option). Setting a value of "-1" causes rEFInd to start up with its
# screen saver active. The default is 0, which disables the screen saver.
#
#screensaver 300
# Hide user interface elements for personal preference or to increase
# security:
# banner - the rEFInd title banner (built-in or loaded via "banner")
# label - boot option text label in the menu
# singleuser - remove the submenu options to boot macOS in single-user
# or verbose modes; affects ONLY macOS
# safemode - remove the submenu option to boot macOS in "safe mode"
# hwtest - the submenu option to run Apple's hardware test
# arrows - scroll arrows on the OS selection tag line
# hints - brief command summary in the menu
# editor - the options editor (+, F2, or Insert on boot options menu)
# badges - device-type badges for boot options
# all - all of the above
# Default is none of these (all elements active)
#
#hideui singleuser
#hideui all
# Set the name of a subdirectory in which icons are stored. Icons must
# have the same names they have in the standard directory. The directory
# name is specified relative to the main rEFInd binary's directory. If
# an icon can't be found in the specified directory, an attempt is made
# to load it from the default directory; thus, you can replace just some
# icons in your own directory and rely on the default for others.
# Icon files may be in any supported format -- ICNS (*.icns), BMP (*.bmp),
# PNG (*.png), or JPEG (*.jpg or *.jpeg); however, rEFInd's BMP and JPEG
# implementations do not support transparency, which is highly desirable
# in icons.
# Default is "icons".
#
#icons_dir myicons
#icons_dir icons/snowy
# Use a custom title banner instead of the rEFInd icon and name. The file
# path is relative to the directory where refind.efi is located. The color
# in the top left corner of the image is used as the background color
# for the menu screens. Currently uncompressed BMP images with color
# depths of 24, 8, 4 or 1 bits are supported, as well as PNG and JPEG
# images. (ICNS images can also be used, but ICNS has limitations that
# make it a poor choice for this purpose.) PNG and JPEG support is
# limited by the underlying libraries; some files, like progressive JPEGs,
# will not work.
#
#banner hostname.bmp
#banner mybanner.jpg
#banner icons/snowy/banner-snowy.png
# Specify how to handle banners that aren't exactly the same as the screen
# size:
# noscale - Crop if too big, show with border if too small
# fillscreen - Fill the screen
# Default is noscale
#
#banner_scale fillscreen
# Icon sizes. All icons are square, so just one value is specified. The
# big icons are used for OS selectors in the first row and the small
# icons are used for tools on the second row. Drive-type badges are 1/4
# the size of the big icons. Legal values are 32 and above. If the icon
# files do not hold icons of the proper size, the icons are scaled to
# the specified size. The default values are 48 and 128 for small and
# big icons, respectively.
#
#small_icon_size 96
#big_icon_size 256
# Custom images for the selection background. There is a big one (144 x 144)
# for the OS icons, and a small one (64 x 64) for the function icons in the
# second row. If only a small image is given, that one is also used for
# the big icons by stretching it in the middle. If only a big one is given,
# the built-in default will be used for the small icons. If an image other
# than the optimal size is specified, it will be scaled in a way that may
# be ugly.
#
# Like the banner option above, these options take a filename of an
# uncompressed BMP, PNG, JPEG, or ICNS image file with a color depth of
# 24, 8, 4, or 1 bits. The PNG or ICNS format is required if you need
# transparency support (to let you "see through" to a full-screen banner).
#
#selection_big selection-big.bmp
#selection_small selection-small.bmp
# Set the font to be used for all textual displays in graphics mode.
# For best results, the font must be a PNG file with alpha channel
# transparency. It must contain ASCII characters 32-126 (space through
# tilde), inclusive, plus a glyph to be displayed in place of characters
# outside of this range, for a total of 96 glyphs. Only monospaced fonts
# are supported. Fonts may be of any size, although large fonts can
# produce display irregularities.
# The default is rEFInd's built-in font, Luxi Mono Regular 12 point.
#
#font myfont.png
# Use text mode only. When enabled, this option forces rEFInd into text mode.
# Passing this option a "0" value causes graphics mode to be used. Pasing
# it no value or any non-0 value causes text mode to be used.
# Default is to use graphics mode.
#
#textonly
# Set the EFI text mode to be used for textual displays. This option
# takes a single digit that refers to a mode number. Mode 0 is normally
# 80x25, 1 is sometimes 80x50, and higher numbers are system-specific
# modes. Mode 1024 is a special code that tells rEFInd to not set the
# text mode; it uses whatever was in use when the program was launched.
# If you specify an invalid mode, rEFInd pauses during boot to inform
# you of valid modes.
# CAUTION: On VirtualBox, and perhaps on some real computers, specifying
# a text mode and uncommenting the "textonly" option while NOT specifying
# a resolution can result in an unusable display in the booted OS.
# Default is 1024 (no change)
#
#textmode 2
# Set the screen's video resolution. Pass this option one of the following:
# * two integer values, corresponding to the X and Y resolutions
# * one integer value, corresponding to a GOP (UEFI) video mode
# * the string "max", which sets the maximum available resolution
# Note that not all resolutions are supported. On UEFI systems, passing
# an incorrect value results in a message being shown on the screen to
# that effect, along with a list of supported modes. On EFI 1.x systems
# (e.g., Macintoshes), setting an incorrect mode silently fails. On both
# types of systems, setting an incorrect resolution results in the default
# resolution being used. A resolution of 1024x768 usually works, but higher
# values often don't.
# Default is "0 0" (use the system default resolution, usually 800x600).
#
#resolution 1024 768
#resolution 1440 900
#resolution 3
#resolution max
# Enable touch screen support. If active, this feature enables use of
# touch screen controls (as on tablets). Note, however, that not all
# tablets' EFIs provide the necessary underlying support, so this
# feature may not work for you. If it does work, you should be able
# to launch an OS or tool by touching it. In a submenu, touching
# anywhere launches the currently-selection item; there is, at present,
# no way to select a specific submenu item. This feature is mutually
# exclusive with the enable_mouse feature. If both are uncommented,
# the one read most recently takes precedence.
#
#enable_touch
# Enable mouse support. If active, this feature enables use of the
# computer's mouse. Note, however, that not all computers' EFIs
# provide the necessary underlying support, so this feature may not
# work for you. If it does work, you should be able to launch an
# OS or tool by clicking it with the mouse pointer. This feature
# is mutually exclusive with the enable_touch feature. If both
# are uncommented, the one read most recently takes precedence.
#
#enable_mouse
# Size of the mouse pointer, in pixels, per side.
# Default is 16
#
#mouse_size 16
# Speed of mouse tracking. Higher numbers equate to faster
# mouse movement. This option requires that enable_mouse be
# uncommented.
# Legal values are between 1 and 32. Default is 4.
#
#mouse_speed 4
# Launch specified OSes in graphics mode. By default, rEFInd switches
# to text mode and displays basic pre-launch information when launching
# all OSes except macOS. Using graphics mode can produce a more seamless
# transition, but displays no information, which can make matters
# difficult if you must debug a problem. Also, on at least one known
# computer, using graphics mode prevents a crash when using the Linux
# kernel's EFI stub loader. You can specify an empty list to boot all
# OSes in text mode.
# Valid options:
# osx - macOS
# linux - A Linux kernel with EFI stub loader
# elilo - The ELILO boot loader
# grub - The GRUB (Legacy or 2) boot loader
# windows - Microsoft Windows
# Default value: osx
#
#use_graphics_for osx,linux
# Which non-bootloader tools to show on the tools line, and in what
# order to display them:
# shell - the EFI shell (requires external program; see rEFInd
# documentation for details)
# memtest - the memtest86 program, in EFI/tools, EFI/memtest86,
# EFI/memtest, EFI/tools/memtest86, EFI/tools/memtest,
# or a boot loader's directory
# gptsync - the (dangerous) gptsync.efi utility (requires external
# program; see rEFInd documentation for details)
# gdisk - the gdisk partitioning program
# apple_recovery - boots the Apple Recovery HD partition, if present
# windows_recovery - boots an OEM Windows recovery tool, if present
# (see also the windows_recovery_files option)
# mok_tool - makes available the Machine Owner Key (MOK) maintenance
# tool, MokManager.efi, used on Secure Boot systems
# csr_rotate - adjusts Apple System Integrity Protection (SIP)
# policy. Requires "csr_values" to be set.
# install - an option to install rEFInd from the current location
# to another ESP
# bootorder - adjust the EFI's (NOT rEFInd's) boot order
# about - an "about this program" option
# hidden_tags - manage hidden tags
# exit - a tag to exit from rEFInd
# shutdown - shuts down the computer (a bug causes this to reboot
# many UEFI systems)
# reboot - a tag to reboot the computer
# firmware - a tag to reboot the computer into the firmware's
# user interface (ignored on older computers)
# fwupdate - a tag to update the firmware; launches the fwupx64.efi
# (or similar) program
# netboot - launch the ipxe.efi tool for network (PXE) booting
# Default is shell,memtest,gdisk,apple_recovery,windows_recovery,mok_tool,about,hidden_tags,shutdown,reboot,firmware,fwupdate
# To completely disable scanning for all tools, provide a showtools line
# with no options.
#
#showtools shell, bootorder, gdisk, memtest, mok_tool, apple_recovery, windows_recovery, about, hidden_tags, reboot, exit, firmware, fwupdate
# Additional directories to scan for tools. You may specify a directory
# alone or a volume identifier plus pathname. The default is to scan no
# extra directories, beyond EFI/tools and any directory in which an EFI
# loader is found.
#
#also_scan_tool_dirs EFI/memtest,ESP2:/EFI/tools/memtest86
# Tool binaries to be excluded from the tools line, even if the
# general class is specified in showtools. This enables trimming an
# overabundance of tools, as when you see multiple mok_tool entries
# after installing multiple Linux distributions.
# Just as with dont_scan_files, you can specify a filename alone, a
# full pathname, or a volume identifier (filesystem label, partition
# name, or partition GUID) and a full pathname.
# Default is an empty list (nothing is excluded)
#
#dont_scan_tools ESP2:/EFI/ubuntu/mmx64.efi,gptsync_x64.efi
# Boot loaders that can launch a Windows restore or emergency system.
# These tend to be OEM-specific.
# Default is LRS_ESP:/EFI/Microsoft/Boot/LrsBootmgr.efi
#
#windows_recovery_files LRS_ESP:/EFI/Microsoft/Boot/LrsBootmgr.efi
# Directories in which to search for EFI drivers. These drivers can
# provide filesystem support, give access to hard disks on plug-in
# controllers, etc. In most cases none are needed, but if you add
# EFI drivers and you want rEFInd to automatically load them, you
# should specify one or more paths here. rEFInd always scans the
# "drivers" and "drivers_{arch}" subdirectories of its own installation
# directory (where "{arch}" is your architecture code); this option
# specifies ADDITIONAL directories to scan.
# Default is to scan no additional directories for EFI drivers
#
#scan_driver_dirs EFI/tools/drivers,drivers
# Which types of boot loaders to search, and in what order to display them:
# internal - internal EFI disk-based boot loaders
# external - external EFI disk-based boot loaders
# optical - EFI optical discs (CD, DVD, etc.)
# netboot - EFI network (PXE) boot options
# hdbios - BIOS disk-based boot loaders
# biosexternal - BIOS external boot loaders (USB, eSATA, etc.)
# cd - BIOS optical-disc boot loaders
# manual - use stanzas later in this configuration file
# firmware - boot EFI programs set in the firmware's NVRAM
# Note that the legacy BIOS options require firmware support, which is
# not present on all computers.
# The netboot option is experimental and relies on the ipxe.efi and
# ipxe_discover.efi program files.
# On UEFI PCs, default is internal,external,optical,manual
# On Macs, default is internal,hdbios,external,biosexternal,optical,cd,manual
#
#scanfor internal,external,optical,manual,firmware
# By default, rEFInd relies on the UEFI firmware to detect BIOS-mode boot
# devices. This sometimes doesn't detect all the available devices, though.
# For these cases, uefi_deep_legacy_scan results in a forced scan and
# modification of NVRAM variables on each boot. Adding "0", "off", or
# "false" resets to the default value. This token has no effect on Macs or
# when no BIOS-mode options are set via scanfor.
# Default is unset (or "uefi_deep_legacy_scan false")
#
#uefi_deep_legacy_scan
# Delay for the specified number of seconds before scanning disks.
# This can help some users who find that some of their disks
# (usually external or optical discs) aren't detected initially,
# but are detected after pressing Esc.
# The default is 0.
#
#scan_delay 5
# When scanning volumes for EFI boot loaders, rEFInd always looks for
# macOS's and Microsoft Windows' boot loaders in their normal locations,
# and scans the root directory and every subdirectory of the /EFI directory
# for additional boot loaders, but it doesn't recurse into these directories.
# The also_scan_dirs token adds more directories to the scan list.
# Directories are specified relative to the volume's root directory. This
# option applies to ALL the volumes that rEFInd scans UNLESS you include
# a volume name and colon before the directory name, as in "myvol:/somedir"
# to scan the somedir directory only on the filesystem named myvol. If a
# specified directory doesn't exist, it's ignored (no error condition
# results). The "+" symbol denotes appending to the list of scanned
# directories rather than overwriting that list.
# The default is to scan the "boot" and "@/boot" directories in addition
# to various hard-coded directories.
#
#also_scan_dirs boot,ESP2:EFI/linux/kernels
#also_scan_dirs boot,@/boot
#also_scan_dirs +,@/kernels
# Partitions (or whole disks, for legacy-mode boots) to omit from scans.
# For EFI-mode scans, you normally specify a volume by its label, which you
# can obtain in an EFI shell by typing "vol", from Linux by typing
# "blkid /dev/{devicename}", or by examining the disk's label in various
# OSes' file browsers. It's also possible to identify a partition by its
# unique GUID (aka its "PARTUUID" in Linux parlance). (Note that this is
# NOT the partition TYPE CODE GUID.) This identifier can be obtained via
# "blkid" in Linux or "diskutil info {partition-id}" in macOS.
# For legacy-mode scans, you can specify any subset of the boot loader
# description shown when you highlight the option in rEFInd.
# The default is "LRS_ESP".
#
#dont_scan_volumes "Recovery HD"
# Directories that should NOT be scanned for boot loaders. By default,
# rEFInd doesn't scan its own directory, the EFI/tools directory, the
# EFI/memtest directory, the EFI/memtest86 directory, or the
# com.apple.recovery.boot directory. Using the dont_scan_dirs option
# enables you to "blacklist" other directories; but be sure to use "+"
# as the first element if you want to continue blacklisting existing
# directories. You might use this token to keep EFI/boot/bootx64.efi out
# of the menu if that's a duplicate of another boot loader or to exclude
# a directory that holds drivers or non-bootloader utilities provided by
# a hardware manufacturer. If a directory is listed both here and in
# also_scan_dirs, dont_scan_dirs takes precedence. Note that this
# blacklist applies to ALL the filesystems that rEFInd scans, not just
# the ESP, unless you precede the directory name by a filesystem name or
# partition unique GUID, as in "myvol:EFI/somedir" to exclude EFI/somedir
# from the scan on the myvol volume but not on other volumes.
#
#dont_scan_dirs ESP:/EFI/boot,EFI/Dell,EFI/memtest86
# Files that should NOT be included as EFI boot loaders (on the
# first line of the display). If you're using a boot loader that
# relies on support programs or drivers that are installed alongside
# the main binary or if you want to "blacklist" certain loaders by
# name rather than location, use this option. Note that this will
# NOT prevent certain binaries from showing up in the second-row
# set of tools. Most notably, various Secure Boot and recovery
# tools are present in this list, but may appear as second-row
# items.
# The file may be specified as a bare name (e.g., "notme.efi"), as
# a complete pathname (e.g., "/EFI/somedir/notme.efi"), or as a
# complete pathname with volume (e.g., "SOMEDISK:/EFI/somedir/notme.efi"
# or 2C17D5ED-850D-4F76-BA31-47A561740082:/EFI/somedir/notme.efi").
# OS tags hidden via the Delete or '-' key in the rEFInd menu are
# added to this list, but stored in NVRAM.
# The default is shim.efi,shim-fedora.efi,shimx64.efi,PreLoader.efi,
# TextMode.efi,ebounce.efi,GraphicsConsole.efi,MokManager.efi,HashTool.efi,
# HashTool-signed.efi,bootmgr.efi,fb{arch}.efi
# (where "{arch}" is the architecture code, like "x64").
# If you want to keep these defaults but add to them, be sure to
# specify "+" as the first item in the new list; if you don't, then
# items from the default list are likely to appear.
#
#dont_scan_files shim.efi,MokManager.efi
# EFI NVRAM Boot#### variables that should NOT be presented as loaders
# when "firmware" is an option to "scanfor". The comma-separated list
# presented here contains strings that are matched against the
# description field -- if a value here is a case-insensitive substring
# of the boot option description, then it will be excluded from the
# boot list. To specify a string that includes a space, enclose it
# in quotes. Specifying "shell" will counteract the automatic
# inclusion of built-in EFI shells.
#
#dont_scan_firmware HARDDISK,shell,"Removable Device"
# Scan for Linux kernels that lack a ".efi" filename extension. This is
# useful for better integration with Linux distributions that provide
# kernels with EFI stub loaders but that don't give those kernels filenames
# that end in ".efi", particularly if the kernels are stored on a
# filesystem that the EFI can read. When set to "1", "true", or "on", this
# option causes all files in scanned directories with names that begin with
# "vmlinuz", "bzImage", or "kernel" to be included as loaders, even if they
# lack ".efi" extensions. Passing this option a "0", "false", or "off" value
# causes kernels without ".efi" extensions to NOT be scanned.
# Default is "true" -- to scan for kernels without ".efi" extensions.
#
#scan_all_linux_kernels false
# Support loaders that have been compressed with gzip.
# On x86 and x86-64 platforms, Linux kernels are self-decompressing.
# On ARM64, Linux kernel files are typically compressed with gzip,
# including the EFI stub loader. This makes them unloadable in rEFInd
# unless rEFInd itself uncompresses them. This option enables rEFInd
# to do this. This feature is unnecessary on x86 and x86-64 systems.
# Default is "false" on x86 and x86-64; "true" on ARM64.
#
#support_gzipped_loaders true
# Combine all Linux kernels in a given directory into a single entry.
# When so set, the kernel with the most recent time stamp will be launched
# by default, and its filename will appear in the entry's description.
# To launch other kernels, the user must press F2 or Insert; alternate
# kernels then appear as options on the sub-menu.
# Default is "true" -- kernels are "folded" into a single menu entry.
#
#fold_linux_kernels false
# Filename prefixes that indicate a file is a Linux kernel. Files that
# begin with any of these strings are treated as Linux kernels, if they
# are also EFI boot loaders. To include the default string, use "+"
# Default is "vmlinuz,bzImage,kernel", except on ARM64, where it is
# "vmlinuz,Image,kernel".
#
#linux_prefixes vmlinuz,bzImage,kernel
#linux_prefixes +,zImage
# Comma-delimited list of strings to treat as if they were numbers for the
# purpose of kernel version number detection. These strings are matched on a
# first-found basis; that is, if you want to treat both "linux-lts" and
# "linux" as version strings, they MUST be specified as "linux-lts,linux",
# since if you specify it the other way, both vmlinuz-linux and
# vmlinuz-linux-lts will return with "linux" as the "version string," which
# is not what you'd want. Also, if the kernel or initrd file includes both a
# specified string and digits, the "version string" includes both. For
# instance, "vmlinuz-linux-4.8" would yield a version string of "linux-4.8".
# This option is intended for Arch and other distributions that don't include
# version numbers in their kernel filenames, but may provide other uniquely
# identifying strings for multiple kernels. If this feature causes problems
# (say, if your kernel filename includes "linux" but the initrd filename
# doesn't), be sure this is set to an empty string
# (extra_kernel_version_strings "") or comment out the option to disable it.
# Default is no extra version strings
#
#extra_kernel_version_strings linux-lts,linux
# Write to systemd EFI variables (currently only LoaderDevicePartUUID) when
# launching Linux via an EFI stub loader, ELILO, or GRUB. This variable,
# when present, causes systemd to mount the ESP at /boot or /efi *IF* either
# directory is empty and nothing else is mounted there.
# Default is "false"
#
#write_systemd_vars true
# Symlinked loaders will be processed when this setting is set to true.
# These are ignored by default as they may result in undesirable outcomes.
# This token may, however, be useful on Linux setups that provide symbolic
# links in scanned locations that point to kernels in unscanned locations,
# such as some openSUSE installations.
#
#follow_symlinks true
# Set the maximum number of tags that can be displayed on the screen at
# any time. If more loaders are discovered than this value, rEFInd shows
# a subset in a scrolling list. If this value is set too high for the
# screen to handle, it's reduced to the value that the screen can manage.
# If this value is set to 0 (the default), it's adjusted to the number
# that the screen can handle.
#
#max_tags 0
# Set the default menu selection. The available arguments match the
# keyboard accelerators available within rEFInd. You may select the
# default loader using:
# - A digit between 1 and 9, in which case the Nth loader in the menu
# will be the default.
# - A "+" symbol at the start of the string, which refers to the most
# recently booted loader.
# - Any substring that corresponds to a portion of the loader's title
# (usually the OS's name, boot loader's path, or a volume or
# filesystem title).
# You may also specify multiple selectors by separating them with commas
# and enclosing the list in quotes. (The "+" option is only meaningful in
# this context.)
# If you follow the selector(s) with two times, in 24-hour format, the
# default will apply only between those times. The times are in the
# motherboard's time standard, whether that's UTC or local time, so if
# you use UTC, you'll need to adjust this from local time manually.
# Times may span midnight as in "23:30 00:30", which applies to 11:30 PM
# to 12:30 AM. You may specify multiple default_selection lines, in which
# case the last one to match takes precedence. Thus, you can set a main
# option without a time followed by one or more that include times to
# set different defaults for different times of day.
# The default behavior is to boot the previously-booted OS.
#
#default_selection 1
#default_selection Microsoft
#default_selection "+,bzImage,vmlinuz"
#default_selection Maintenance 23:30 2:00
#default_selection "Maintenance,macOS" 1:00 2:30
# Enable VMX bit and lock the CPU MSR if unlocked.
# On some Intel Apple computers, the firmware does not lock the MSR 0x3A.
# The symptom on Windows is Hyper-V not working even if the CPU
# meets the minimum requirements (HW assisted virtualization and SLAT)
# DO NOT SET THIS EXCEPT ON INTEL CPUs THAT SUPPORT VMX! See
# http://www.thomas-krenn.com/en/wiki/Activating_the_Intel_VT_Virtualization_Feature
# for more on this subject.
# The default is false: Don't try to enable and lock the MSR.
#
#enable_and_lock_vmx false
# Tell a Mac's EFI that macOS is about to be launched, even when it's not.
# This option causes some Macs to initialize their hardware differently than
# when a third-party OS is launched normally. In some cases (particularly on
# Macs with multiple video cards), using this option can cause hardware to
# work that would not otherwise work. On the other hand, using this option
# when it is not necessary can cause hardware (such as keyboards and mice) to
# become inaccessible. Therefore, you should not enable this option if your
# non-Apple OSes work correctly; enable it only if you have problems with
# some hardware devices. When needed, a value of "10.9" usually works, but
# you can experiment with other values. This feature has no effect on
# non-Apple computers.
# The default is inactive (no macOS spoofing is done).
#
#spoof_osx_version 10.9
# Set the CSR values for Apple's System Integrity Protection (SIP) feature.
# Values are two-byte (four-character) hexadecimal numbers. These values
# define which specific security features are enabled. Below are the codes
# for what the values mean. Add them up (in hexadecimal!) to set new values.
# Apple's "csrutil enable" and "csrutil disable" commands set values of 10
# and 877, respectively. (Prior to OS 11, 77 was used rather than 877; 877
# is required for OS 11, and should work for OS X 10.x, too.)
# CSR_ALLOW_UNTRUSTED_KEXTS 0x0001
# CSR_ALLOW_UNRESTRICTED_FS 0x0002
# CSR_ALLOW_TASK_FOR_PID 0x0004
# CSR_ALLOW_KERNEL_DEBUGGER 0x0008
# CSR_ALLOW_APPLE_INTERNAL 0x0010
# CSR_ALLOW_UNRESTRICTED_DTRACE 0x0020
# CSR_ALLOW_UNRESTRICTED_NVRAM 0x0040
# CSR_ALLOW_DEVICE_CONFIGURATION 0x0080
# CSR_ALLOW_ANY_RECOVERY_OS 0x0100
# CSR_ALLOW_UNAPPROVED_KEXTS 0x0200
# CSR_ALLOW_EXECUTABLE_POLICY_OVERRIDE 0x0400
# CSR_ALLOW_UNAUTHENTICATED_ROOT 0x0800
#csr_values 10,877
# Include a secondary configuration file within this one. This secondary
# file is loaded as if its options appeared at the point of the "include"
# token itself, so if you want to override a setting in the main file,
# the secondary file must be referenced AFTER the setting you want to
# override. Note that the secondary file may NOT load a tertiary file.
#
#include manual.conf
# Sample manual configuration stanzas. Each begins with the "menuentry"
# keyword followed by a name that's to appear in the menu (use quotes
# if you want the name to contain a space) and an open curly brace
# ("{"). Each entry ends with a close curly brace ("}"). Common
# keywords within each stanza include:
#
# volume - identifies the filesystem from which subsequent files
# are loaded. You can specify the volume by filesystem
# label, by partition label, or by partition GUID number
# (but NOT yet by filesystem UUID number).
# loader - identifies the boot loader file
# initrd - Specifies an initial RAM disk file
# icon - specifies a custom boot loader icon
# ostype - OS type code to determine boot options available by
# pressing Insert. Valid values are "MacOS", "Linux",
# "Windows", and "XOM". Case-sensitive.
# graphics - set to "on" to enable graphics-mode boot (useful
# mainly for MacOS) or "off" for text-mode boot.
# Default is auto-detected from loader filename.
# options - sets options to be passed to the boot loader; use
# quotes if more than one option should be passed or
# if any options use characters that might be changed
# by rEFInd parsing procedures (=, /, #, or tab).
# disabled - use alone or set to "yes" to disable this entry.
#
# Note that you can use either DOS/Windows/EFI-style backslashes (\)
# or Unix-style forward slashes (/) as directory separators. Either
# way, all file references are on the ESP from which rEFInd was
# launched.
# Use of quotes around parameters causes them to be interpreted as
# one keyword, and for parsing of special characters (spaces, =, /,
# and #) to be disabled. This is useful mainly with the "options"
# keyword. Use of quotes around parameters that specify filenames is
# permissible, but you must then use backslashes instead of slashes,
# except when you must pass a forward slash to the loader, as when
# passing a root= option to a Linux kernel.
# Below are several sample boot stanzas. All are disabled by default.
# Find one similar to what you need, copy it, remove the "disabled" line,
# and adjust the entries to suit your needs.
# A sample entry for a Linux 3.13 kernel with EFI boot stub support
# on a partition with a GUID of 904404F8-B481-440C-A1E3-11A5A954E601.
# This entry includes Linux-specific boot options and specification
# of an initial RAM disk. Note uses of Linux-style forward slashes.
# Also note that a leading slash is optional in file specifications.
menuentry Linux {
icon EFI/refind/icons/os_linux.png
volume 904404F8-B481-440C-A1E3-11A5A954E601
loader bzImage-3.3.0-rc7
initrd initrd-3.3.0.img
options "ro root=UUID=5f96cafa-e0a7-4057-b18f-fa709db5b837"
disabled
}
# Below is a more complex Linux example, specifically for Arch Linux.
# This example MUST be modified for your specific installation; if nothing
# else, the PARTUUID code must be changed for your disk. Because Arch Linux
# does not include version numbers in its kernel and initrd filenames, you
# may need to use manual boot stanzas when using fallback initrds or
# multiple kernels with Arch. This example is modified from one in the Arch
# wiki page on rEFInd (https://wiki.archlinux.org/index.php/rEFInd).
menuentry "Arch Linux" {
icon /EFI/refind/icons/os_arch.png
volume "Arch Linux"
loader /boot/vmlinuz-linux
initrd /boot/initramfs-linux.img
options "root=PARTUUID=5028fa50-0079-4c40-b240-abfaf28693ea rw add_efi_memmap"
submenuentry "Boot using fallback initramfs" {
initrd /boot/initramfs-linux-fallback.img
}
submenuentry "Boot to terminal" {
add_options "systemd.unit=multi-user.target"
}
disabled
}
# A sample entry for loading Ubuntu using its standard name for
# its GRUB 2 boot loader. Note uses of Linux-style forward slashes
menuentry Ubuntu {
loader /EFI/ubuntu/grubx64.efi
icon /EFI/refind/icons/os_linux.png
disabled
}
# A minimal ELILO entry, which probably offers nothing that
# auto-detection can't accomplish.
menuentry "ELILO" {
loader \EFI\elilo\elilo.efi
disabled
}
# Like the ELILO entry, this one offers nothing that auto-detection
# can't do; but you might use it if you want to disable auto-detection
# but still boot Windows....
menuentry "Windows 7" {
loader \EFI\Microsoft\Boot\bootmgfw.efi
disabled
}
# EFI shells are programs just like boot loaders, and can be
# launched in the same way. You can pass a shell the name of a
# script that it's to run on the "options" line. The script
# could initialize hardware and then launch an OS, or it could
# do something entirely different.
menuentry "Windows via shell script" {
icon \EFI\refind\icons\os_win.png
loader \EFI\tools\shell.efi
options "fs0:\EFI\tools\launch_windows.nsh"
disabled
}
# MacOS is normally detected and run automatically; however,
# if you want to do something unusual, a manual boot stanza may
# be the way to do it. This one does nothing very unusual, but
# it may serve as a starting point. Note that you'll almost
# certainly need to change the "volume" line for this example
# to work.
menuentry "My macOS" {
icon \EFI\refind\icons\os_mac.png
volume "macOS boot"
loader \System\Library\CoreServices\boot.efi
disabled
}
# The firmware_bootnum token takes a HEXADECIMAL value as an option
# and sets that value using the EFI's BootNext variable and then
# reboots the computer. This then causes a one-time boot of the
# computer using this EFI boot option. It can be used for various
# purposes, but one that's likely to interest some rEFInd users is
# that some Macs with HiDPI displays produce lower-resolution
# desktops when booted through rEFInd than when booted via Apple's
# own boot manager. Booting using the firmware_bootnum option
# produces the better resolution. Note that no loader option is
# used in this type of configuration.
menuentry "macOS via BootNext" {
icon /EFI/refind/icons/os_mac.png
firmware_bootnum 80
disabled
}
@@ -0,0 +1,3 @@
"Boot with standard options" "rw cryptdevice=UUID=58ae6681-79a6-418e-94e1-8ed298b19e7c:cryptroot root=/dev/vg0/arch quiet resume=UUID=33876d89-a80d-4422-b852-d308fec68d4a"
"Boot to single-user mode" "rw cryptdevice=UUID=58ae6681-79a6-418e-94e1-8ed298b19e7c:cryptroot root=/dev/vg0/arch quiet single resume=UUID=33876d89-a80d-4422-b852-d308fec68d4a"
"Boot with minimal options" "rw cryptdevice=UUID=58ae6681-79a6-418e-94e1-8ed298b19e7c:cryptroot root=/dev/vg0/arch options quiet resume=UUID=33875d89-a80d-4422-b852-d308fec68d4a"
@@ -0,0 +1 @@
alex-desktop
@@ -0,0 +1,24 @@
# Rules for Oryx web flashing and live training
KERNEL=="hidraw*", ATTRS{idVendor}=="16c0", MODE="0664", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="3297", MODE="0664", GROUP="plugdev"
# Legacy rules for live training over webusb (Not needed for firmware v21+)
# Rule for all ZSA keyboards
SUBSYSTEM=="usb", ATTR{idVendor}=="3297", GROUP="plugdev"
# Rule for the Moonlander
SUBSYSTEM=="usb", ATTR{idVendor}=="3297", ATTR{idProduct}=="1969", GROUP="plugdev"
# Rule for the Ergodox EZ
SUBSYSTEM=="usb", ATTR{idVendor}=="feed", ATTR{idProduct}=="1307", GROUP="plugdev"
# Rule for the Planck EZ
SUBSYSTEM=="usb", ATTR{idVendor}=="feed", ATTR{idProduct}=="6060", GROUP="plugdev"
# Wally Flashing rules for the Ergodox EZ
ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789B]?", ENV{ID_MM_DEVICE_IGNORE}="1"
ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789A]?", ENV{MTP_NO_PROBE}="1"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789ABCD]?", MODE:="0666"
KERNEL=="ttyACM*", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789B]?", MODE:="0666"
# Keymapp / Wally Flashing rules for the Moonlander and Planck EZ
SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="df11", MODE:="0666", SYMLINK+="stm32_dfu"
# Keymapp Flashing rules for the Voyager
SUBSYSTEMS=="usb", ATTRS{idVendor}=="3297", MODE:="0666", SYMLINK+="ignition_dfu"
@@ -0,0 +1,21 @@
[ids]
*
[main]
capslock = layer(capslayer)
rightcontrol = rightcontrol
' = G-'
[capslayer]
# arrow keys vimlike
j = down
h = left
k = up
l = right
# german special keys
a = G-q
o = G-p
u = G-y
s = G-s
e = G-5
@@ -0,0 +1,5 @@
# Configuration file for NetworkManager.
# See "man 5 NetworkManager.conf" for details.
[main]
dns=none
rc-manager=unmanaged
@@ -0,0 +1,2 @@
options nvidia NVreg_EnableGpuFirmware=0
@@ -0,0 +1,8 @@
[Theme]
ThemeDir=/usr/share/sddm/themes
Current=maya
EnableAvatars=false
[Users]
RememberLastUser=true
RememberLastSession=true
@@ -0,0 +1,12 @@
# Static information about the filesystems.
# See fstab(5) for details.
# <file system> <dir> <type> <options> <dump> <pass>
# /dev/mapper/vg0-arch
UUID=91fb6a1e-90ce-4a62-b27f-0f5e3b356a9a / ext4 rw,relatime 0 1
# /dev/nvme0n1p1
UUID=8D94-3117 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 2
# /dev/nvme0n1p2
UUID=33876d89-a80d-4422-b852-d308fec68d4a none swap defaults 0 0
@@ -0,0 +1,8 @@
[general]
import = ["~/.config/alacritty/themes/noctalia.toml"]
[font]
size = 11.0
[font.normal]
family = "MesloLGS Nerd Font"
@@ -0,0 +1,36 @@
# Generated from Noctalia's custom "Ember OLED" palette.
[colors.primary]
background = "#050403"
foreground = "#fff1e6"
[colors.cursor]
cursor = "#fff1e6"
text = "#050403"
[colors.vi_mode_cursor]
cursor = "#ffb05c"
text = "#2b1600"
[colors.selection]
background = "#3b220f"
text = "#fff1e6"
[colors.normal]
black = "#050403"
red = "#ff6b4a"
green = "#c98a45"
yellow = "#ffb05c"
blue = "#c97c45"
magenta = "#d98a5c"
cyan = "#e8a76a"
white = "#fff1e6"
[colors.bright]
black = "#6e4a31"
red = "#ff8f72"
green = "#e8ad68"
yellow = "#ffca80"
blue = "#e8a06a"
magenta = "#f0ad82"
cyan = "#ffd0a0"
white = "#ffffff"
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Track session ScreenSaver inhibitors and expose combined idle blockers as TSV.
logind exposes idle inhibitors through systemd-inhibit. Browser video wake locks
use org.freedesktop.ScreenSaver instead, which has no list API, so this monitor
observes Inhibit/UnInhibit calls and keeps their active state in a small JSON
file for the Noctalia widget and panel.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import signal
import subprocess
import sys
from pathlib import Path
from tempfile import NamedTemporaryFile
STATE_FILE = Path.home() / ".local/state/noctalia/idle-inhibitors.json"
SCREEN_SAVER = "org.freedesktop.ScreenSaver"
def write_state(inhibitors: dict[tuple[str, int], dict[str, str]]) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
payload = {"inhibitors": list(inhibitors.values())}
with NamedTemporaryFile("w", dir=STATE_FILE.parent, delete=False) as file:
json.dump(payload, file, separators=(",", ":"))
file.write("\n")
temporary_name = file.name
os.replace(temporary_name, STATE_FILE)
def read_screensaver_inhibitors() -> list[dict[str, str]]:
try:
payload = json.loads(STATE_FILE.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
inhibitors = payload.get("inhibitors", [])
return [item for item in inhibitors if isinstance(item, dict)]
def logind_inhibitors() -> list[dict[str, str]]:
result = subprocess.run(
["systemd-inhibit", "--list", "--no-pager"],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
inhibitors = []
for line in result.stdout.splitlines()[1:]:
fields = line.split(maxsplit=6)
if len(fields) < 7 or "idle" not in fields[5].split(":"):
continue
reason_and_mode = fields[6].rsplit(maxsplit=1)
inhibitors.append(
{
"source": "logind",
"who": fields[0],
"user": fields[2],
"pid": fields[3],
"comm": fields[4],
"mode": reason_and_mode[-1],
"why": reason_and_mode[0] if len(reason_and_mode) == 2 else "No reason supplied",
}
)
return inhibitors
def combined_inhibitors() -> list[dict[str, str]]:
return logind_inhibitors() + read_screensaver_inhibitors()
def print_inhibitors() -> None:
for inhibitor in combined_inhibitors():
values = [
inhibitor.get("source", "screensaver"),
inhibitor.get("who", ""),
inhibitor.get("user", ""),
inhibitor.get("pid", ""),
inhibitor.get("comm", ""),
inhibitor.get("mode", ""),
inhibitor.get("why", ""),
]
print("\t".join(value.replace("\t", " ").replace("\n", " ") for value in values))
def monitor() -> None:
active: dict[tuple[str, int], dict[str, str]] = {}
pending: dict[tuple[str, int], dict[str, str]] = {}
write_state(active)
process = subprocess.Popen(
["stdbuf", "-oL", "busctl", "--user", "--json=short", "monitor"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
def stop(_signal: int, _frame: object) -> None:
process.terminate()
write_state({})
raise SystemExit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
assert process.stdout is not None
for line in process.stdout:
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
message_type = message.get("type")
sender = message.get("sender", "")
payload = message.get("payload", {}).get("data", [])
if (
message_type == "method_call"
and message.get("interface") == SCREEN_SAVER
and message.get("member") == "Inhibit"
and len(payload) >= 2
):
pending[(sender, int(message.get("cookie", 0)))] = {
"source": "screensaver",
"who": str(payload[0]),
"user": "session",
"pid": "",
"comm": str(payload[0]),
"mode": "block",
"why": str(payload[1]),
}
elif (
message_type == "method_return"
and payload
and isinstance(payload[0], int)
):
request = (message.get("destination", ""), int(message.get("reply_cookie", 0)))
inhibitor = pending.pop(request, None)
if inhibitor is not None:
active[(request[0], int(payload[0]))] = inhibitor
write_state(active)
elif (
message_type == "method_call"
and message.get("interface") == SCREEN_SAVER
and message.get("member") == "UnInhibit"
and payload
and isinstance(payload[0], int)
):
cookie = int(payload[0])
active.pop((sender, cookie), None)
# Some clients reconnect before releasing a cookie; the cookie is
# globally unique for Noctalia, so clean up that fallback as well.
for key in [key for key in active if key[1] == cookie]:
active.pop(key, None)
write_state(active)
elif (
message_type == "signal"
and message.get("interface") == "org.freedesktop.DBus"
and message.get("member") == "NameOwnerChanged"
and len(payload) == 3
and payload[2] == ""
):
vanished = str(payload[0])
for key in [key for key in active if key[0] == vanished]:
active.pop(key, None)
for key in [key for key in pending if key[0] == vanished]:
pending.pop(key, None)
write_state(active)
parser = argparse.ArgumentParser()
parser.add_argument("--count", action="store_true", help="print combined inhibitor count")
parser.add_argument("--list", action="store_true", help="print combined inhibitors as TSV")
arguments = parser.parse_args()
if arguments.count:
print(len(combined_inhibitors()))
elif arguments.list:
print_inhibitors()
else:
monitor()
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# The opaque release window runs natively on Wayland; disable WebKit DMABUF,
# which is unreliable with this NVIDIA desktop's compositor path.
exec env WEBKIT_DISABLE_DMABUF_RENDERER=1 "$HOME/projects/pi-gui/ui/src-tauri/target/release/pi-status-ui" "$@"
Binary file not shown.
@@ -1,3 +1,3 @@
# Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher.
PI_STATUS_BRIDGE_CLIENT=/home/alex/.npm-global/bin/pi-status-bridge-client
PI_STATUS_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
@@ -78,7 +78,6 @@ hl.bind(main_mod .. " + CTRL + L", hl.dsp.exec_cmd("command -v hyprlock >/dev/nu
hl.bind(main_mod .. " + CTRL + SHIFT + ALT + S", hl.dsp.exec_cmd("systemctl suspend"))
hl.bind(main_mod .. " + CTRL + SHIFT + ALT + H", hl.dsp.exec_cmd("systemctl hibernate"))
hl.bind(main_mod .. " + SHIFT + C", hl.dsp.window.close())
hl.bind(main_mod .. " + CTRL + SHIFT + F", hl.dsp.exec_cmd("/home/alex/.local/bin/screenshot-select"))
-- Audio, media, brightness, and calculator keys.
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true })
@@ -90,10 +89,12 @@ hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind(main_mod .. " + XF86AudioRaiseVolume", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind(main_mod .. " + XF86AudioLowerVolume", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd('light -A 5'), { locked = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd('light -U 5'), { locked = true })
hl.bind(main_mod .. " + XF86MonBrightnessDown", hl.dsp.exec_cmd('light -S 0.0'), { locked = true })
hl.bind(main_mod .. " + XF86MonBrightnessUp", hl.dsp.exec_cmd('light -S 100'), { locked = true })
if machine.is_laptop then
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd('light -A 5'), { locked = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd('light -U 5'), { locked = true })
hl.bind(main_mod .. " + XF86MonBrightnessDown", hl.dsp.exec_cmd('light -S 0.0'), { locked = true })
hl.bind(main_mod .. " + XF86MonBrightnessUp", hl.dsp.exec_cmd('light -S 100'), { locked = true })
end
hl.bind("XF86Calculator", hl.dsp.exec_cmd("qalculate-qt"), { locked = true })
-- Retain i3's hjkl and arrow-key directional workflow.
@@ -145,8 +146,6 @@ hl.window_rule({ name = "thunderbird-workspace", match = { class = "^Thunderbird
float_rule("pi-status-ui", { class = "^(pi-status-ui|Pi-status-ui)$" }, 1)
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
float_rule("qalculate", { title = "^Qalculate!$" })
float_rule("psensor", { class = "^Psensor$" })
float_rule("lxrandr", { title = "^lxrandr$" })
float_rule("blueman", { class = "^Blueman-manager$" })
float_rule("gparted", { class = "^GParted$" })
float_rule("pavucontrol", { class = "^pavucontrol$" })
@@ -0,0 +1,15 @@
[Default Applications]
x-scheme-handler/mailto=userapp-Thunderbird-JBPLA3.desktop
message/rfc822=userapp-Thunderbird-JBPLA3.desktop
x-scheme-handler/mid=userapp-Thunderbird-JBPLA3.desktop
x-scheme-handler/webcal=userapp-Thunderbird-0DWKA3.desktop
text/calendar=userapp-Thunderbird-0DWKA3.desktop
application/x-extension-ics=userapp-Thunderbird-0DWKA3.desktop
x-scheme-handler/webcals=userapp-Thunderbird-0DWKA3.desktop
image/png=nomacs.desktop
[Added Associations]
x-scheme-handler/mailto=userapp-Thunderbird-JBPLA3.desktop;
x-scheme-handler/mid=userapp-Thunderbird-JBPLA3.desktop;
x-scheme-handler/webcal=userapp-Thunderbird-0DWKA3.desktop;
x-scheme-handler/webcals=userapp-Thunderbird-0DWKA3.desktop;
@@ -6,8 +6,8 @@ external_ip_enabled = false
telemetry_enabled = false
# Noctalia owns idle handling so ScreenSaver/Wayland idle inhibitors (such as
# browser video playback) prevent these actions. Power displays down after five
# minutes, lock after ten minutes, then suspend-then-hibernate after thirty.
# browser video playback) prevent these actions. The desktop profile overrides
# the five-minute screen-off action with its Hypridle OLED overlay.
[idle]
behavior_order = ["lock", "screen-off", "suspend-then-hibernate"]
pre_action_fade_seconds = 2.0
@@ -0,0 +1,4 @@
__pycache__/
.ruff_cache/
index-cache.json
index-cache-*.json
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Safely create one direct child in the configured knowledge vault."""
from __future__ import annotations
import json
import sys
from pathlib import Path
def fail(message: str) -> int:
print(message, file=sys.stderr)
return 1
def child_name(raw: str) -> str | None:
name = raw.strip()
if not name or name in {".", ".."} or "/" in name or "\\" in name or "\0" in name:
return None
if name.startswith("."):
return None
return name
def main() -> int:
if len(sys.argv) != 5:
return fail("usage: create-entry.py VAULT KIND CURRENT_DIRECTORY NAME")
raw_vault, kind, current_directory, raw_name = sys.argv[1:]
vault = Path(raw_vault).expanduser().resolve()
if not vault.is_dir():
return fail(f"vault does not exist: {vault}")
if kind not in {"note", "directory"}:
return fail("kind must be note or directory")
current = (vault / current_directory).resolve()
if current != vault and vault not in current.parents:
return fail("current directory escaped the vault")
if not current.is_dir():
return fail("current directory no longer exists")
name = child_name(raw_name)
if name is None:
return fail("use a non-hidden direct name without slashes")
if kind == "note" and not name.lower().endswith(".md"):
name += ".md"
target = (current / name).resolve()
if target.parent != current:
return fail("target escaped the current directory")
try:
if kind == "directory":
target.mkdir()
else:
title = target.stem
with target.open("x", encoding="utf-8") as handle:
handle.write(f"# {title}\n")
except FileExistsError:
return fail(f"already exists: {name}")
except OSError as error:
return fail(f"cannot create {name}: {error}")
relative = target.relative_to(vault).as_posix()
json.dump(
{
"kind": kind,
"relative": relative,
"name": name,
"absolute": str(target),
},
sys.stdout,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Focus the most recently focused Obsidian window under Hyprland."""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import quote, urlencode
OBSIDIAN_CLASS = "md.obsidian.Obsidian"
VAULT_ROOT: Path
OBSIDIAN_VAULT: str
def hyprctl(*arguments: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["hyprctl", *arguments],
check=False,
capture_output=True,
text=True,
)
def focus_expression(field: str, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'hl.dispatch(hl.dsp.focus({{ {field} = "{escaped}" }}))'
def note_uri() -> str | None:
if len(sys.argv) != 4:
print("usage: focus-obsidian.py VAULT VAULT_ID NOTE_PATH", file=sys.stderr)
return None
note = Path(sys.argv[3]).resolve()
try:
relative = note.relative_to(VAULT_ROOT).as_posix()
except ValueError as error:
print(f"note escaped the knowledge vault: {error}", file=sys.stderr)
return None
return "obsidian://open?" + urlencode(
{"vault": OBSIDIAN_VAULT, "file": relative}, quote_via=quote
)
def dispatch_uri(uri: str) -> bool:
try:
subprocess.Popen(
["obsidian", uri],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as error:
print(f"cannot open Obsidian: {error}", file=sys.stderr)
return False
return True
def main() -> int:
global VAULT_ROOT, OBSIDIAN_VAULT
if len(sys.argv) != 4:
print("usage: focus-obsidian.py VAULT VAULT_ID NOTE_PATH", file=sys.stderr)
return 2
VAULT_ROOT = Path(sys.argv[1]).expanduser().resolve()
OBSIDIAN_VAULT = sys.argv[2]
if not VAULT_ROOT.is_dir():
print(f"vault does not exist: {VAULT_ROOT}", file=sys.stderr)
return 1
if not OBSIDIAN_VAULT:
print("vault is not registered in Obsidian", file=sys.stderr)
return 1
uri = note_uri()
if uri is None or not dispatch_uri(uri):
return 1
target: dict[str, object] | None = None
for _ in range(150):
result = hyprctl("clients", "-j")
if result.returncode == 0:
try:
clients = json.loads(result.stdout)
except json.JSONDecodeError as error:
print(f"cannot parse hyprctl clients: {error}", file=sys.stderr)
clients = []
matches = [
client for client in clients if client.get("class") == OBSIDIAN_CLASS
]
if matches:
target = min(
matches, key=lambda client: client.get("focusHistoryID", 1_000_000)
)
break
time.sleep(0.1)
if target is None:
return 1
workspace = target.get("workspace")
address = target.get("address")
if not isinstance(workspace, dict) or not isinstance(address, str):
return 1
workspace_id = workspace.get("id")
if not isinstance(workspace_id, int):
return 1
workspace_expression = focus_expression("workspace", str(workspace_id))
selector = f"address:{address}"
window_expression = focus_expression("window", selector)
focused_samples = 0
for attempt in range(20):
if attempt == 0 or attempt % 5 == 0:
hyprctl("eval", workspace_expression)
hyprctl("eval", window_expression)
active = hyprctl("activewindow", "-j")
if active.returncode == 0:
try:
active_window = json.loads(active.stdout)
except json.JSONDecodeError as error:
print(f"cannot parse active Hyprland window: {error}", file=sys.stderr)
active_window = {}
focused_samples = (
focused_samples + 1 if active_window.get("address") == address else 0
)
if focused_samples >= 3:
# Re-send after focus: Electron occasionally consumes the URI
# before its renderer is ready, leaving the prior note visible.
if not dispatch_uri(uri):
return 1
time.sleep(0.35)
hyprctl("eval", window_expression)
return 0
time.sleep(0.1)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Build bounded JSON cache chunks for a Markdown vault."""
from __future__ import annotations
import json
import os
import re
import sys
import tempfile
import unicodedata
from pathlib import Path
from typing import Any
SKIP_DIRS = {
".git",
".obsidian",
".trash",
".stfolder",
"node_modules",
"dist",
"build",
}
HEADING = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
WORDS = re.compile(r"[^\W_]+", re.UNICODE)
CHUNK_BYTES = 96 * 1024
RECORD_BYTES = 48 * 1024
def visible_directory(name: str) -> bool:
return name not in SKIP_DIRS and not name.startswith(".")
def note_title(path: Path, content: str) -> str:
match = HEADING.search(content)
return match.group(1).strip() if match else path.stem
def folded(value: str) -> str:
"""Case-fold and strip accents so ASCII queries match Unicode notes."""
normalized = unicodedata.normalize("NFKD", value.casefold())
return "".join(
character for character in normalized if not unicodedata.combining(character)
)
def token_buckets(content: str) -> dict[str, dict[str, list[str]]]:
buckets: dict[str, dict[str, list[str]]] = {}
for token in sorted(set(WORDS.findall(content))):
if not token:
continue
by_length = buckets.setdefault(token[0], {})
by_length.setdefault(str(len(token)), []).append(token)
return buckets
def encoded(record: dict[str, Any]) -> str:
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
def fold_records(characters: set[str]) -> list[dict[str, Any]]:
mapping: dict[str, str] = {}
for character in characters:
variants = {character, character.lower(), character.upper(), character.title()}
for variant in variants:
for variant_character in variant:
mapping[variant_character] = folded(variant_character)
records: list[dict[str, Any]] = []
current: dict[str, str] = {}
for source, target in sorted(mapping.items()):
candidate = {**current, source: target}
record = {"kind": "fold", "map": candidate}
if current and len(encoded(record).encode("utf-8")) > RECORD_BYTES:
records.append({"kind": "fold", "map": current})
current = {source: target}
else:
current = candidate
if current:
records.append({"kind": "fold", "map": current})
return records
def directory_record(relative: str) -> dict[str, Any]:
record = {"kind": "directory", "relative": relative}
if len(encoded(record).encode("utf-8")) > RECORD_BYTES:
raise ValueError(f"directory metadata exceeded bounded size: {relative}")
return record
def note_record(title: str, relative: str) -> dict[str, Any]:
display_title = title[:256] + ("…" if len(title) > 256 else "")
record = {
"kind": "note",
"id": relative,
"title": display_title,
"relative": relative,
"titleRaw": display_title.casefold(),
"pathRaw": relative.casefold(),
"titleSearch": folded(display_title),
"pathSearch": folded(relative),
}
if len(encoded(record).encode("utf-8")) > RECORD_BYTES:
raise ValueError(f"note metadata exceeded bounded size: {relative}")
return record
def content_record(relative: str, fragment: str) -> dict[str, Any]:
content_search = folded(fragment)
return {
"kind": "content",
"id": relative,
"fragment": fragment,
"lines": fragment.split("\n"),
"contentRaw": fragment.casefold(),
"contentSearch": content_search,
"contentTokens": token_buckets(content_search),
}
def content_records(relative: str, content: str) -> list[dict[str, Any]]:
"""Split one note into bounded records without changing its original text."""
pending = [content]
records: list[dict[str, Any]] = []
while pending:
fragment = pending.pop()
record = content_record(relative, fragment)
if len(encoded(record).encode("utf-8")) <= RECORD_BYTES:
records.append(record)
continue
midpoint = max(len(fragment) // 2, 1)
if midpoint >= len(fragment):
raise ValueError(f"cannot split oversized content in {relative}")
pending.extend((fragment[midpoint:], fragment[:midpoint]))
return records
def index_records(root: Path) -> list[dict[str, Any]]:
notes: list[tuple[str, str, str]] = []
indexed_directories: set[str] = set()
characters: set[str] = set()
for current, directories, filenames in os.walk(root):
directories[:] = sorted(
name
for name in directories
if visible_directory(name) and not Path(current, name).is_symlink()
)
for directory in directories:
relative_directory = Path(current, directory).relative_to(root).as_posix()
indexed_directories.add(relative_directory)
characters.update(relative_directory)
for filename in sorted(filenames):
if not filename.lower().endswith(".md") or filename.startswith("."):
continue
path = Path(current, filename)
if path.is_symlink():
continue
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
relative = path.relative_to(root).as_posix()
title = note_title(path, content)
characters.update(title)
characters.update(relative)
characters.update(content)
notes.append((title, relative, content))
notes.sort(key=lambda note: (note[0].casefold(), note[1].casefold()))
records = fold_records(characters)
for relative in sorted(indexed_directories, key=str.casefold):
records.append(directory_record(relative))
for title, relative, content in notes:
records.append(note_record(title, relative))
records.extend(content_records(relative, content))
return records
def write_chunks(cache: Path, records: list[dict[str, Any]]) -> list[str]:
encoded_records = [encoded(record) for record in records]
chunks: list[list[str]] = []
current: list[str] = []
current_bytes = 2
for record in encoded_records:
record_bytes = len(record.encode("utf-8"))
if record_bytes > RECORD_BYTES:
raise ValueError("index record exceeded its bounded size")
separator_bytes = 1 if current else 0
if current and current_bytes + separator_bytes + record_bytes > CHUNK_BYTES:
chunks.append(current)
current = []
current_bytes = 2
separator_bytes = 0
current.append(record)
current_bytes += separator_bytes + record_bytes
if current or not chunks:
chunks.append(current)
names: list[str] = []
for index, chunk in enumerate(chunks):
chunk_name = f"index-cache-{index:03d}.json"
path = (cache.parent / chunk_name).resolve()
if path.parent != cache.parent:
raise ValueError("cache chunk escaped the plugin directory")
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=cache.parent, delete=False
) as handle:
handle.write("[" + ",".join(chunk) + "]")
temporary = Path(handle.name)
os.replace(temporary, path)
names.append(chunk_name)
active = set(names)
for stale in cache.parent.glob(f"{cache.stem}-*.json"):
if stale.name not in active:
stale.unlink(missing_ok=True)
cache.unlink(missing_ok=True)
return names
def main() -> int:
if len(sys.argv) != 3:
print("usage: index.py VAULT CACHE", file=sys.stderr)
return 2
root = Path(sys.argv[1]).expanduser().resolve()
requested_cache = Path(sys.argv[2]).expanduser().resolve()
plugin_dir = Path(__file__).resolve().parent
if (
requested_cache.parent != plugin_dir
or requested_cache.name != "index-cache.json"
):
print("cache must be index-cache.json in the plugin directory", file=sys.stderr)
return 2
if not root.is_dir():
print(f"vault does not exist: {root}", file=sys.stderr)
return 1
records = index_records(root)
chunk_names = write_chunks(plugin_dir / "index-cache.json", records)
note_count = sum(record["kind"] == "note" for record in records)
json.dump(
{"chunks": chunk_names, "notes": note_count},
sys.stdout,
separators=(",", ":"),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Safely move or delete entries inside the knowledge vault."""
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
VAULT: Path
def fail(message: str) -> int:
print(message, file=sys.stderr)
return 1
def vault_entry(relative: str) -> Path | None:
if not relative or relative.startswith("/") or "\0" in relative:
return None
candidate = VAULT / relative
parent = candidate.parent.resolve()
if parent != VAULT and VAULT not in parent.parents:
return None
return parent / candidate.name
def move_entry(source_relative: str, destination_relative: str) -> int:
source = vault_entry(source_relative)
destination_directory = (VAULT / destination_relative).resolve()
if source is None or not (source.exists() or source.is_symlink()):
return fail("selected entry no longer exists")
if destination_directory != VAULT and VAULT not in destination_directory.parents:
return fail("destination escaped the vault")
if not destination_directory.is_dir():
return fail("destination directory no longer exists")
if source.is_dir() and (
destination_directory == source or source in destination_directory.parents
):
return fail("cannot move a directory into itself")
destination = destination_directory / source.name
if destination.exists() or destination.is_symlink():
return fail(f"already exists: {destination.name}")
try:
source.rename(destination)
except OSError as error:
return fail(f"cannot move {source.name}: {error}")
json.dump(
{
"action": "move",
"relative": destination.relative_to(VAULT).as_posix(),
"kind": "directory" if destination.is_dir() else "note",
},
sys.stdout,
)
return 0
def delete_entry(source_relative: str) -> int:
source = vault_entry(source_relative)
if source is None or not (source.exists() or source.is_symlink()):
return fail("selected entry no longer exists")
try:
if source.is_symlink():
source.unlink()
elif source.is_dir():
shutil.rmtree(source)
else:
source.unlink()
except OSError as error:
return fail(f"cannot delete {source.name}: {error}")
json.dump({"action": "delete", "relative": source_relative}, sys.stdout)
return 0
def main() -> int:
global VAULT
if len(sys.argv) < 4:
return fail(
"usage: manage-entry.py VAULT ACTION SOURCE [DESTINATION_DIRECTORY]"
)
VAULT = Path(sys.argv[1]).expanduser().resolve()
if not VAULT.is_dir():
return fail(f"vault does not exist: {VAULT}")
action, source = sys.argv[2:4]
if action == "move" and len(sys.argv) == 5:
return move_entry(source, sys.argv[4])
if action == "delete" and len(sys.argv) == 4:
return delete_entry(source)
return fail("invalid file operation")
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
id = "alex/knowledge-lookup"
name = "Knowledge Lookup"
version = "1.0.0"
plugin_api = 24
author = "alex"
license = "MIT"
dependencies = []
tags = ["productivity", "knowledge"]
icon = "notes"
description = "Search and preview a local Obsidian knowledge vault from the bar."
[[setting]]
key = "vault_path"
type = "folder"
label_key = "Knowledge vault directory"
description_key = "Directory containing the Markdown vault to browse"
default = ""
[[widget]]
id = "bar"
entry = "widget.luau"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 1100
height = 720
placement = "floating"
position = "center"
keyboard_focus = "exclusive"
dismiss_on_outside_click = true
capture_keys = [
"j",
"k",
"h",
"l",
"ctrl+U0063",
"ctrl+U0061",
"ctrl+U006e",
"g",
"shift+U0047",
"q",
"o",
"r",
"x",
"p",
"d",
"y",
"n",
"slash",
"enter",
]
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Validate a vault path and resolve its registered Obsidian vault ID."""
from __future__ import annotations
import json
import sys
from pathlib import Path
OBSIDIAN_CONFIG = Path.home() / ".config" / "obsidian" / "obsidian.json"
def fail(message: str) -> int:
json.dump({"ok": False, "error": message}, sys.stdout)
return 1
def main() -> int:
if len(sys.argv) != 2:
return fail("No vault directory is configured")
raw_path = sys.argv[1].strip()
if not raw_path:
return fail("No vault directory is configured")
vault = Path(raw_path).expanduser().resolve()
if not vault.is_dir():
return fail(f"Vault directory was not found: {vault}")
vault_id: str | None = None
config_error: str | None = None
try:
config = json.loads(OBSIDIAN_CONFIG.read_text(encoding="utf-8"))
for registered_id, metadata in config.get("vaults", {}).items():
registered_path = Path(str(metadata.get("path", ""))).expanduser().resolve()
if registered_path == vault:
vault_id = str(registered_id)
break
except FileNotFoundError:
config_error = "Obsidian has not registered any vaults yet"
except (OSError, json.JSONDecodeError, TypeError) as error:
config_error = f"Cannot read Obsidian vault registration: {error}"
response = {
"ok": True,
"path": str(vault),
"name": vault.name,
"vaultId": vault_id,
"obsidianError": config_error
or (
None
if vault_id
else "This directory is not registered as an Obsidian vault"
),
}
json.dump(response, sys.stdout)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,17 @@
--!strict
local PANEL_ID = "alex/knowledge-lookup:panel"
function update()
barWidget.setGlyph("book-2")
barWidget.setText("")
barWidget.setTooltip("Knowledge lookup · Super+N")
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
function onRightClick()
noctalia.runAsync({ "obsidian" })
end
@@ -0,0 +1,102 @@
local request_in_flight = false
local function shell_quote(value)
return "'" .. string.gsub(value, "'", "'\\\"'\\\"'") .. "'"
end
local function ui_binary()
local configured = noctalia.getenv("PI_STATUS_UI_BINARY")
if configured ~= nil and configured ~= "" then return configured end
return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui"
end
local function bridge_client_binary()
local configured = noctalia.getenv("PI_STATUS_BRIDGE_CLIENT")
if configured ~= nil and configured ~= "" then return shell_quote(configured) end
return "pi-status-bridge-client"
end
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
return runtime .. "/pi-status-bridge/bridge.sock"
end
local bridge_start_in_flight = false
local pending_ui_open = false
local function open_ui()
local binary = shell_quote(ui_binary())
local command = "if test -x " .. binary .. "; then setsid -f env TMPDIR=/tmp "
.. binary .. " --toggle >/tmp/pi-status-ui.log 2>&1; else exit 1; fi"
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi UI unavailable")
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
end
end)
end
local function start_bridge(open_after_start)
if open_after_start then pending_ui_open = true end
if bridge_start_in_flight then return end
bridge_start_in_flight = true
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi starting")
barWidget.setTooltip("Bridge is starting")
noctalia.runAsync("systemctl --user start pi-status-bridge.service", function(result)
bridge_start_in_flight = false
if result.exitCode ~= 0 then
pending_ui_open = false
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi offline")
barWidget.setTooltip("Could not start pi-status-bridge.service")
return
end
if pending_ui_open then pending_ui_open = false; open_ui() end
end)
end
local function json_count(payload, key)
local value = string.match(payload, '"' .. key .. '":(%d+)')
return tonumber(value) or 0
end
local function update_summary(payload)
local open = json_count(payload, "openCount")
local working = json_count(payload, "workingCount")
local attention = json_count(payload, "attentionCount")
local recovering = json_count(payload, "recoveringCount")
local errors = json_count(payload, "errorCount")
local glyph = errors > 0 and "alert-triangle" or attention > 0 and "circle-alert" or working > 0 and "terminal-2" or recovering > 0 and "refresh-cw" or "terminal"
local text = errors > 0 and "Pi needs attention" or working > 0 and "Pi working" or recovering > 0 and "Pi recovering" or "Pi ready"
local detail = open .. " open · " .. working .. " working · " .. attention .. " attention · " .. recovering .. " recovering · " .. errors .. " errors"
barWidget.setGlyph(glyph)
barWidget.setText(text)
barWidget.setTooltip(detail .. "; click to open Pi Status UI")
end
function update()
noctalia.setUpdateInterval(2000)
local socket = socket_path()
if socket == nil then start_bridge(false); return end
if request_in_flight then return end
request_in_flight = true
local command = bridge_client_binary() .. " --socket \"" .. socket .. "\" request '{\"op\":\"get_workspace_summary\"}'"
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
start_bridge(false)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi reconnecting")
barWidget.setTooltip("Refreshing Pi workspace summary")
return
end
update_summary(result.stdout)
end)
end
function onClick()
start_bridge(true)
end
@@ -0,0 +1,10 @@
id = "alex/pi-status-bridge"
name = "Pi Status Bridge"
version = "0.2.0"
plugin_api = 3
author = "alex"
description = "Compact Pi status and launcher for the standalone Pi Status UI."
[[widget]]
id = "bridge"
entry = "bridge.luau"
@@ -0,0 +1,205 @@
-- Show instantaneous battery power above an ETA based on a time-weighted,
-- five-minute rolling average. Energy values are normalized to µWh so that
-- both energy_* and charge_* battery drivers can produce an estimate.
local command = [[
bat=/sys/class/power_supply/BAT0
number() {
if [ -r "$1" ]; then
value=$(cat "$1" 2>/dev/null)
case "$value" in
''|*[!0-9]*) ;;
*) printf '%s' "$value"; return 0 ;;
esac
fi
return 1
}
status=$(cat "$bat/status" 2>/dev/null) || exit 1
power=$(number "$bat/power_now")
voltage=$(number "$bat/voltage_now")
if [ -z "$power" ]; then
current=$(number "$bat/current_now")
if [ -n "$current" ] && [ -n "$voltage" ]; then
power=$(awk -v current="$current" -v voltage="$voltage" \
'BEGIN { printf "%.0f", current * voltage / 1000000 }')
fi
fi
energy_now=$(number "$bat/energy_now")
energy_full=$(number "$bat/energy_full")
if [ -z "$energy_now" ] || [ -z "$energy_full" ]; then
charge_now=$(number "$bat/charge_now")
charge_full=$(number "$bat/charge_full")
if [ -n "$charge_now" ] && [ -n "$charge_full" ] && [ -n "$voltage" ]; then
energy_now=$(awk -v charge="$charge_now" -v voltage="$voltage" \
'BEGIN { printf "%.0f", charge * voltage / 1000000 }')
energy_full=$(awk -v charge="$charge_full" -v voltage="$voltage" \
'BEGIN { printf "%.0f", charge * voltage / 1000000 }')
fi
fi
printf '%s\t%s\t%s\t%s\n' "$status" "$power" "$energy_now" "$energy_full"
]]
local sample_window_seconds = 5 * 60
local power_samples = {}
local sampled_state = nil
local request_in_flight = false
local function renderLines(top, bottom)
barWidget.render(ui.column({ gap = 0, align = "center" }, {
ui.label({ text = top, fontSize = 10 }),
ui.label({ text = bottom, fontSize = 9 }),
}))
end
local function resetSamples(state)
if sampled_state ~= state then
power_samples = {}
sampled_state = state
end
end
local function addPowerSample(state, power, now)
resetSamples(state)
local latest = power_samples[#power_samples]
if latest ~= nil and latest.time == now then
latest.power = power
else
table.insert(power_samples, { time = now, power = power })
end
local cutoff = now - sample_window_seconds
while #power_samples > 1 and power_samples[2].time <= cutoff do
table.remove(power_samples, 1)
end
end
local function rollingAverage(now)
if #power_samples == 0 then
return nil, 0
end
if #power_samples == 1 then
return power_samples[1].power, 0
end
local cutoff = now - sample_window_seconds
local integral = 0
local duration = 0
for index = 2, #power_samples do
local earlier = power_samples[index - 1]
local later = power_samples[index]
local interval = later.time - earlier.time
local start_time = math.max(earlier.time, cutoff)
local end_time = math.min(later.time, now)
if interval > 0 and end_time > start_time then
local start_fraction = (start_time - earlier.time) / interval
local end_fraction = (end_time - earlier.time) / interval
local start_power = earlier.power + (later.power - earlier.power) * start_fraction
local end_power = earlier.power + (later.power - earlier.power) * end_fraction
local elapsed = end_time - start_time
integral = integral + (start_power + end_power) * elapsed / 2
duration = duration + elapsed
end
end
if duration == 0 then
return power_samples[#power_samples].power, 0
end
return integral / duration, duration
end
local function formatDuration(hours)
local minutes = math.max(1, math.floor(hours * 60 + 0.5))
local whole_hours = math.floor(minutes / 60)
local remaining_minutes = minutes % 60
if whole_hours > 0 then
return string.format("%dh %02dm", whole_hours, remaining_minutes)
end
return string.format("%dm", remaining_minutes)
end
local function formatSampleDuration(seconds)
local minutes = math.floor(seconds / 60)
return string.format("%dm %02ds", minutes, seconds % 60)
end
function update()
noctalia.setUpdateInterval(5000)
if request_in_flight then
return
end
request_in_flight = true
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
renderLines("—", "Unavailable")
barWidget.setTooltip("Battery rate and remaining-time estimate unavailable")
return
end
local state, power, energy_now, energy_full = string.match(
result.stdout,
"^([^\t]+)\t([^\t]*)\t([^\t]*)\t([^\r\n]*)"
)
power = tonumber(power)
energy_now = tonumber(energy_now)
energy_full = tonumber(energy_full)
if state ~= "Discharging" and state ~= "Charging" then
resetSamples(state)
renderLines("—", state)
barWidget.setTooltip("Battery: " .. state)
return
end
if power == nil or power <= 0 then
renderLines("—", "Calculating…")
barWidget.setTooltip("Battery: " .. state .. " · waiting for a power reading")
return
end
local now = os.time()
addPowerSample(state, power, now)
local average_power, sample_duration = rollingAverage(now)
local label = "Calculating…"
if average_power ~= nil and average_power > 0 and energy_now ~= nil and energy_full ~= nil then
local energy_remaining = energy_now
if state == "Charging" then
energy_remaining = energy_full - energy_now
end
if energy_remaining >= 0 then
local suffix = state == "Charging" and " to full" or " left"
label = formatDuration(energy_remaining / average_power) .. suffix
end
end
local direction = state == "Charging" and "↑" or "↓"
renderLines(string.format("%s %.1f W", direction, power / 1000000), label)
barWidget.setTooltip(
string.format(
"Battery: %s · %.1f W now · %.1f W rolling average (%s sampled of 5m)",
state,
power / 1000000,
average_power / 1000000,
formatSampleDuration(sample_duration)
)
)
end)
end
@@ -0,0 +1,87 @@
-- Sum each logical CPU's interval utilization. Unlike the built-in average,
-- this follows Unix tools by allowing the value to exceed 100%.
local command = [[awk '
/^cpu[0-9]+ / {
total = 0
for (i = 2; i <= NF; i++) total += $i
idle = $5 + $6
printf "%s %.0f %.0f\n", $1, total, idle
}' /proc/stat]]
local previous = {}
local request_in_flight = false
local function colorForUsage(percent)
if percent >= 85 then
return "#ff453a" -- red
end
if percent >= 70 then
return "#ff8800" -- orange
end
if percent >= 50 then
return "#ffd60a" -- yellow
end
return "#ffffff" -- white
end
local function renderStatus(text, color)
barWidget.render(ui.row({ gap = 4, align = "center" }, {
ui.glyph({ name = "cpu", size = 14, color = color }),
ui.label({ text = text, color = color }),
}))
end
function update()
noctalia.setUpdateInterval(2000)
if request_in_flight then
return
end
request_in_flight = true
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
renderStatus("CPU —", "#fff1e6")
barWidget.setTooltip("Unable to read CPU utilization")
return
end
local utilization = 0
local sampled = 0
for line in string.gmatch(result.stdout, "[^\r\n]+") do
local name, total, idle = string.match(line, "^(cpu%d+) (%d+) (%d+)$")
total = tonumber(total)
idle = tonumber(idle)
local prior = previous[name]
if prior ~= nil then
local total_delta = total - prior.total
local idle_delta = idle - prior.idle
if total_delta > 0 then
utilization = utilization + (100 * (total_delta - idle_delta) / total_delta)
sampled = sampled + 1
end
end
previous[name] = { total = total, idle = idle }
end
if sampled > 0 then
-- Keep the aggregate display, but base its heat color on average
-- utilization per logical CPU so multicore systems do not stay red.
local per_cpu_utilization = utilization / sampled
renderStatus(string.format("%.0f%%", utilization), colorForUsage(per_cpu_utilization))
barWidget.setTooltip(
string.format(
"Aggregate CPU utilization (100%% per logical CPU) · %.0f%% average per CPU",
per_cpu_utilization
)
)
else
renderStatus("—", "#fff1e6")
barWidget.setTooltip("Sampling CPU utilization…")
end
end)
end
@@ -0,0 +1,31 @@
-- Query EnvyControl once when this widget starts; GPU mode changes require a restart.
local command = "envycontrol --query"
local labels = {
integrated = "iGPU",
dedicated = "dGPU",
nvidia = "dGPU",
hybrid = "hybrid",
}
barWidget.setGlyph("device-desktop")
barWidget.setText("GPU…")
barWidget.setTooltip("Reading EnvyControl GPU mode…")
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
barWidget.setText("GPU?")
barWidget.setTooltip("Unable to read EnvyControl GPU mode")
return
end
local mode = string.lower(string.match(result.stdout, "([%a_-]+)") or "")
local label = labels[mode]
if label == nil then
barWidget.setText("GPU?")
barWidget.setTooltip("Unknown EnvyControl GPU mode: " .. mode)
return
end
barWidget.setText(label)
barWidget.setTooltip("EnvyControl GPU mode: " .. mode)
end)
@@ -0,0 +1,85 @@
-- Click-through details for both logind idle inhibitors and session
-- ScreenSaver requests such as browser video wake locks.
local command = (noctalia.getenv("HOME") or "") .. "/.local/bin/idle-inhibitor-monitor --list"
local inhibitors = {}
local status = "Loading idle inhibitors…"
local request_in_flight = false
local function renderPanel()
local children = {
ui.column({ gap = 2 }, {
ui.label({ text = "Idle inhibitors", fontSize = 18, fontWeight = "bold" }),
ui.label({ text = status, color = "on_surface_variant" }),
}),
ui.button({ text = "Refresh", onClick = "onRefresh" }),
}
if #inhibitors == 0 then
table.insert(children, ui.label({ text = "No process currently blocks automatic idle." }))
else
for _, inhibitor in ipairs(inhibitors) do
local title = inhibitor.comm ~= "" and inhibitor.comm or inhibitor.who
table.insert(children, ui.column({ gap = 2 }, {
ui.label({ text = string.format("%s · %s", inhibitor.source, title), fontWeight = "bold" }),
ui.label({ text = inhibitor.why ~= "" and inhibitor.why or "No reason supplied", maxLines = 2 }),
ui.label({
text = string.format("%s · %s · PID %s", inhibitor.mode, inhibitor.user, inhibitor.pid),
color = "on_surface_variant",
fontSize = 11,
}),
}))
end
end
panel.render(ui.scroll({ padding = 20, gap = 12 }, children))
end
local function refreshInhibitors()
if request_in_flight then return end
request_in_flight = true
status = "Loading idle inhibitors…"
renderPanel()
noctalia.runAsync(command, function(result)
request_in_flight = false
inhibitors = {}
if result.exitCode ~= 0 then
status = "Unable to inspect logind inhibitors."
renderPanel()
return
end
for line in string.gmatch(result.stdout, "[^\r\n]+") do
local source, who, user, pid, comm, mode, why = string.match(
line,
"^([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t(.*)$"
)
if source ~= nil then
table.insert(inhibitors, {
source = source,
who = who,
user = user,
pid = pid,
comm = comm,
mode = mode,
why = why,
})
end
end
status = #inhibitors == 0
and "Automatic idle is eligible."
or string.format("%d active idle inhibitor%s.", #inhibitors, #inhibitors == 1 and "" or "s")
renderPanel()
end)
end
function onOpen()
refreshInhibitors()
end
function onRefresh()
refreshInhibitors()
end
@@ -0,0 +1,39 @@
-- Show whether any process blocks automatic idle. The monitor combines logind
-- idle inhibitors with session ScreenSaver requests such as browser video wake locks.
local command = (noctalia.getenv("HOME") or "") .. "/.local/bin/idle-inhibitor-monitor --count"
local request_in_flight = false
function update()
noctalia.setUpdateInterval(5000)
if request_in_flight then return end
request_in_flight = true
noctalia.runAsync(command, function(result)
request_in_flight = false
local count = tonumber(result.stdout)
if result.exitCode ~= 0 or count == nil then
barWidget.setGlyph("circle-help")
barWidget.setText("Idle ?")
barWidget.setTooltip("Unable to inspect idle inhibitors")
return
end
if count > 0 then
barWidget.setGlyph("moon-off")
barWidget.setText(string.format("Idle %d", count))
barWidget.setTooltip(
string.format("%d idle inhibitor%s active · click for details", count, count == 1 and "" or "s")
)
else
barWidget.setGlyph("moon")
barWidget.setText("Idle")
barWidget.setTooltip("No active idle inhibitors · click for details")
end
end)
end
function onClick()
noctalia.runAsync("noctalia msg panel-toggle alex/system-status:inhibitors-panel")
end
@@ -0,0 +1,82 @@
-- Read the tiny kernel meminfo pseudo-file every five seconds. This avoids a
-- resident helper process and reuses Noctalia's lightweight widget lifecycle.
local command = [[awk '
$1 == "MemTotal:" { total = $2 }
$1 == "MemAvailable:" { available = $2 }
$1 == "SwapTotal:" { swap_total = $2 }
$1 == "SwapFree:" { swap_free = $2 }
END {
if (!total || !available) exit 1
used = total - available
memory_used = used * 100 / total
if (swap_total > 0) {
swap_used = (swap_total - swap_free) * 100 / swap_total
printf "%.1f/%.1f GiB S %.0f%%\t%.0f\t%.0f\n", used / 1048576, total / 1048576, swap_used, memory_used, swap_used
} else {
printf "%.1f/%.1f GiB S —\t%.0f\t0\n", used / 1048576, total / 1048576, memory_used
}
}' /proc/meminfo]]
local request_in_flight = false
local function colorForUsage(percent)
if percent >= 85 then
return "#ff453a" -- red
end
if percent >= 70 then
return "#ff8800" -- orange
end
if percent >= 50 then
return "#ffd60a" -- yellow
end
return "#ffffff" -- white
end
local function renderStatus(text, color)
barWidget.render(ui.row({ gap = 4, align = "center" }, {
ui.glyph({ name = "memory", size = 14, color = color }),
ui.label({ text = text, color = color }),
}))
end
function update()
noctalia.setUpdateInterval(5000)
if request_in_flight then
return
end
request_in_flight = true
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
renderStatus("RAM —", "#fff1e6")
barWidget.setTooltip("Unable to read /proc/meminfo")
return
end
local text, memory_percent, swap_percent = string.match(
result.stdout,
"^([^\t]+)\t([%d.]+)\t([%d.]+)"
)
memory_percent = tonumber(memory_percent)
swap_percent = tonumber(swap_percent)
if text == nil or memory_percent == nil or swap_percent == nil then
renderStatus("RAM —", "#fff1e6")
barWidget.setTooltip("Unable to parse memory utilization")
return
end
local highest_usage = math.max(memory_percent, swap_percent)
renderStatus(text, colorForUsage(highest_usage))
barWidget.setTooltip(
string.format(
"RAM used / total · %.0f%% RAM · %.0f%% swap",
memory_percent,
swap_percent
)
)
end)
end
@@ -0,0 +1,119 @@
-- Compact, two-line network throughput widget. It samples the interface used
-- by the default route so virtual and physical interfaces are not double-counted.
local command = [[
iface=$(ip route show default 2>/dev/null | awk '$1 == "default" { for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if [ -z "$iface" ]; then
for path in /sys/class/net/*; do
candidate=${path##*/}
[ "$candidate" = "lo" ] && continue
[ "$(cat "$path/carrier" 2>/dev/null)" = "1" ] || continue
iface=$candidate
break
done
fi
[ -n "$iface" ] || exit 1
awk -v iface="$iface" '$1 ~ ("^" iface ":") { sub(":", "", $1); print iface "\t" $2 "\t" $10; exit }' /proc/net/dev
]]
local previous = nil
local request_in_flight = false
-- 82px accommodates the longest fixed field ("1023 KiB/s") at 10px while
-- removing the excess side padding from the original 112px capsule.
local label_width = 82
local function formatRate(bytes_per_second)
local units = { "B/s", "KiB/s", "MiB/s", "GiB/s" }
local value = bytes_per_second
local unit_index = 1
while value >= 1024 and unit_index < #units do
value = value / 1024
unit_index = unit_index + 1
end
if unit_index == 1 then
return string.format("%d %s", math.floor(value + 0.5), units[unit_index])
end
if value >= 100 then
return string.format("%.0f %s", value, units[unit_index])
end
return string.format("%.1f %s", value, units[unit_index])
end
local function renderLines(download, upload)
-- The explicit minimum width and fixed ten-character value field keep the
-- capsule width stable while inheriting the bar font used by other modules.
barWidget.render(ui.column({ gap = 0, align = "center", minWidth = label_width }, {
ui.label({
text = string.format("↓ %10s", download),
maxLines = 1,
fontSize = 10,
}),
ui.label({
text = string.format("↑ %10s", upload),
maxLines = 1,
fontSize = 10,
}),
}))
end
function update()
noctalia.setUpdateInterval(2000)
if request_in_flight then
return
end
request_in_flight = true
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
previous = nil
renderLines("—", "—")
barWidget.setTooltip("Network throughput unavailable: no active interface")
return
end
local iface, received, sent = string.match(
result.stdout,
"^([^\t]+)\t(%d+)\t(%d+)"
)
received = tonumber(received)
sent = tonumber(sent)
if iface == nil or received == nil or sent == nil then
previous = nil
renderLines("—", "—")
barWidget.setTooltip("Network throughput unavailable")
return
end
local now = os.time()
if previous == nil or previous.iface ~= iface or now <= previous.time then
previous = { iface = iface, received = received, sent = sent, time = now }
renderLines("Sampling…", "Sampling…")
barWidget.setTooltip("Sampling network throughput on " .. iface)
return
end
local elapsed = now - previous.time
local download = math.max(0, (received - previous.received) / elapsed)
local upload = math.max(0, (sent - previous.sent) / elapsed)
previous = { iface = iface, received = received, sent = sent, time = now }
renderLines(formatRate(download), formatRate(upload))
barWidget.setTooltip(
string.format(
"%s · Download %s · Upload %s",
iface,
formatRate(download),
formatRate(upload)
)
)
end)
end
@@ -0,0 +1,34 @@
id = "alex/system-status"
name = "System Status"
version = "1.0.0"
plugin_api = 3
author = "alex"
description = "Low-overhead system and network status indicators for the bar."
[[widget]]
id = "cpu"
entry = "cpu.luau"
[[widget]]
id = "memory"
entry = "memory.luau"
[[widget]]
id = "battery-rate"
entry = "battery-rate.luau"
[[widget]]
id = "network-rate"
entry = "network-rate.luau"
[[widget]]
id = "gpu-mode"
entry = "gpu-mode.luau"
[[widget]]
id = "inhibitors"
entry = "inhibitors.luau"
[[panel]]
id = "inhibitors-panel"
entry = "inhibitors-panel.luau"
@@ -4,7 +4,7 @@ After=graphical-session.target
[Service]
Type=simple
Environment=PATH=/home/alex/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
Environment=PATH=/home/alex/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
Restart=on-failure
RestartSec=3
@@ -0,0 +1,9 @@
XDG_DESKTOP_DIR="$HOME/desktop"
XDG_DOWNLOAD_DIR="$HOME/downloads"
XDG_PUBLICSHARE_DIR="$HOME"
XDG_TEMPLATES_DIR="$HOME"
XDG_DOCUMENTS_DIR="$HOME/cloud/documents"
XDG_MUSIC_DIR="$HOME"
XDG_PICTURES_DIR="$HOME/cloud/photos"
XDG_VIDEOS_DIR="$HOME"
@@ -1,17 +1,21 @@
# Desktop-specific shell layout and hardware telemetry.
# Pi Status Bridge is installed locally from ~/projects/pi-status-bridge.
# Pi Status Bridge is installed from ~/projects/pi-gui on both hosts.
[plugins]
enabled = ["alex/system-status", "alex/pi-status-bridge", "alex/knowledge-lookup"]
[widget.pi-status]
type = "alex/pi-status-bridge:bridge"
[shell.screenshot]
confirm_region = true
directory = "/home/alex/downloads"
[bar.main]
position = "bottom"
layer = "top"
scale = 0.95
capsule_radius = 5
center = ["group:g3", "notifications"]
center = ["pi-status", "knowledge-lookup", "group:g3", "notifications", "screenshot"]
end = ["group:g2", "group:g6", "group:g1", "session"]
font_weight = 400
margin_ends = 0
@@ -19,11 +23,11 @@ padding = 0
panel_overlap = 3
radius = 0
shadow = false
# Keep the desktop status bar visible and reserve its screen edge.
auto_hide = false
# Auto-hide while reserving the screen edge; reveal the bar at its edge.
auto_hide = true
smart_auto_hide = false
reserve_space = true
start = ["tray", "group:g4", "privacy"]
start = ["tray", "group:g4"]
thickness = 39
widget_spacing = 10
@@ -56,7 +60,7 @@ radius = 5.0
enabled = true
fill = "surface_variant"
id = "g4"
members = ["pi-status", "cpu", "temp", "ram", "gpu", "gpu_temp", "disk"]
members = ["cpu", "temp", "ram", "gpu", "gpu_temp", "disk"]
opacity = 1.0
padding = 6.0
@@ -89,3 +93,8 @@ width = 880
[system.monitor]
# GPU polling is disabled by Noctalia by default; enable it for the RTX 3090 widgets.
gpu_poll_seconds = 2.0
# The desktop's Hypridle listener provides a five-minute input-dismissable black
# OLED overlay. Keep Noctalia from issuing a competing DPMS screen-off action.
[idle.behavior.screen-off]
enabled = false
@@ -1,6 +1,7 @@
-- Laptop profile: leave monitor selection to Hyprland's hotplug support.
-- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack.
return {
is_laptop = true,
input = {
touchpad = {
-- Standard laptop behavior: one/two/three-finger taps map to