1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-01 19:18:28 -04:00

freebsd: add initial support for running on FreeBSD, excluding Bluez,

ppd, and some things. Add a simple wpa_supplicant backend
This commit is contained in:
bbedward
2026-07-19 10:31:48 -04:00
parent 069df80b22
commit 90291bd627
46 changed files with 3168 additions and 209 deletions
+28
View File
@@ -58,3 +58,31 @@ jobs:
- name: Build dankinstall
run: go build -v ./cmd/dankinstall
build-freebsd:
runs-on: ubuntu-latest
defaults:
run:
working-directory: core
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: ./core/go.mod
- name: Build all packages
env:
GOOS: freebsd
CGO_ENABLED: 0
run: go build -v ./...
- name: Build dms (distropkg, embedded shell)
run: |
make sync-shell
GOOS=freebsd CGO_ENABLED=0 go build -v -tags 'distro_binary withshell' ./cmd/dms
+63 -36
View File
@@ -27,7 +27,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
include:
- { goos: linux, arch: amd64 }
- { goos: linux, arch: arm64 }
- { goos: freebsd, arch: amd64 }
- { goos: freebsd, arch: arm64 }
defaults:
run:
@@ -35,6 +39,8 @@ jobs:
env:
TAG: ${{ inputs.tag }}
# linux assets keep their historical arch-only names
ASSET: ${{ matrix.goos == 'linux' && matrix.arch || format('{0}-{1}', matrix.goos, matrix.arch) }}
steps:
- name: Checkout
@@ -50,6 +56,7 @@ jobs:
go-version-file: ./core/go.mod
- name: Format check
if: matrix.goos == 'linux'
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
@@ -58,41 +65,43 @@ jobs:
fi
- name: Run tests
if: matrix.goos == 'linux'
run: go test -v ./...
- name: Build dankinstall (${{ matrix.arch }})
- name: Build dankinstall (${{ env.ASSET }})
if: matrix.goos == 'linux'
env:
GOOS: linux
GOOS: ${{ matrix.goos }}
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dankinstall
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dankinstall-${{ matrix.arch }}
-o ../../dankinstall-${{ env.ASSET }}
cd ../..
gzip -9 -k dankinstall-${{ matrix.arch }}
sha256sum dankinstall-${{ matrix.arch }}.gz > dankinstall-${{ matrix.arch }}.gz.sha256
gzip -9 -k dankinstall-${{ env.ASSET }}
sha256sum dankinstall-${{ env.ASSET }}.gz > dankinstall-${{ env.ASSET }}.gz.sha256
- name: Sync embedded shell
run: make sync-shell
- name: Build dms (${{ matrix.arch }})
- name: Build dms (${{ env.ASSET }})
env:
GOOS: linux
GOOS: ${{ matrix.goos }}
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags withshell -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-${{ matrix.arch }}
-o ../../dms-${{ env.ASSET }}
cd ../..
gzip -9 -k dms-${{ matrix.arch }}
sha256sum dms-${{ matrix.arch }}.gz > dms-${{ matrix.arch }}.gz.sha256
gzip -9 -k dms-${{ env.ASSET }}
sha256sum dms-${{ env.ASSET }}.gz > dms-${{ env.ASSET }}.gz.sha256
- name: Generate shell completions
if: matrix.arch == 'amd64'
if: matrix.goos == 'linux' && matrix.arch == 'amd64'
run: |
set -eux
chmod +x dms-amd64
@@ -100,46 +109,58 @@ jobs:
./dms-amd64 completion fish > completion.fish
./dms-amd64 completion zsh > completion.zsh
- name: Build dms-distropkg (${{ matrix.arch }})
- name: Build dms-distropkg (${{ env.ASSET }})
env:
GOOS: linux
GOOS: ${{ matrix.goos }}
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags 'distro_binary withshell' -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-distropkg-${{ matrix.arch }}
-o ../../dms-distropkg-${{ env.ASSET }}
cd ../..
gzip -9 -k dms-distropkg-${{ matrix.arch }}
sha256sum dms-distropkg-${{ matrix.arch }}.gz > dms-distropkg-${{ matrix.arch }}.gz.sha256
gzip -9 -k dms-distropkg-${{ env.ASSET }}
sha256sum dms-distropkg-${{ env.ASSET }}.gz > dms-distropkg-${{ env.ASSET }}.gz.sha256
- name: Upload artifacts (${{ matrix.arch }})
if: matrix.arch == 'arm64'
- name: Upload artifacts (${{ env.ASSET }})
if: matrix.goos == 'linux' && matrix.arch == 'arm64'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ matrix.arch }}
name: core-assets-${{ env.ASSET }}
path: |
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
core/dankinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts (${{ env.ASSET }}, no installer)
if: matrix.goos != 'linux'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
path: |
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts with completions
if: matrix.arch == 'amd64'
if: matrix.goos == 'linux' && matrix.arch == 'amd64'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ matrix.arch }}
name: core-assets-${{ env.ASSET }}
path: |
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
core/dankinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
core/completion.bash
core/completion.fish
core/completion.zsh
@@ -248,12 +269,18 @@ jobs:
### Complete Packages
- **`dms-full-amd64.tar.gz`** - Complete package for x86_64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-arm64.tar.gz`** - Complete package for ARM64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-freebsd-amd64.tar.gz`** - Complete package for FreeBSD x86_64 systems
- **`dms-full-freebsd-arm64.tar.gz`** - Complete package for FreeBSD ARM64 systems
### Individual Components
- **`dms-cli-amd64.gz`** - DMS CLI binary for x86_64 systems
- **`dms-cli-arm64.gz`** - DMS CLI binary for ARM64 systems
- **`dms-cli-freebsd-amd64.gz`** - DMS CLI binary for FreeBSD x86_64 systems
- **`dms-cli-freebsd-arm64.gz`** - DMS CLI binary for FreeBSD ARM64 systems
- **`dms-distropkg-amd64.gz`** - DMS CLI binary built with distro_package tag for AMD64 systems
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
- **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems
- **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
@@ -338,8 +365,8 @@ jobs:
# Generate checksum for QML package
(cd _release_assets && sha256sum dms-qml.tar.gz > dms-qml.tar.gz.sha256)
# Create full packages for each architecture
for arch in amd64 arm64; do
# Create full packages for each os/architecture
for arch in amd64 arm64 freebsd-amd64 freebsd-arm64; do
mkdir -p _temp_full/dms
mkdir -p _temp_full/bin
mkdir -p _temp_full/completions
+4
View File
@@ -72,6 +72,9 @@ install-completions:
@echo "Shell completions installed"
install-systemd:
ifneq ($(shell uname),Linux)
@echo "Skipping systemd user service (non-Linux); start the shell from your compositor config with 'dms run'"
else
@echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@@ -79,6 +82,7 @@ install-systemd:
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
endif
install-icon:
@echo "Installing icon..."
+20 -20
View File
@@ -23,6 +23,14 @@ BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUI
# Architecture to build for dist target (amd64, arm64, or all)
ARCH ?= all
# Target OSes for dist builds
DIST_OSES ?= linux freebsd
ifeq ($(ARCH),all)
DIST_ARCHS = amd64 arm64
else
DIST_ARCHS = $(ARCH)
endif
.PHONY: all build sync-shell dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
@@ -57,26 +65,18 @@ dankinstall:
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)"
# Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support)
# Build distro binaries (no update/greeter support) for each DIST_OSES/DIST_ARCHS pair
dist: sync-shell
ifeq ($(ARCH),all)
@echo "Building $(BINARY_NAME) for distribution (amd64 and arm64)..."
@echo "Building $(BINARY_NAME) for distribution ($(DIST_OSES) x $(DIST_ARCHS))..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -tags 'distro_binary withshell' $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(SOURCE_DIR)
@echo "Building for linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags 'distro_binary withshell' $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(SOURCE_DIR)
@echo "Distribution builds complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
else
@echo "Building $(BINARY_NAME) for distribution ($(ARCH))..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/$(ARCH)..."
CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) $(GO) build -tags 'distro_binary withshell' $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH) ./$(SOURCE_DIR)
@echo "Distribution build complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH)"
endif
@for os in $(DIST_OSES); do \
for arch in $(DIST_ARCHS); do \
echo "Building for $$os/$$arch..."; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -tags 'distro_binary withshell' $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch ./$(SOURCE_DIR) || exit 1; \
echo " $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch"; \
done; \
done
@echo "Distribution builds complete"
build-all: build dankinstall
@@ -161,8 +161,8 @@ help:
@echo " build - Build the main binary (dms) with the embedded UI"
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
@echo " dankinstall - Build dankinstall binary"
@echo " dist - Build dms for linux amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 to build only one"
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow"
@echo " build-all - Build both binaries"
@echo " install - Install dms to $(INSTALL_DIR)"
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)"
+2
View File
@@ -833,6 +833,8 @@ func detectNetworkBackend(stackResult *network.DetectResult) string {
return "systemd-networkd"
case network.BackendConnMan:
return "ConnMan"
case network.BackendWpaSupplicant:
return "wpa_supplicant"
default:
return ""
}
-20
View File
@@ -980,26 +980,6 @@ func signalTerminals(opts *Options) {
}
}
func signalByName(name string, sig syscall.Signal) {
entries, err := os.ReadDir("/proc")
if err != nil {
return
}
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
comm, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "comm"))
if err != nil {
continue
}
if strings.TrimSpace(string(comm)) == name {
syscall.Kill(pid, sig)
}
}
}
func syncColorScheme(mode ColorMode) {
scheme := "prefer-dark"
if mode == ColorModeLight {
+16
View File
@@ -0,0 +1,16 @@
package matugen
import (
"os/exec"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
// procfs(5) is optional on FreeBSD; pkill(1) from base queries the kernel
// directly.
func signalByName(name string, sig syscall.Signal) {
signame := strings.TrimPrefix(unix.SignalName(sig), "SIG")
exec.Command("pkill", "-"+signame, "-x", name).Run()
}
+29
View File
@@ -0,0 +1,29 @@
package matugen
import (
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
func signalByName(name string, sig syscall.Signal) {
entries, err := os.ReadDir("/proc")
if err != nil {
return
}
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
comm, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "comm"))
if err != nil {
continue
}
if strings.TrimSpace(string(comm)) == name {
syscall.Kill(pid, sig)
}
}
}
+9 -2
View File
@@ -38,7 +38,8 @@ const (
// rest cover distros (or minimal installs) with no /etc/pam.d/login.
// lockscreenPamBaseDirs mirrors libpam's search order: /etc overrides, then the
// vendor dir (/usr/lib) and the stateless-distro default (/usr/share).
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d"}
// /usr/local/etc/pam.d is OpenPAM's ports dir on FreeBSD (openpam_configure).
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d", "/usr/local/etc/pam.d"}
// Standalone auth+account services, most universal first. login exists almost
// everywhere (util-linux); system-* cover Fedora/Arch/Gentoo/SUSE-Leap.
@@ -50,13 +51,15 @@ var lockscreenPamEntryCandidates = []string{
}
// Fallback for distros with no standalone login service, only shared building
// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*).
// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*), FreeBSD
// (system holds both stanzas, included by login).
var lockscreenPamSharedIncludePairs = []struct {
auth string
account string
}{
{auth: "common-auth", account: "common-account"},
{auth: "base-auth", account: "base-account"},
{auth: "system", account: "system"},
}
var includedPamAuthFiles = []string{
@@ -67,6 +70,7 @@ var includedPamAuthFiles = []string{
"system-local-login",
"common-auth-pc",
"login",
"system",
}
type AuthSettings struct {
@@ -1268,6 +1272,9 @@ func pamModuleExists(module string) bool {
"/usr/lib/aarch64-linux-gnu/security",
"/run/current-system/sw/lib64/security",
"/run/current-system/sw/lib/security",
"/usr/local/lib/security",
"/usr/local/lib",
"/usr/lib",
} {
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
return true
@@ -0,0 +1,167 @@
package brightness
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"unsafe"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/dankgo/syncmap"
"golang.org/x/sys/unix"
)
// sys/sys/backlight.h: brightness is a 0-100 percent;
// BACKLIGHTGETSTATUS/BACKLIGHTUPDATESTATUS = _IOWR('G', 0/1, struct
// backlight_props{uint32 brightness; uint32 nlevels; uint32 levels[100]}).
const (
backlightDevDir = "/dev/backlight"
backlightGetStatus = 0xc1984700
backlightUpdateStatus = 0xc1984701
)
type backlightProps struct {
brightness uint32
nlevels uint32
levels [100]uint32
}
type BacklightBackend struct {
devices syncmap.Map[string, string]
}
func NewBacklightBackend() (*BacklightBackend, error) {
b := &BacklightBackend{}
if err := b.scanDevices(); err != nil {
return nil, err
}
return b, nil
}
func isGenericBacklightName(name string) bool {
rest, ok := strings.CutPrefix(name, "backlight")
if !ok || rest == "" {
return false
}
for _, r := range rest {
if r < '0' || r > '9' {
return false
}
}
return true
}
func (b *BacklightBackend) scanDevices() error {
entries, err := os.ReadDir(backlightDevDir)
if err != nil {
return fmt.Errorf("read %s: %w", backlightDevDir, err)
}
// backlight_register (sys/dev/backlight/backlight.c) publishes each unit
// as backlight/backlightN plus a driver-named alias for the same cdev;
// dedupe on the device number and keep the descriptive alias.
names := make(map[uint64]string)
for _, entry := range entries {
var st unix.Stat_t
if err := unix.Stat(filepath.Join(backlightDevDir, entry.Name()), &st); err != nil {
continue
}
rdev := uint64(st.Rdev)
current, exists := names[rdev]
if exists && !isGenericBacklightName(current) {
continue
}
names[rdev] = entry.Name()
}
for _, name := range names {
b.devices.Store("backlight:"+name, filepath.Join(backlightDevDir, name))
}
return nil
}
func backlightIoctl(fd int, req uint, props *backlightProps) error {
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(unsafe.Pointer(props)))
if errno != 0 {
return errno
}
return nil
}
func readBrightness(path string) (int, error) {
fd, err := unix.Open(path, unix.O_RDWR, 0)
if err != nil {
return 0, err
}
defer unix.Close(fd)
var props backlightProps
if err := backlightIoctl(fd, backlightGetStatus, &props); err != nil {
return 0, err
}
return int(props.brightness), nil
}
func (b *BacklightBackend) Rescan() error {
return b.scanDevices()
}
func (b *BacklightBackend) GetDevices() ([]Device, error) {
devices := make([]Device, 0)
b.devices.Range(func(id, path string) bool {
brightness, err := readBrightness(path)
if err != nil {
log.Debugf("failed to read brightness for %s: %v", id, err)
return true
}
devices = append(devices, Device{
Class: ClassBacklight,
ID: id,
Name: strings.TrimPrefix(id, "backlight:"),
Current: brightness,
Max: 100,
CurrentPercent: brightness,
Backend: "backlight",
})
return true
})
return devices, nil
}
func (b *BacklightBackend) SetBrightnessWithExponent(id string, percent int, exponential bool, exponent float64) error {
if percent < 0 || percent > 100 {
return fmt.Errorf("percent out of range: %d", percent)
}
path, ok := b.devices.Load(id)
if !ok {
return fmt.Errorf("device not found: %s", id)
}
value := percent
switch {
case percent == 0:
value = 1
case exponential:
value = 1 + int(math.Round(math.Pow(float64(percent-1)/99.0, exponent)*99.0))
}
fd, err := unix.Open(path, unix.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer unix.Close(fd)
props := backlightProps{brightness: uint32(value)}
if err := backlightIoctl(fd, backlightUpdateStatus, &props); err != nil {
return fmt.Errorf("set brightness: %w", err)
}
log.Debugf("set %s to %d%% (hw %d) via backlight(4)", id, percent, value)
return nil
}
@@ -0,0 +1,162 @@
package brightness
import (
"net"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
// devd(8) publishes device events on this SOCK_SEQPACKET socket, one
// "!system=... subsystem=... type=..." record per packet.
const devdSocketPath = "/var/run/devd.seqpacket.pipe"
const (
devdMaxRetries = 5
devdBaseDelay = 2 * time.Second
devdMaxDelay = 60 * time.Second
)
type DevdMonitor struct {
stop chan struct{}
rescanMutex sync.Mutex
rescanTimer *time.Timer
rescanPending bool
}
func newDevdMonitor(manager *Manager) *DevdMonitor {
m := &DevdMonitor{
stop: make(chan struct{}),
}
go m.run(manager)
return m
}
func (m *DevdMonitor) run(manager *Manager) {
failures := 0
for {
if err := m.monitorLoop(manager); err != nil {
log.Errorf("Devd monitor error: %v", err)
}
select {
case <-m.stop:
return
default:
}
failures++
if failures > devdMaxRetries {
log.Errorf("Devd monitor exceeded %d retries, giving up", devdMaxRetries)
return
}
delay := min(devdBaseDelay*time.Duration(1<<(failures-1)), devdMaxDelay)
log.Infof("Devd monitor reconnecting in %v (attempt %d/%d)", delay, failures, devdMaxRetries)
select {
case <-m.stop:
return
case <-time.After(delay):
}
}
}
func (m *DevdMonitor) monitorLoop(manager *Manager) error {
conn, err := net.Dial("unixpacket", devdSocketPath)
if err != nil {
return err
}
defer conn.Close()
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-m.stop:
conn.Close()
case <-done:
}
}()
log.Info("Devd monitor started for backlight/drm events")
buf := make([]byte, 8192)
for {
n, err := conn.Read(buf)
if err != nil {
select {
case <-m.stop:
return nil
default:
return err
}
}
m.handleEvent(manager, string(buf[:n]))
}
}
func (m *DevdMonitor) handleEvent(manager *Manager, event string) {
notification, ok := strings.CutPrefix(event, "!")
if !ok {
return
}
fields := parseDevdEvent(notification)
switch fields["system"] {
case "DRM":
m.debouncedRescan(manager)
case "DEVFS":
if fields["subsystem"] != "CDEV" {
return
}
if !strings.HasPrefix(fields["cdev"], "backlight/") {
return
}
m.debouncedRescan(manager)
}
}
func parseDevdEvent(s string) map[string]string {
fields := make(map[string]string)
for _, part := range strings.Fields(s) {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
fields[k] = v
}
return fields
}
func (m *DevdMonitor) debouncedRescan(manager *Manager) {
m.rescanMutex.Lock()
defer m.rescanMutex.Unlock()
m.rescanPending = true
if m.rescanTimer != nil {
m.rescanTimer.Reset(2 * time.Second)
return
}
m.rescanTimer = time.AfterFunc(2*time.Second, func() {
m.rescanMutex.Lock()
pending := m.rescanPending
m.rescanPending = false
m.rescanMutex.Unlock()
if !pending {
return
}
manager.Rescan()
})
}
func (m *DevdMonitor) Close() {
close(m.stop)
}
+15 -45
View File
@@ -20,7 +20,7 @@ func NewManagerWithOptions(exponential bool) (*Manager, error) {
}
go m.initLogind()
go m.initSysfs()
go m.initNative()
go m.initDDC()
return m, nil
@@ -40,39 +40,6 @@ func (m *Manager) initLogind() {
log.Info("Logind backend initialized - will use for brightness control")
}
func (m *Manager) initSysfs() {
log.Debug("Initializing sysfs backend...")
sysfs, err := NewSysfsBackend()
if err != nil {
log.Warnf("Failed to initialize sysfs backend: %v", err)
return
}
devices, err := sysfs.GetDevices()
if err != nil {
log.Warnf("Failed to get initial sysfs devices: %v", err)
m.sysfsBackend = sysfs
m.sysfsReady = true
m.updateState()
m.initUdev()
return
}
log.Infof("Sysfs backend initialized with %d devices", len(devices))
for _, d := range devices {
log.Debugf(" - %s: %s (%d%%)", d.ID, d.Name, d.CurrentPercent)
}
m.sysfsBackend = sysfs
m.sysfsReady = true
m.updateState()
m.initUdev()
}
func (m *Manager) initUdev() {
m.udevMonitor = NewUdevMonitor(m)
}
func (m *Manager) initDDC() {
ddc, err := NewDDCBackend()
if err != nil {
@@ -96,9 +63,9 @@ func (m *Manager) Rescan() {
}
}
if m.sysfsReady && m.sysfsBackend != nil {
if err := m.sysfsBackend.Rescan(); err != nil {
log.Debugf("Sysfs rescan failed: %v", err)
if m.nativeReady && m.nativeBackend != nil {
if err := m.nativeBackend.Rescan(); err != nil {
log.Debugf("Native backend rescan failed: %v", err)
}
}
@@ -150,10 +117,10 @@ func stateChanged(old, new State) bool {
func (m *Manager) updateState() {
allDevices := make([]Device, 0)
if m.sysfsReady && m.sysfsBackend != nil {
devices, err := m.sysfsBackend.GetDevices()
if m.nativeReady && m.nativeBackend != nil {
devices, err := m.nativeBackend.GetDevices()
if err != nil {
log.Debugf("Failed to get sysfs devices: %v", err)
log.Debugf("Failed to get native backend devices: %v", err)
}
if err == nil {
allDevices = append(allDevices, devices...)
@@ -232,18 +199,21 @@ func (m *Manager) SetBrightnessWithExponent(deviceID string, percent int, expone
m.stateMutex.Unlock()
var err error
if deviceClass == ClassDDC {
switch {
case deviceClass == ClassDDC:
log.Debugf("Calling DDC backend for %s", deviceID)
err = m.ddcBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, func() {
m.updateState()
m.debouncedBroadcast(deviceID)
})
} else if m.logindReady && m.logindBackend != nil {
case m.logindReady && m.logindBackend != nil:
log.Debugf("Calling logind backend for %s", deviceID)
err = m.setViaSysfsWithLogindWithExponent(deviceID, percent, exponential, exponent)
} else {
log.Debugf("Calling sysfs backend for %s", deviceID)
err = m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
case m.nativeBackend != nil:
log.Debugf("Calling native backend for %s", deviceID)
err = m.nativeBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
default:
err = fmt.Errorf("no brightness backend for %s", deviceID)
}
if err != nil {
@@ -0,0 +1,24 @@
package brightness
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func (m *Manager) initNative() {
log.Debug("Initializing backlight backend...")
backend, err := NewBacklightBackend()
if err != nil {
log.Warnf("Failed to initialize backlight backend: %v", err)
return
}
devices, err := backend.GetDevices()
if err == nil {
log.Infof("Backlight backend initialized with %d devices", len(devices))
}
m.nativeBackend = backend
m.nativeReady = true
m.updateState()
m.monitor = newDevdMonitor(m)
}
@@ -0,0 +1,36 @@
package brightness
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func (m *Manager) initNative() {
log.Debug("Initializing sysfs backend...")
sysfs, err := NewSysfsBackend()
if err != nil {
log.Warnf("Failed to initialize sysfs backend: %v", err)
return
}
devices, err := sysfs.GetDevices()
if err != nil {
log.Warnf("Failed to get initial sysfs devices: %v", err)
m.sysfsBackend = sysfs
m.nativeBackend = sysfs
m.nativeReady = true
m.updateState()
m.monitor = NewUdevMonitor(m)
return
}
log.Infof("Sysfs backend initialized with %d devices", len(devices))
for _, d := range devices {
log.Debugf(" - %s: %s (%d%%)", d.ID, d.Name, d.CurrentPercent)
}
m.sysfsBackend = sysfs
m.nativeBackend = sysfs
m.nativeReady = true
m.updateState()
m.monitor = NewUdevMonitor(m)
}
@@ -43,7 +43,7 @@ func TestManager_SetBrightness_LogindSuccess(t *testing.T) {
logindBackend: mockLogind,
sysfsBackend: sysfs,
logindReady: true,
sysfsReady: true,
nativeReady: true,
stopChan: make(chan struct{}),
}
@@ -114,7 +114,7 @@ func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) {
logindBackend: mockLogind,
sysfsBackend: sysfs,
logindReady: true,
sysfsReady: true,
nativeReady: true,
stopChan: make(chan struct{}),
}
@@ -180,8 +180,9 @@ func TestManager_SetBrightness_NoLogind(t *testing.T) {
m := &Manager{
logindBackend: nil,
sysfsBackend: sysfs,
nativeBackend: sysfs,
logindReady: false,
sysfsReady: true,
nativeReady: true,
stopChan: make(chan struct{}),
}
@@ -243,7 +244,7 @@ func TestManager_SetBrightness_LEDWithLogind(t *testing.T) {
logindBackend: mockLogind,
sysfsBackend: sysfs,
logindReady: true,
sysfsReady: true,
nativeReady: true,
stopChan: make(chan struct{}),
}
+15 -4
View File
@@ -33,14 +33,25 @@ type DeviceUpdate struct {
Device Device `json:"device"`
}
type Backend interface {
Rescan() error
GetDevices() ([]Device, error)
SetBrightnessWithExponent(id string, percent int, exponential bool, exponent float64) error
}
type deviceMonitor interface {
Close()
}
type Manager struct {
logindBackend *LogindBackend
sysfsBackend *SysfsBackend
nativeBackend Backend
ddcBackend *DDCBackend
udevMonitor *UdevMonitor
monitor deviceMonitor
logindReady bool
sysfsReady bool
nativeReady bool
ddcReady bool
exponential bool
@@ -170,8 +181,8 @@ func (m *Manager) Close() {
return true
})
if m.udevMonitor != nil {
m.udevMonitor.Close()
if m.monitor != nil {
m.monitor.Close()
}
if m.logindBackend != nil {
@@ -33,7 +33,7 @@ func setupTestManager(t *testing.T) (*Manager, string) {
m := &Manager{
sysfsBackend: sysfs,
sysfsReady: true,
nativeReady: true,
stopChan: make(chan struct{}),
}
+282
View File
@@ -0,0 +1,282 @@
package network
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
// Default ctrl_interface directory on FreeBSD and minimal Linux setups; see
// https://man.freebsd.org/cgi/man.cgi?query=wpa_supplicant.conf&sektion=5
const wpaSupplicantCtrlDir = "/var/run/wpa_supplicant"
type wpaConnectAttempt struct {
ssid string
netID int
saved bool
newlyAdded bool
prompted bool
sawTempDisabled bool
finalized bool
deadline time.Time
mu sync.Mutex
}
type WpaSupplicantBackend struct {
ctrlDir string
ifname string
cmd *wpaCtrlConn
monitor *wpaCtrlConn
state *BackendState
stateMutex sync.RWMutex
promptBroker PromptBroker
onStateChange func()
stopChan chan struct{}
sigWG sync.WaitGroup
curAttempt *wpaConnectAttempt
attemptMutex sync.RWMutex
savedIDs map[string]int
savedIDsMu sync.Mutex
recentScans map[string]time.Time
recentScansMu sync.Mutex
}
func NewWpaSupplicantBackend() (*WpaSupplicantBackend, error) {
return &WpaSupplicantBackend{
ctrlDir: wpaSupplicantCtrlDir,
state: &BackendState{
Backend: "wpa_supplicant",
WiFiNetworks: []WiFiNetwork{},
},
stopChan: make(chan struct{}),
savedIDs: make(map[string]int),
recentScans: make(map[string]time.Time),
}, nil
}
func discoverWpaInterfaces(ctrlDir string) ([]string, error) {
entries, err := os.ReadDir(ctrlDir)
if err != nil {
return nil, fmt.Errorf("read %s: %w", ctrlDir, err)
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
// wpa_supplicant creates an extra p2p-dev-<ifname> management socket
// when built with CONFIG_P2P; it is not a station interface.
if strings.HasPrefix(entry.Name(), "p2p-dev-") {
continue
}
names = append(names, entry.Name())
}
sort.Strings(names)
return names, nil
}
func (b *WpaSupplicantBackend) Initialize() error {
names, err := discoverWpaInterfaces(b.ctrlDir)
if err != nil {
return fmt.Errorf("failed to discover wpa_supplicant interfaces: %w", err)
}
if len(names) == 0 {
return fmt.Errorf("no wpa_supplicant control sockets in %s", b.ctrlDir)
}
b.ifname = names[0]
if len(names) > 1 {
log.Infof("wpa_supplicant manages %d interfaces; using %s", len(names), b.ifname)
}
conn, err := newWpaCtrlConn(filepath.Join(b.ctrlDir, b.ifname))
if err != nil {
return fmt.Errorf("failed to open wpa_ctrl socket: %w", err)
}
b.cmd = conn
reply, err := b.cmd.request("PING")
if err != nil {
b.cmd.close()
return fmt.Errorf("wpa_supplicant not responding on %s: %w", b.ifname, err)
}
if reply != "PONG" {
b.cmd.close()
return fmt.Errorf("unexpected PING reply from wpa_supplicant: %s", reply)
}
b.stateMutex.Lock()
b.state.WiFiDevice = b.ifname
b.state.WiFiEnabled = true
b.stateMutex.Unlock()
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
}
if err := b.updateState(); err != nil {
b.cmd.close()
return fmt.Errorf("failed to get initial state: %w", err)
}
if _, err := b.updateWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial WiFi networks: %v", err)
}
if err := b.ScanWiFi(); err != nil {
log.Debugf("Initial WiFi scan not started: %v", err)
}
return nil
}
func (b *WpaSupplicantBackend) Close() {
b.StopMonitoring()
if b.monitor != nil {
_ = b.monitor.send("DETACH")
b.monitor.close()
}
if b.cmd != nil {
b.cmd.close()
}
}
func (b *WpaSupplicantBackend) GetCurrentState() (*BackendState, error) {
b.stateMutex.RLock()
defer b.stateMutex.RUnlock()
state := *b.state
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...)
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
state.WiFiDevices = b.getWiFiDevicesLocked()
return &state, nil
}
func (b *WpaSupplicantBackend) GetPromptBroker() PromptBroker {
return b.promptBroker
}
func (b *WpaSupplicantBackend) SetPromptBroker(broker PromptBroker) error {
if broker == nil {
return fmt.Errorf("broker cannot be nil")
}
b.promptBroker = broker
return nil
}
func (b *WpaSupplicantBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
if b.promptBroker == nil {
return fmt.Errorf("prompt broker not initialized")
}
return b.promptBroker.Resolve(token, PromptReply{
Secrets: secrets,
Save: save,
Cancel: false,
})
}
func (b *WpaSupplicantBackend) CancelCredentials(token string) error {
if b.promptBroker == nil {
return fmt.Errorf("prompt broker not initialized")
}
return b.promptBroker.Resolve(token, PromptReply{
Cancel: true,
})
}
func (b *WpaSupplicantBackend) StopMonitoring() {
select {
case <-b.stopChan:
return
default:
close(b.stopChan)
}
b.sigWG.Wait()
}
func (b *WpaSupplicantBackend) promptForPSK(ssid, reason string) (string, bool) {
if b.promptBroker == nil {
return "", false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
go func() {
select {
case <-b.stopChan:
cancel()
case <-ctx.Done():
}
}()
token, err := b.promptBroker.Ask(ctx, PromptRequest{
SSID: ssid,
SettingName: "802-11-wireless-security",
Fields: []string{"psk"},
Reason: reason,
})
if err != nil {
log.Warnf("failed to request credentials for %s: %v", ssid, err)
return "", false
}
reply, err := b.promptBroker.Wait(ctx, token)
if err != nil || reply.Cancel {
return "", false
}
psk, ok := reply.Secrets["psk"]
if !ok || psk == "" {
return "", false
}
return psk, true
}
func (b *WpaSupplicantBackend) storeSavedIDs(ids map[string]int) {
b.savedIDsMu.Lock()
b.savedIDs = ids
b.savedIDsMu.Unlock()
}
func (b *WpaSupplicantBackend) savedNetworkID(ssid string) (int, bool) {
b.savedIDsMu.Lock()
defer b.savedIDsMu.Unlock()
id, ok := b.savedIDs[ssid]
return id, ok
}
func (b *WpaSupplicantBackend) seenInRecentScan(ssid string) bool {
b.recentScansMu.Lock()
defer b.recentScansMu.Unlock()
lastSeen, ok := b.recentScans[ssid]
return ok && time.Since(lastSeen) < 30*time.Second
}
func (b *WpaSupplicantBackend) setConnectError(code string) {
b.stateMutex.Lock()
b.state.IsConnecting = false
b.state.ConnectingSSID = ""
b.state.LastError = code
b.stateMutex.Unlock()
}
@@ -0,0 +1,188 @@
package network
import (
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
wpaCtrlRequestTimeout = 5 * time.Second
wpaCtrlReadBufferSize = 65536
)
// Client side of the wpa_ctrl protocol: a unix datagram socket bound to a
// local path, connected to wpa_supplicant's per-interface socket. See
// https://w1.fi/wpa_supplicant/devel/ctrl_iface_page.html
type wpaCtrlConn struct {
sockPath string
localDir string
mu sync.Mutex
conn *net.UnixConn
seq int
}
func wpaCtrlLocalSocketBase() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return dir
}
return os.TempDir()
}
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
localDir, err := os.MkdirTemp(wpaCtrlLocalSocketBase(), "dms-wpa-")
if err != nil {
return nil, fmt.Errorf("create wpa_ctrl socket dir: %w", err)
}
c := &wpaCtrlConn{sockPath: sockPath, localDir: localDir}
if err := c.dialLocked(); err != nil {
os.RemoveAll(localDir)
return nil, err
}
return c, nil
}
func (c *wpaCtrlConn) dialLocked() error {
c.seq++
localPath := filepath.Join(c.localDir, fmt.Sprintf("ctrl_%d", c.seq))
os.Remove(localPath)
conn, err := net.DialUnix("unixgram",
&net.UnixAddr{Name: localPath, Net: "unixgram"},
&net.UnixAddr{Name: c.sockPath, Net: "unixgram"})
if err != nil {
return fmt.Errorf("dial %s: %w", c.sockPath, err)
}
if c.conn != nil {
c.conn.Close()
}
c.conn = conn
return nil
}
func (c *wpaCtrlConn) reconnectLocked() error {
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
return c.dialLocked()
}
func (c *wpaCtrlConn) reconnect() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.reconnectLocked()
}
// request sends a command and waits for its reply, retrying once on a fresh
// socket so a wpa_supplicant restart between requests is transparent.
func (c *wpaCtrlConn) request(cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
reply, err := c.roundTripLocked(cmd)
if err == nil {
return reply, nil
}
if rerr := c.reconnectLocked(); rerr != nil {
return "", err
}
return c.roundTripLocked(cmd)
}
func (c *wpaCtrlConn) roundTripLocked(cmd string) (string, error) {
if c.conn == nil {
if err := c.dialLocked(); err != nil {
return "", err
}
}
_ = c.conn.SetDeadline(time.Now().Add(wpaCtrlRequestTimeout))
if _, err := c.conn.Write([]byte(cmd)); err != nil {
return "", fmt.Errorf("write %q: %w", cmd, err)
}
buf := make([]byte, wpaCtrlReadBufferSize)
for {
n, err := c.conn.Read(buf)
if err != nil {
return "", fmt.Errorf("read reply for %q: %w", cmd, err)
}
// Unsolicited event datagrams carry a "<priority>" prefix, command
// replies never do (ctrl_iface_page.html, "Control interface data").
if n > 0 && buf[0] == '<' {
continue
}
return strings.TrimRight(string(buf[:n]), "\n"), nil
}
}
func (c *wpaCtrlConn) send(cmd string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
if err := c.dialLocked(); err != nil {
return err
}
}
_ = c.conn.SetWriteDeadline(time.Now().Add(wpaCtrlRequestTimeout))
if _, err := c.conn.Write([]byte(cmd)); err != nil {
return fmt.Errorf("write %q: %w", cmd, err)
}
return nil
}
func (c *wpaCtrlConn) readDatagram(timeout time.Duration) (string, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return "", fmt.Errorf("wpa_ctrl socket closed")
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, wpaCtrlReadBufferSize)
n, err := conn.Read(buf)
if err != nil {
return "", err
}
return strings.TrimRight(string(buf[:n]), "\n"), nil
}
func (c *wpaCtrlConn) attach() error {
reply, err := c.request("ATTACH")
if err != nil {
return err
}
if reply != "OK" {
return fmt.Errorf("ATTACH failed: %s", reply)
}
return nil
}
func (c *wpaCtrlConn) close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
os.RemoveAll(c.localDir)
}
func isWpaCtrlTimeout(err error) bool {
netErr, ok := err.(net.Error)
return ok && netErr.Timeout()
}
@@ -0,0 +1,142 @@
package network
import (
"fmt"
"net"
"strings"
"github.com/godbus/dbus/v5"
)
func interfaceIPv4(ifname string) string {
iface, err := net.InterfaceByName(ifname)
if err != nil {
return ""
}
addrs, err := iface.Addrs()
if err != nil {
return ""
}
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipv4 := ipnet.IP.To4(); ipv4 != nil {
return ipv4.String()
}
}
return ""
}
func looksWirelessName(name string) bool {
return strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp")
}
func (b *WpaSupplicantBackend) isWpaManaged(name string) bool {
return name == b.ifname
}
func (b *WpaSupplicantBackend) ethernetDevices() []EthernetDevice {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var devices []EthernetDevice
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if looksVirtual(iface.Name) || looksWirelessName(iface.Name) {
continue
}
if b.isWpaManaged(iface.Name) {
continue
}
up := iface.Flags&net.FlagUp != 0
running := iface.Flags&net.FlagRunning != 0
ip := interfaceIPv4(iface.Name)
stateStr := "off"
switch {
case running && ip != "":
stateStr = "routable"
case running:
stateStr = "carrier"
case up:
stateStr = "no-carrier"
}
devices = append(devices, EthernetDevice{
Name: iface.Name,
HwAddress: iface.HardwareAddr.String(),
State: stateStr,
Connected: up && running,
IP: ip,
})
}
return devices
}
func wiredConnectionsFromEthernetDevices(devices []EthernetDevice) []WiredConnection {
conns := make([]WiredConnection, 0, len(devices))
for _, dev := range devices {
conns = append(conns, WiredConnection{
Path: dbus.ObjectPath("/" + dev.Name),
ID: dev.Name,
UUID: "wired:" + dev.Name,
Type: "ethernet",
IsActive: dev.Connected,
})
}
return conns
}
func (b *WpaSupplicantBackend) GetEthernetDevices() []EthernetDevice {
b.stateMutex.RLock()
defer b.stateMutex.RUnlock()
return append([]EthernetDevice(nil), b.state.EthernetDevices...)
}
func (b *WpaSupplicantBackend) GetWiredConnections() ([]WiredConnection, error) {
return wiredConnectionsFromEthernetDevices(b.ethernetDevices()), nil
}
func (b *WpaSupplicantBackend) GetWiredNetworkDetails(id string) (*WiredNetworkInfoResponse, error) {
ifname := strings.TrimPrefix(id, "wired:")
iface, err := net.InterfaceByName(ifname)
if err != nil {
return nil, fmt.Errorf("get interface: %w", err)
}
addrs, _ := iface.Addrs()
var ipv4s, ipv6s []string
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipv4 := ipnet.IP.To4(); ipv4 != nil {
ipv4s = append(ipv4s, ipnet.String())
} else if ipv6 := ipnet.IP.To16(); ipv6 != nil {
ipv6s = append(ipv6s, ipnet.String())
}
}
return &WiredNetworkInfoResponse{
UUID: id,
IFace: ifname,
HwAddr: iface.HardwareAddr.String(),
IPv4: WiredIPConfig{
IPs: ipv4s,
},
IPv6: WiredIPConfig{
IPs: ipv6s,
},
}, nil
}
@@ -0,0 +1,298 @@
package network
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
// Event strings per WPA_EVENT_* in contrib/wpa/src/common/wpa_ctrl.h
// (freebsd-src).
const (
wpaEventConnected = "CTRL-EVENT-CONNECTED"
wpaEventDisconnected = "CTRL-EVENT-DISCONNECTED"
wpaEventScanResults = "CTRL-EVENT-SCAN-RESULTS"
wpaEventTempDisabled = "CTRL-EVENT-SSID-TEMP-DISABLED"
)
const (
wpaMonitorReadTimeout = 5 * time.Second
wpaMonitorPingInterval = 30 * time.Second
wpaMonitorReconnectDelay = 2 * time.Second
)
type wpaEvent struct {
priority int
name string
args string
}
func parseWpaEventLine(line string) (wpaEvent, bool) {
if len(line) < 3 || line[0] != '<' {
return wpaEvent{}, false
}
end := strings.IndexByte(line, '>')
if end < 0 {
return wpaEvent{}, false
}
priority, err := strconv.Atoi(line[1:end])
if err != nil {
return wpaEvent{}, false
}
rest := line[end+1:]
name, args, _ := strings.Cut(rest, " ")
if name == "" {
return wpaEvent{}, false
}
return wpaEvent{priority: priority, name: name, args: args}, true
}
// Args format: id=%d ssid="%s" auth_failures=%u duration=%d reason=%s, per
// wpas_auth_failed in contrib/wpa/wpa_supplicant/wpa_supplicant.c
// (freebsd-src); the SSID is printf_encoded inside the quotes.
func parseWpaTempDisabled(args string) (ssid string, reason string) {
if start := strings.Index(args, `ssid="`); start >= 0 {
raw := args[start+len(`ssid="`):]
if end := indexUnescapedQuote(raw); end >= 0 {
ssid = decodeWpaSSIDText(raw[:end])
}
}
for _, field := range strings.Fields(args) {
if value, ok := strings.CutPrefix(field, "reason="); ok {
reason = value
break
}
}
return ssid, reason
}
func indexUnescapedQuote(s string) int {
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
return i
}
}
return -1
}
func (b *WpaSupplicantBackend) StartMonitoring(onStateChange func()) error {
b.onStateChange = onStateChange
monitor, err := newWpaCtrlConn(filepath.Join(b.ctrlDir, b.ifname))
if err != nil {
return fmt.Errorf("failed to open wpa_ctrl monitor socket: %w", err)
}
if err := monitor.attach(); err != nil {
monitor.close()
return fmt.Errorf("failed to attach wpa_ctrl monitor: %w", err)
}
b.monitor = monitor
b.sigWG.Add(1)
go b.monitorLoop()
return nil
}
func (b *WpaSupplicantBackend) monitorLoop() {
defer b.sigWG.Done()
lastActivity := time.Now()
pingOutstanding := false
var pingSent time.Time
for {
select {
case <-b.stopChan:
return
default:
}
msg, err := b.monitor.readDatagram(wpaMonitorReadTimeout)
switch {
case err == nil:
lastActivity = time.Now()
if strings.HasPrefix(msg, "<") {
b.handleEvent(msg)
continue
}
if msg == "PONG" {
pingOutstanding = false
}
case isWpaCtrlTimeout(err):
if pingOutstanding && time.Since(pingSent) > wpaCtrlRequestTimeout {
b.reattachMonitor()
pingOutstanding = false
lastActivity = time.Now()
continue
}
if !pingOutstanding && time.Since(lastActivity) > wpaMonitorPingInterval {
if b.monitor.send("PING") != nil {
b.reattachMonitor()
lastActivity = time.Now()
continue
}
pingOutstanding = true
pingSent = time.Now()
}
default:
b.reattachMonitor()
pingOutstanding = false
lastActivity = time.Now()
}
}
}
func (b *WpaSupplicantBackend) reattachMonitor() {
for {
select {
case <-b.stopChan:
return
default:
}
if err := b.monitor.reconnect(); err == nil {
if err := b.monitor.attach(); err == nil {
break
}
}
select {
case <-b.stopChan:
return
case <-time.After(wpaMonitorReconnectDelay):
}
}
log.Infof("wpa_supplicant monitor reattached on %s", b.ifname)
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("failed to refresh saved networks after wpa reattach: %v", err)
}
if err := b.updateState(); err != nil {
log.Warnf("failed to refresh state after wpa reattach: %v", err)
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *WpaSupplicantBackend) handleEvent(raw string) {
event, ok := parseWpaEventLine(raw)
if !ok {
return
}
switch event.name {
case wpaEventScanResults:
b.handleScanResults()
case wpaEventConnected:
b.handleConnected()
case wpaEventDisconnected:
b.handleDisconnected()
case wpaEventTempDisabled:
b.handleTempDisabled(event.args)
}
}
func (b *WpaSupplicantBackend) handleScanResults() {
if _, err := b.updateWiFiNetworks(); err != nil {
log.Warnf("failed to update WiFi networks after scan: %v", err)
return
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *WpaSupplicantBackend) handleConnected() {
if err := b.updateState(); err != nil {
log.Warnf("failed to update wpa state after connect event: %v", err)
}
b.attemptMutex.RLock()
att := b.curAttempt
b.attemptMutex.RUnlock()
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
b.stateMutex.RUnlock()
if att != nil && att.ssid == currentSSID {
b.finalizeAttempt(att, "")
b.attemptMutex.Lock()
if b.curAttempt == att {
b.curAttempt = nil
}
b.attemptMutex.Unlock()
return
}
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("failed to refresh saved networks after connect event: %v", err)
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *WpaSupplicantBackend) handleDisconnected() {
if err := b.updateState(); err != nil {
log.Warnf("failed to update wpa state after disconnect event: %v", err)
}
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("failed to refresh saved networks after disconnect event: %v", err)
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *WpaSupplicantBackend) handleTempDisabled(args string) {
ssid, reason := parseWpaTempDisabled(args)
b.attemptMutex.RLock()
att := b.curAttempt
b.attemptMutex.RUnlock()
if att == nil || att.ssid != ssid {
return
}
att.mu.Lock()
att.sawTempDisabled = true
att.mu.Unlock()
code := errdefs.ErrConnectionFailed
// WRONG_KEY is the reason wpas_auth_failed reports for a PSK mismatch
// (could_be_psk_mismatch path in contrib/wpa/wpa_supplicant/events.c).
if reason == "WRONG_KEY" {
code = errdefs.ErrBadCredentials
}
b.finalizeAttempt(att, code)
b.attemptMutex.Lock()
if b.curAttempt == att {
b.curAttempt = nil
}
b.attemptMutex.Unlock()
}
@@ -0,0 +1,320 @@
package network
import (
"net"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestParseWpaScanResults(t *testing.T) {
text := "bssid / frequency / signal level / flags / ssid\n" +
"02:00:01:02:03:04\t2412\t-45\t[WPA2-PSK-CCMP][ESS]\tHome\n" +
"02:00:01:02:03:05\t5180\t-60\t[WPA2-EAP-CCMP][ESS]\tOffice\n" +
"02:00:01:02:03:06\t2437\t-70\t[ESS]\tCafe\n" +
"02:00:01:02:03:07\t2462\t-80\t[WPA2-PSK-CCMP][ESS]\t\n" +
"02:00:01:02:03:08\t2412\t-50\t[WEP][ESS]\tTab\\tNet\n" +
"garbage line without tabs\n"
results := parseWpaScanResults(text)
assert.Len(t, results, 5)
assert.Equal(t, "02:00:01:02:03:04", results[0].BSSID)
assert.Equal(t, uint32(2412), results[0].Frequency)
assert.Equal(t, -45, results[0].Level)
assert.Equal(t, "[WPA2-PSK-CCMP][ESS]", results[0].Flags)
assert.Equal(t, "Home", results[0].SSID)
assert.Empty(t, results[3].SSID)
assert.Equal(t, "Tab\tNet", results[4].SSID)
}
func TestWpaWiFiNetworksFromScanResults(t *testing.T) {
results := []wpaScanResult{
{BSSID: "02:00:01:02:03:04", Frequency: 2412, Level: -45, Flags: "[WPA2-PSK-CCMP][ESS]", SSID: "Home"},
{BSSID: "02:00:01:02:03:05", Frequency: 5180, Level: -80, Flags: "[WPA2-PSK-CCMP][ESS]", SSID: "Home"},
{BSSID: "02:00:01:02:03:06", Frequency: 5200, Level: -60, Flags: "[WPA2-EAP-CCMP][ESS]", SSID: "Office"},
{BSSID: "02:00:01:02:03:07", Frequency: 2437, Level: -70, Flags: "[ESS]", SSID: "Cafe"},
{BSSID: "02:00:01:02:03:08", Frequency: 2462, Level: -50, Flags: "[WPA2-PSK-CCMP][ESS]", SSID: ""},
}
networks := wpaWiFiNetworksFromScanResults(results)
byNet := make(map[string]WiFiNetwork, len(networks))
for _, network := range networks {
byNet[network.SSID] = network
}
assert.Len(t, networks, 3)
home := byNet["Home"]
assert.Equal(t, "02:00:01:02:03:04", home.BSSID, "strongest BSS should win")
assert.Equal(t, uint8(55), home.Signal)
assert.True(t, home.Secured)
assert.False(t, home.Enterprise)
assert.Equal(t, uint32(1), home.Channel)
office := byNet["Office"]
assert.True(t, office.Secured)
assert.True(t, office.Enterprise)
cafe := byNet["Cafe"]
assert.False(t, cafe.Secured)
assert.Equal(t, "infrastructure", cafe.Mode)
}
func TestSignalPercentFromDbm(t *testing.T) {
testCases := []struct {
dbm int
expected uint8
}{
{-30, 70},
{-100, 0},
{-110, 0},
{0, 100},
{10, 100},
{-55, 45},
}
for _, tc := range testCases {
assert.Equal(t, tc.expected, signalPercentFromDbm(tc.dbm), "dbm=%d", tc.dbm)
}
}
func TestParseWpaStatus(t *testing.T) {
text := "bssid=02:00:01:02:03:04\n" +
"freq=2412\n" +
"ssid=Home Network\n" +
"id=3\n" +
"key_mgmt=WPA2-PSK\n" +
"wpa_state=COMPLETED\n" +
"ip_address=192.168.1.21\n"
st := parseWpaStatus(text)
assert.Equal(t, "COMPLETED", st.wpaState)
assert.Equal(t, "Home Network", st.ssid)
assert.Equal(t, "02:00:01:02:03:04", st.bssid)
assert.Equal(t, "192.168.1.21", st.ipAddress)
assert.Equal(t, "WPA2-PSK", st.keyMgmt)
assert.Equal(t, 3, st.networkID)
}
func TestParseWpaStatus_Disconnected(t *testing.T) {
st := parseWpaStatus("wpa_state=DISCONNECTED\naddress=02:00:01:02:03:04\n")
assert.Equal(t, "DISCONNECTED", st.wpaState)
assert.Empty(t, st.ssid)
assert.Equal(t, -1, st.networkID)
}
func TestParseWpaSignalPollRSSI(t *testing.T) {
rssi, ok := parseWpaSignalPollRSSI("RSSI=-62\nLINKSPEED=433\nNOISE=9999\nFREQUENCY=5180\n")
assert.True(t, ok)
assert.Equal(t, -62, rssi)
_, ok = parseWpaSignalPollRSSI("FAIL")
assert.False(t, ok)
}
func TestParseWpaListNetworks(t *testing.T) {
text := "network id / ssid / bssid / flags\n" +
"0\tHome\tany\t[CURRENT]\n" +
"1\tOffice\tany\t[DISABLED]\n" +
"2\tCafe\tany\t\n" +
"3\tFlaky\tany\t[TEMP-DISABLED]\n" +
"bogus\tline\tany\t\n"
networks := parseWpaListNetworks(text)
assert.Len(t, networks, 4)
assert.Equal(t, 0, networks[0].id)
assert.Equal(t, "Home", networks[0].ssid)
assert.True(t, networks[0].current)
assert.False(t, networks[0].disabled)
assert.True(t, networks[1].disabled)
assert.False(t, networks[2].disabled)
assert.False(t, networks[2].current)
assert.True(t, networks[3].tempDisabled)
}
func TestParseWpaEventLine(t *testing.T) {
testCases := []struct {
line string
ok bool
priority int
name string
args string
}{
{"<2>CTRL-EVENT-CONNECTED - Connection to 02:00:01:02:03:04 completed [id=0 id_str=]", true, 2, "CTRL-EVENT-CONNECTED", "- Connection to 02:00:01:02:03:04 completed [id=0 id_str=]"},
{"<3>CTRL-EVENT-DISCONNECTED bssid=02:00:01:02:03:04 reason=3 locally_generated=1", true, 3, "CTRL-EVENT-DISCONNECTED", "bssid=02:00:01:02:03:04 reason=3 locally_generated=1"},
{"<2>CTRL-EVENT-SCAN-RESULTS ", true, 2, "CTRL-EVENT-SCAN-RESULTS", ""},
{"OK", false, 0, "", ""},
{"", false, 0, "", ""},
{"<x>BROKEN", false, 0, "", ""},
}
for _, tc := range testCases {
event, ok := parseWpaEventLine(tc.line)
assert.Equal(t, tc.ok, ok, "line=%q", tc.line)
if !tc.ok {
continue
}
assert.Equal(t, tc.priority, event.priority, "line=%q", tc.line)
assert.Equal(t, tc.name, event.name, "line=%q", tc.line)
assert.Equal(t, tc.args, event.args, "line=%q", tc.line)
}
}
func TestParseWpaTempDisabled(t *testing.T) {
ssid, reason := parseWpaTempDisabled(`id=1 ssid="Home Net" auth_failures=1 duration=10 reason=WRONG_KEY`)
assert.Equal(t, "Home Net", ssid)
assert.Equal(t, "WRONG_KEY", reason)
ssid, reason = parseWpaTempDisabled(`id=2 ssid="quo\"te" auth_failures=3 duration=20 reason=CONN_FAILED`)
assert.Equal(t, `quo"te`, ssid)
assert.Equal(t, "CONN_FAILED", reason)
ssid, reason = parseWpaTempDisabled("garbage")
assert.Empty(t, ssid)
assert.Empty(t, reason)
}
func TestDecodeWpaSSIDText(t *testing.T) {
testCases := []struct {
encoded string
expected string
}{
{"Plain", "Plain"},
{`With\tTab`, "With\tTab"},
{`With\\Backslash`, `With\Backslash`},
{`With\"Quote`, `With"Quote`},
{`Hex\xc3\xa9`, "Hexé"},
{`Trailing\`, `Trailing\`},
{`Bad\xzz`, `Bad\xzz`},
}
for _, tc := range testCases {
assert.Equal(t, tc.expected, decodeWpaSSIDText(tc.encoded), "encoded=%q", tc.encoded)
}
}
func TestWpaQuotedString(t *testing.T) {
quoted, err := wpaQuotedString("passphrase")
assert.NoError(t, err)
assert.Equal(t, `"passphrase"`, quoted)
_, err = wpaQuotedString(`with"quote`)
assert.Error(t, err)
_, err = wpaQuotedString("with\nnewline")
assert.Error(t, err)
}
func TestParseWpaConfigPassphrase(t *testing.T) {
config := "ctrl_interface=/var/run/wpa_supplicant\n" +
"update_config=1\n" +
"\n" +
"network={\n" +
"\tssid=\"Home\"\n" +
"\tpsk=\"secret pass\"\n" +
"}\n" +
"\n" +
"network={\n" +
"\tssid=486578\n" +
"\tpsk=\"hexpass\"\n" +
"}\n" +
"\n" +
"network={\n" +
"\tssid=\"RawKey\"\n" +
"\tpsk=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n" +
"}\n"
pass, err := parseWpaConfigPassphrase(config, "Home")
assert.NoError(t, err)
assert.Equal(t, "secret pass", pass)
pass, err = parseWpaConfigPassphrase(config, "Hex")
assert.NoError(t, err)
assert.Equal(t, "hexpass", pass)
_, err = parseWpaConfigPassphrase(config, "RawKey")
assert.Error(t, err, "raw hex PSK is not a passphrase")
_, err = parseWpaConfigPassphrase(config, "Unknown")
assert.Error(t, err)
}
func startFakeWpaSocket(t *testing.T, handler func(cmd string) []string) string {
t.Helper()
sockPath := filepath.Join(t.TempDir(), "wlan0")
conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sockPath, Net: "unixgram"})
if err != nil {
t.Fatalf("listen fake wpa socket: %v", err)
}
t.Cleanup(func() { conn.Close() })
go func() {
buf := make([]byte, 4096)
for {
n, addr, err := conn.ReadFromUnix(buf)
if err != nil {
return
}
for _, reply := range handler(string(buf[:n])) {
conn.WriteToUnix([]byte(reply), addr)
}
}
}()
return sockPath
}
func TestWpaCtrlConn_Request(t *testing.T) {
sockPath := startFakeWpaSocket(t, func(cmd string) []string {
switch cmd {
case "PING":
return []string{"PONG\n"}
case "SCAN":
return []string{"<2>CTRL-EVENT-SCAN-STARTED ", "OK\n"}
default:
return []string{"FAIL\n"}
}
})
conn, err := newWpaCtrlConn(sockPath)
if err != nil {
t.Fatalf("open wpa_ctrl conn: %v", err)
}
defer conn.close()
reply, err := conn.request("PING")
assert.NoError(t, err)
assert.Equal(t, "PONG", reply)
reply, err = conn.request("SCAN")
assert.NoError(t, err)
assert.Equal(t, "OK", reply, "unsolicited event datagrams must be skipped")
reply, err = conn.request("BOGUS")
assert.NoError(t, err)
assert.Equal(t, "FAIL", reply)
}
func TestWpaSupplicantBackend_ClassifyAttempt(t *testing.T) {
backend, _ := NewWpaSupplicantBackend()
att := &wpaConnectAttempt{ssid: "Home", sawTempDisabled: true}
assert.Equal(t, "bad-credentials", backend.classifyAttempt(att))
att = &wpaConnectAttempt{ssid: "Home"}
assert.Equal(t, "no-such-ssid", backend.classifyAttempt(att))
backend.recentScansMu.Lock()
backend.recentScans["Home"] = time.Now()
backend.recentScansMu.Unlock()
assert.Equal(t, "assoc-timeout", backend.classifyAttempt(att))
}
@@ -0,0 +1,103 @@
package network
import "fmt"
func (b *WpaSupplicantBackend) ConnectEthernet() error {
return fmt.Errorf("wired control not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) DisconnectEthernet() error {
return fmt.Errorf("wired control not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) DisconnectEthernetDevice(device string) error {
return fmt.Errorf("wired control not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ActivateWiredConnection(uuid string) error {
return fmt.Errorf("wired control not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ListVPNProfiles() ([]VPNProfile, error) {
return nil, fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ListActiveVPN() ([]VPNActive, error) {
return nil, fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) DisconnectVPN(uuidOrName string) error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) DisconnectAllVPN() error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ClearVPNCredentials(uuidOrName string) error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ListVPNPlugins() ([]VPNPlugin, error) {
return nil, fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
return nil, fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
return nil, fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) UpdateVPNConfig(uuid string, updates map[string]any) error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) DeleteVPN(uuidOrName string) error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
return fmt.Errorf("VPN not supported by wpa_supplicant backend")
}
func (b *WpaSupplicantBackend) ScanWiFiDevice(device string) error {
return b.ScanWiFi()
}
func (b *WpaSupplicantBackend) DisconnectWiFiDevice(device string) error {
return b.DisconnectWiFi()
}
func (b *WpaSupplicantBackend) GetWiFiDevices() []WiFiDevice {
b.stateMutex.RLock()
defer b.stateMutex.RUnlock()
return b.getWiFiDevicesLocked()
}
func (b *WpaSupplicantBackend) getWiFiDevicesLocked() []WiFiDevice {
if b.state.WiFiDevice == "" {
return nil
}
stateStr := "disconnected"
if b.state.WiFiConnected {
stateStr = "connected"
}
return []WiFiDevice{{
Name: b.state.WiFiDevice,
State: stateStr,
Connected: b.state.WiFiConnected,
SSID: b.state.WiFiSSID,
BSSID: b.state.WiFiBSSID,
Signal: b.state.WiFiSignal,
IP: b.state.WiFiIP,
Networks: b.state.WiFiNetworks,
}}
}
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -2,6 +2,7 @@ package network
import (
"fmt"
"os"
"github.com/godbus/dbus/v5"
)
@@ -14,6 +15,7 @@ const (
BackendIwd
BackendConnMan
BackendNetworkd
BackendWpaSupplicant
)
func nameHasOwner(bus *dbus.Conn, name string) (bool, error) {
@@ -35,11 +37,25 @@ type DetectResult struct {
ChosenReason string
}
func wpaSupplicantCtrlDirPresent() bool {
info, err := os.Stat(wpaSupplicantCtrlDir)
return err == nil && info.IsDir()
}
func DetectNetworkStack() (*DetectResult, error) {
bus, err := dbus.ConnectSystemBus()
if err != nil {
// FreeBSD and minimal Linux systems may run wpa_supplicant with no
// system bus at all; the control directory is the only signal there.
if !wpaSupplicantCtrlDirPresent() {
return nil, fmt.Errorf("connect system bus: %w", err)
}
return &DetectResult{
Backend: BackendWpaSupplicant,
HasWpaSupp: true,
ChosenReason: "System bus unreachable; wpa_supplicant control directory present. Using wpa_ctrl interface.",
}, nil
}
defer bus.Close()
hasNM, _ := nameHasOwner(bus, "org.freedesktop.NetworkManager")
@@ -76,10 +92,14 @@ func DetectNetworkStack() (*DetectResult, error) {
case hasNetworkd:
res.Backend = BackendNetworkd
res.ChosenReason = "systemd-networkd detected (no NM/ConnMan). Using networkd for L3 and wired."
case wpaSupplicantCtrlDirPresent():
res.Backend = BackendWpaSupplicant
res.HasWpaSupp = true
res.ChosenReason = "No NM/ConnMan/iwd/networkd; wpa_supplicant control directory present. Using wpa_ctrl interface."
default:
res.Backend = BackendNone
if hasWpa {
res.ChosenReason = "No NM/ConnMan/iwd; wpa_supplicant present. Consider a wpa_supplicant path."
res.ChosenReason = "No NM/ConnMan/iwd; wpa_supplicant D-Bus name present but control directory missing."
} else {
res.ChosenReason = "No known network manager bus names found."
}
@@ -13,6 +13,7 @@ func TestBackendType_Constants(t *testing.T) {
assert.Equal(t, BackendType(2), BackendIwd)
assert.Equal(t, BackendType(3), BackendConnMan)
assert.Equal(t, BackendType(4), BackendNetworkd)
assert.Equal(t, BackendType(5), BackendWpaSupplicant)
}
func TestDetectResult_HasNetworkdField(t *testing.T) {
+9 -1
View File
@@ -12,7 +12,8 @@ import (
)
// ErrNoNetworkBackend is returned when no supported network management daemon
// (NetworkManager, iwd, systemd-networkd, ConnMan) is present on the system.
// (NetworkManager, iwd, systemd-networkd, ConnMan, wpa_supplicant) is present
// on the system.
var ErrNoNetworkBackend = errors.New("no supported network backend found")
func NewManager() (*Manager, error) {
@@ -62,6 +63,13 @@ func NewManager() (*Manager, error) {
backend = nd
}
case BackendWpaSupplicant:
wpa, err := NewWpaSupplicantBackend()
if err != nil {
return nil, fmt.Errorf("failed to create wpa_supplicant backend: %w", err)
}
backend = wpa
default:
return nil, fmt.Errorf("%w: %s", ErrNoNetworkBackend, detection.ChosenReason)
}
@@ -8,7 +8,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/godbus/dbus/v5"
"golang.org/x/sys/unix"
)
const resumeDelay = 3 * time.Second
@@ -95,21 +94,3 @@ func (m *Manager) Close() {
}
log.Info("TrayRecovery manager closed")
}
// timeSuspended returns how long the system has spent in suspend since boot.
// It is the difference between CLOCK_BOOTTIME (includes suspend) and
// CLOCK_MONOTONIC (excludes suspend).
func timeSuspended() time.Duration {
var bt, mt unix.Timespec
if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &bt); err != nil {
return 0
}
if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &mt); err != nil {
return 0
}
diff := (bt.Sec-mt.Sec)*int64(time.Second) + (bt.Nsec - mt.Nsec)
if diff < 0 {
return 0
}
return time.Duration(diff)
}
@@ -0,0 +1,9 @@
package trayrecovery
import "time"
// FreeBSD has no CLOCK_BOOTTIME (clock_gettime(2)), so suspend time since
// boot cannot be measured; the startup recovery scan is skipped.
func timeSuspended() time.Duration {
return 0
}
@@ -0,0 +1,25 @@
package trayrecovery
import (
"time"
"golang.org/x/sys/unix"
)
// timeSuspended returns how long the system has spent in suspend since boot.
// It is the difference between CLOCK_BOOTTIME (includes suspend) and
// CLOCK_MONOTONIC (excludes suspend).
func timeSuspended() time.Duration {
var bt, mt unix.Timespec
if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &bt); err != nil {
return 0
}
if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &mt); err != nil {
return 0
}
diff := (bt.Sec-mt.Sec)*int64(time.Second) + (bt.Nsec - mt.Nsec)
if diff < 0 {
return 0
}
return time.Duration(diff)
}
+2 -10
View File
@@ -13,12 +13,12 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/godbus/dbus/v5"
"golang.org/x/sys/unix"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm"
)
const animKelvinStep = 25
@@ -920,7 +920,7 @@ func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
return fmt.Errorf("gamma control invalid")
}
fd, err := MemfdCreate("gamma-ramp", 0)
fd, err := shm.CreateAnonFd("gamma-ramp")
if err != nil {
return err
}
@@ -1295,11 +1295,3 @@ func (m *Manager) Close() {
m.dbusConn.Close()
}
}
func MemfdCreate(name string, flags int) (int, error) {
fd, err := unix.MemfdCreate(name, flags)
if err != nil {
return -1, err
}
return fd, nil
}
+29
View File
@@ -0,0 +1,29 @@
package trash
import "golang.org/x/sys/unix"
// readMountPoints returns user-visible mount points via getfsstat(2),
// skipping pseudo and system filesystems.
func readMountPoints() []string {
n, err := unix.Getfsstat(nil, unix.MNT_NOWAIT)
if err != nil || n == 0 {
return nil
}
stats := make([]unix.Statfs_t, n)
n, err = unix.Getfsstat(stats, unix.MNT_NOWAIT)
if err != nil {
return nil
}
var out []string
seen := map[string]bool{}
for _, st := range stats[:n] {
mp := unix.ByteSliceToString(st.Mntonname[:])
if mp == "" || skipMountPoint(mp, seen) {
continue
}
seen[mp] = true
out = append(out, mp)
}
return out
}
+30
View File
@@ -0,0 +1,30 @@
package trash
import (
"os"
"strings"
)
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
// skipping pseudo and system filesystems.
func readMountPoints() []string {
data, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return nil
}
var out []string
seen := map[string]bool{}
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
mp := fields[4]
if skipMountPoint(mp, seen) {
continue
}
seen[mp] = true
out = append(out, mp)
}
return out
}
+6 -29
View File
@@ -275,39 +275,16 @@ func allTrashDirs() []string {
return dirs
}
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
// skipping pseudo and system filesystems.
func readMountPoints() []string {
data, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return nil
func skipMountPoint(mp string, seen map[string]bool) bool {
if mp == "/" || seen[mp] {
return true
}
skipPrefixes := []string{"/proc", "/sys", "/dev"}
var out []string
seen := map[string]bool{}
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
mp := fields[4]
if mp == "/" {
continue
}
skip := false
for _, p := range skipPrefixes {
for _, p := range []string{"/proc", "/sys", "/dev"} {
if mp == p || strings.HasPrefix(mp, p+"/") {
skip = true
break
return true
}
}
if skip || seen[mp] {
continue
}
seen[mp] = true
out = append(out, mp)
}
return out
return false
}
func List() ([]Entry, error) {
+2 -2
View File
@@ -43,9 +43,9 @@ type Buffer struct {
func CreateBuffer(width, height, stride int) (*Buffer, error) {
size := stride * height
fd, err := unix.MemfdCreate("dms-shm", 0)
fd, err := CreateAnonFd("dms-shm")
if err != nil {
return nil, fmt.Errorf("memfd_create: %w", err)
return nil, fmt.Errorf("create shm fd: %w", err)
}
if err := unix.Ftruncate(fd, int64(size)); err != nil {
+28
View File
@@ -0,0 +1,28 @@
package shm
import (
"unsafe"
"golang.org/x/sys/unix"
)
// Mirrors FreeBSD libc memfd_create (lib/libc/gen/memfd_create.c):
// shm_open2(SHM_ANON, O_RDWR, 0, SHM_GROW_ON_WRITE, name).
// x/sys/unix has no wrapper for SYS_shm_open2 (571, sys/sys/syscall.h).
const (
sysShmOpen2 = 571
shmAnon = 1 // SHM_ANON ((char *)1), sys/sys/mman.h
shmGrowOnWrite = 0x2 // SHM_GROW_ON_WRITE, sys/sys/mman.h
)
func CreateAnonFd(name string) (int, error) {
p, err := unix.BytePtrFromString(name)
if err != nil {
return -1, err
}
fd, _, errno := unix.Syscall6(sysShmOpen2, shmAnon, unix.O_RDWR, 0, shmGrowOnWrite, uintptr(unsafe.Pointer(p)), 0)
if errno != 0 {
return -1, errno
}
return int(fd), nil
}
+7
View File
@@ -0,0 +1,7 @@
package shm
import "golang.org/x/sys/unix"
func CreateAnonFd(name string) (int, error) {
return unix.MemfdCreate(name, 0)
}
Generated
+3 -3
View File
@@ -3,11 +3,11 @@
"dank-qml-common": {
"flake": false,
"locked": {
"lastModified": 1784386873,
"narHash": "sha256-n0LKLirPnNSXYxElK1IpnybEwU0nnyso4kpsEFX88U0=",
"lastModified": 1784471445,
"narHash": "sha256-hRTmcd3Ms9DF3X35ZiapXM15rG5X1DtIdlrQZwRINFE=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"rev": "4fa313766ab5cd2766be0f87a163305ca9af6faf",
"rev": "493b609cc04659f1388f5738e8f65d6c531994fe",
"type": "github"
},
"original": {
@@ -184,6 +184,8 @@ done
}
Row {
// charge_control_* live in Linux sysfs; no BSD equivalent
visible: Qt.platform.os === "linux"
width: parent.width
height: applyButton.height
layoutDirection: I18n.isRtl ? Qt.LeftToRight : Qt.RightToLeft
+10
View File
@@ -361,6 +361,16 @@ EOFCONFIG
wireplumberReloading = true;
wireplumberReloadStarted();
if (!SessionService.systemctlCommandAvailable) {
Proc.runCommand("restartWireplumber", ["sh", "-c", "pkill -x wireplumber; sleep 1"], () => {
Quickshell.execDetached(["wireplumber"]);
wireplumberReloading = false;
ToastService.showInfo(I18n.tr("Audio system restarted"), I18n.tr("Device names updated"));
wireplumberReloadCompleted(true);
}, 5000);
return;
}
Proc.runCommand("restartWireplumber", ["systemctl", "--user", "restart", "wireplumber"], (output, exitCode) => {
wireplumberReloading = false;
+6 -2
View File
@@ -948,8 +948,12 @@ Singleton {
// systemd user environment from previous sessions and lie. Unset
// WAYLAND_DISPLAY falls back to "wayland-0", mirroring wl_display_connect.
// /proc/net/unix: field 6 is state (01 = listening), 7 inode, 8 bound path.
// The BSDs have no /proc/net; sockstat(1) -l -u lists listening unix
// sockets as USER COMMAND PID FD PROTO LOCAL-ADDRESS.
function detectCompositor() {
const script = 'sock="${WAYLAND_DISPLAY:-wayland-0}"; case "$sock" in /*) ;; *) sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$sock" ;; esac; inode=$(awk -v p="$sock" \'$8 == p && $6 == 1 {print $7; exit}\' /proc/net/unix); [ -n "$inode" ] || exit 1; fd=$(find /proc/[0-9]*/fd/ -mindepth 1 -maxdepth 1 -lname "socket:\\[$inode\\]" 2>/dev/null | head -n1); [ -n "$fd" ] || exit 1; pid="${fd#/proc/}"; cat "/proc/${pid%%/*}/comm"';
const procScript = 'sock="${WAYLAND_DISPLAY:-wayland-0}"; case "$sock" in /*) ;; *) sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$sock" ;; esac; inode=$(awk -v p="$sock" \'$8 == p && $6 == 1 {print $7; exit}\' /proc/net/unix); [ -n "$inode" ] || exit 1; fd=$(find /proc/[0-9]*/fd/ -mindepth 1 -maxdepth 1 -lname "socket:\\[$inode\\]" 2>/dev/null | head -n1); [ -n "$fd" ] || exit 1; pid="${fd#/proc/}"; cat "/proc/${pid%%/*}/comm"';
const sockstatScript = 'sock="${WAYLAND_DISPLAY:-wayland-0}"; case "$sock" in /*) ;; *) sock="${XDG_RUNTIME_DIR:-/var/run/user/$(id -u)}/$sock" ;; esac; sockstat -l -u | awk -v p="$sock" \'$6 == p {print $2; exit}\'';
const script = Qt.platform.os === "unix" ? sockstatScript : procScript;
Proc.runCommand("waylandSocketOwner", ["sh", "-c", script], (output, exitCode) => {
const comm = (exitCode === 0 && output) ? output.trim().toLowerCase() : "";
const name = _compositorNameFromComm(comm);
@@ -1037,7 +1041,7 @@ Singleton {
{
name: "labwc",
present: !!labwcPid,
test: ["sh", "-c", "[ \"$(cat /proc/\"$LABWC_PID\"/comm 2>/dev/null)\" = labwc ]"],
test: ["sh", "-c", "[ \"$(ps -p \"$LABWC_PID\" -o comm= 2>/dev/null)\" = labwc ]"],
detail: "LABWC_PID " + labwcPid
},
{
+18
View File
@@ -12,6 +12,8 @@ Singleton {
id: root
readonly property var log: Log.scoped("SessionService")
// Qt.platform.os is "unix" on the BSDs (qtdeclarative qqmlplatform.cpp)
readonly property bool isBSD: Qt.platform.os === "unix"
property bool hasUwsm: false
property bool isElogind: false
property bool loginctlCommandAvailable: false
@@ -351,10 +353,26 @@ Singleton {
}
function powerManagerCommand(action) {
if (isBSD)
return bsdPowerCommand(action);
const useLoginctl = isElogind || (loginctlCommandAvailable && !systemctlCommandAvailable);
return [useLoginctl ? "loginctl" : "systemctl", action];
}
function bsdPowerCommand(action) {
switch (action) {
case "suspend":
case "suspend-then-hibernate":
return ["acpiconf", "-s", "3"];
case "hibernate":
return ["acpiconf", "-s", "4"];
case "reboot":
return ["shutdown", "-r", "now"];
default:
return ["shutdown", "-p", "now"];
}
}
function suspend() {
if (SettingsData.customPowerActionSuspend.length === 0) {
Quickshell.execDetached(powerManagerCommand("suspend"));
+17 -5
View File
@@ -10,6 +10,9 @@ Singleton {
readonly property var log: Log.scoped("UsersService")
// Qt.platform.os is "unix" on the BSDs; user management goes through pw(8)
readonly property bool isBSD: Qt.platform.os === "unix"
property var users: []
property string adminGroup: "wheel"
property string greeterGroup: "greeter"
@@ -286,7 +289,7 @@ Singleton {
property string op: "passwd"
property var cb: null
property string capturedErr: ""
command: ["pkexec", "sh", "-c", "head -n1 | chpasswd"]
command: root.isBSD ? ["pkexec", "pw", "usermod", "-n", targetUser, "-h", "0"] : ["pkexec", "sh", "-c", "head -n1 | chpasswd"]
stdinEnabled: true
running: false
stdout: StdioCollector {}
@@ -294,6 +297,9 @@ Singleton {
onStreamFinished: chpasswdProc.capturedErr = text || ""
}
onStarted: {
if (root.isBSD)
chpasswdProc.write(chpasswdProc.targetPassword + "\n");
else
chpasswdProc.write(chpasswdProc.targetUser + ":" + chpasswdProc.targetPassword + "\n");
}
onExited: exitCode => {
@@ -414,7 +420,7 @@ Singleton {
function _runUseradd(username, password, addToAdmin, addToGreeter, callback) {
const proc = useraddComp.createObject(root, {
command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
command: root.isBSD ? ["pkexec", "pw", "useradd", "-n", username, "-m", "-s", "/bin/sh"] : ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
targetUser: username,
targetPassword: password,
addAdmin: addToAdmin,
@@ -436,15 +442,21 @@ Singleton {
function _runUserdel(username, callback) {
const proc = userdelComp.createObject(root, {
command: ["pkexec", "userdel", "-r", username],
command: root.isBSD ? ["pkexec", "pw", "userdel", "-n", username, "-r"] : ["pkexec", "userdel", "-r", username],
targetUser: username,
cb: callback
});
proc.running = true;
}
function _groupMemberCmd(group, username, add) {
if (root.isBSD)
return ["pkexec", "pw", "groupmod", group, add ? "-m" : "-d", username];
return add ? ["pkexec", "usermod", "-aG", group, username] : ["pkexec", "gpasswd", "-d", username, group];
}
function _runAdminToggle(username, makeAdmin, callback) {
const cmd = makeAdmin ? ["pkexec", "usermod", "-aG", root.adminGroup, username] : ["pkexec", "gpasswd", "-d", username, root.adminGroup];
const cmd = _groupMemberCmd(root.adminGroup, username, makeAdmin);
const proc = adminToggleComp.createObject(root, {
command: cmd,
targetUser: username,
@@ -455,7 +467,7 @@ Singleton {
}
function _runGreeterToggle(username, enableGreeter, callback) {
const cmd = enableGreeter ? ["pkexec", "usermod", "-aG", root.greeterGroup, username] : ["pkexec", "gpasswd", "-d", username, root.greeterGroup];
const cmd = _groupMemberCmd(root.greeterGroup, username, enableGreeter);
const proc = greeterToggleComp.createObject(root, {
command: cmd,
targetUser: username,
+2 -1
View File
@@ -46,7 +46,8 @@ Singleton {
property int _geocodeReqId: 0
property var _pendingCoords: null
readonly property var lowPriorityCmd: ["nice", "-n", "19", "ionice", "-c3"]
// ionice is util-linux only; the BSDs get plain nice
readonly property var lowPriorityCmd: Qt.platform.os === "linux" ? ["nice", "-n", "19", "ionice", "-c3"] : ["nice", "-n", "19"]
readonly property var curlBaseCmd: ["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "100k", "--compressed"]
property var weatherIcons: ({