diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 02fc28264..378153272 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec39ed261..07c9c2adc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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-.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 diff --git a/Makefile b/Makefile index e68a6a543..fea66a10f 100644 --- a/Makefile +++ b/Makefile @@ -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..." diff --git a/core/Makefile b/core/Makefile index fe68c0081..2d9999d02 100644 --- a/core/Makefile +++ b/core/Makefile @@ -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)" diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go index 654e0c2d4..b0c99ebef 100644 --- a/core/cmd/dms/commands_doctor.go +++ b/core/cmd/dms/commands_doctor.go @@ -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 "" } diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index 4b3bf01b0..574db3f9d 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -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 { diff --git a/core/internal/matugen/signal_freebsd.go b/core/internal/matugen/signal_freebsd.go new file mode 100644 index 000000000..24b0e61cb --- /dev/null +++ b/core/internal/matugen/signal_freebsd.go @@ -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() +} diff --git a/core/internal/matugen/signal_linux.go b/core/internal/matugen/signal_linux.go new file mode 100644 index 000000000..6f2fa740f --- /dev/null +++ b/core/internal/matugen/signal_linux.go @@ -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) + } + } +} diff --git a/core/internal/pam/pam.go b/core/internal/pam/pam.go index c20fe51bb..e90501e1b 100644 --- a/core/internal/pam/pam.go +++ b/core/internal/pam/pam.go @@ -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 diff --git a/core/internal/server/brightness/backlight_freebsd.go b/core/internal/server/brightness/backlight_freebsd.go new file mode 100644 index 000000000..1fc0d19f7 --- /dev/null +++ b/core/internal/server/brightness/backlight_freebsd.go @@ -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 +} diff --git a/core/internal/server/brightness/devd_freebsd.go b/core/internal/server/brightness/devd_freebsd.go new file mode 100644 index 000000000..2d2863e59 --- /dev/null +++ b/core/internal/server/brightness/devd_freebsd.go @@ -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) +} diff --git a/core/internal/server/brightness/manager.go b/core/internal/server/brightness/manager.go index 4cf7dd493..3c395562d 100644 --- a/core/internal/server/brightness/manager.go +++ b/core/internal/server/brightness/manager.go @@ -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 { diff --git a/core/internal/server/brightness/native_freebsd.go b/core/internal/server/brightness/native_freebsd.go new file mode 100644 index 000000000..fce414dd6 --- /dev/null +++ b/core/internal/server/brightness/native_freebsd.go @@ -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) +} diff --git a/core/internal/server/brightness/native_linux.go b/core/internal/server/brightness/native_linux.go new file mode 100644 index 000000000..fea40b730 --- /dev/null +++ b/core/internal/server/brightness/native_linux.go @@ -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) +} diff --git a/core/internal/server/brightness/sysfs_logind_test.go b/core/internal/server/brightness/sysfs_logind_linux_test.go similarity index 98% rename from core/internal/server/brightness/sysfs_logind_test.go rename to core/internal/server/brightness/sysfs_logind_linux_test.go index 6ba84b8ae..00aa2fa22 100644 --- a/core/internal/server/brightness/sysfs_logind_test.go +++ b/core/internal/server/brightness/sysfs_logind_linux_test.go @@ -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{}), } diff --git a/core/internal/server/brightness/types.go b/core/internal/server/brightness/types.go index 0eb2c8307..12aed08b9 100644 --- a/core/internal/server/brightness/types.go +++ b/core/internal/server/brightness/types.go @@ -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 { diff --git a/core/internal/server/brightness/udev.go b/core/internal/server/brightness/udev_linux.go similarity index 100% rename from core/internal/server/brightness/udev.go rename to core/internal/server/brightness/udev_linux.go diff --git a/core/internal/server/brightness/udev_test.go b/core/internal/server/brightness/udev_linux_test.go similarity index 99% rename from core/internal/server/brightness/udev_test.go rename to core/internal/server/brightness/udev_linux_test.go index a5c406bba..71b4cccf9 100644 --- a/core/internal/server/brightness/udev_test.go +++ b/core/internal/server/brightness/udev_linux_test.go @@ -33,7 +33,7 @@ func setupTestManager(t *testing.T) (*Manager, string) { m := &Manager{ sysfsBackend: sysfs, - sysfsReady: true, + nativeReady: true, stopChan: make(chan struct{}), } diff --git a/core/internal/server/network/backend_wpa.go b/core/internal/server/network/backend_wpa.go new file mode 100644 index 000000000..1e4bdbbab --- /dev/null +++ b/core/internal/server/network/backend_wpa.go @@ -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- 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() +} diff --git a/core/internal/server/network/backend_wpa_ctrl.go b/core/internal/server/network/backend_wpa_ctrl.go new file mode 100644 index 000000000..caa5d052c --- /dev/null +++ b/core/internal/server/network/backend_wpa_ctrl.go @@ -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 "" 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() +} diff --git a/core/internal/server/network/backend_wpa_ethernet.go b/core/internal/server/network/backend_wpa_ethernet.go new file mode 100644 index 000000000..06b5c46ee --- /dev/null +++ b/core/internal/server/network/backend_wpa_ethernet.go @@ -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 +} diff --git a/core/internal/server/network/backend_wpa_events.go b/core/internal/server/network/backend_wpa_events.go new file mode 100644 index 000000000..0b64e93b1 --- /dev/null +++ b/core/internal/server/network/backend_wpa_events.go @@ -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() +} diff --git a/core/internal/server/network/backend_wpa_test.go b/core/internal/server/network/backend_wpa_test.go new file mode 100644 index 000000000..d32a6c417 --- /dev/null +++ b/core/internal/server/network/backend_wpa_test.go @@ -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, "", ""}, + {"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)) +} diff --git a/core/internal/server/network/backend_wpa_unimplemented.go b/core/internal/server/network/backend_wpa_unimplemented.go new file mode 100644 index 000000000..9898aa408 --- /dev/null +++ b/core/internal/server/network/backend_wpa_unimplemented.go @@ -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, + }} +} diff --git a/core/internal/server/network/backend_wpa_wifi.go b/core/internal/server/network/backend_wpa_wifi.go new file mode 100644 index 000000000..f16d0b46f --- /dev/null +++ b/core/internal/server/network/backend_wpa_wifi.go @@ -0,0 +1,1008 @@ +package network + +import ( + "encoding/hex" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type wpaScanResult struct { + BSSID string + Frequency uint32 + Level int + Flags string + SSID string +} + +type wpaStatus struct { + wpaState string + ssid string + bssid string + ipAddress string + keyMgmt string + networkID int +} + +type wpaSavedNetwork struct { + id int + ssid string + bssid string + current bool + disabled bool + tempDisabled bool +} + +func signalPercentFromDbm(dbm int) uint8 { + switch { + case dbm > 0: + return 100 + case dbm < -100: + return 0 + default: + return uint8(dbm + 100) + } +} + +// decodeWpaSSIDText reverses printf_encode from contrib/wpa/src/utils/common.c +// (freebsd-src), which wpa_supplicant applies to SSIDs in ctrl replies/events. +func decodeWpaSSIDText(s string) string { + if !strings.ContainsRune(s, '\\') { + return s + } + + var out strings.Builder + out.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 >= len(s) { + out.WriteByte(s[i]) + continue + } + i++ + switch s[i] { + case '"': + out.WriteByte('"') + case '\\': + out.WriteByte('\\') + case 'e': + out.WriteByte(0x1b) + case 'n': + out.WriteByte('\n') + case 'r': + out.WriteByte('\r') + case 't': + out.WriteByte('\t') + case 'x': + if i+2 < len(s) { + if v, err := strconv.ParseUint(s[i+1:i+3], 16, 8); err == nil { + out.WriteByte(byte(v)) + i += 2 + continue + } + } + out.WriteByte('\\') + out.WriteByte('x') + default: + out.WriteByte('\\') + out.WriteByte(s[i]) + } + } + return out.String() +} + +// SET_NETWORK string values are either quoted literals (no escape handling, +// content runs to the last '"') or unquoted hex; see wpa_config_parse_string +// in contrib/wpa/src/utils/common.c (freebsd-src). +func wpaQuotedString(s string) (string, error) { + if strings.ContainsAny(s, "\"\n\r") { + return "", fmt.Errorf("value cannot contain quotes or newlines") + } + return `"` + s + `"`, nil +} + +func wpaHexString(s string) string { + return hex.EncodeToString([]byte(s)) +} + +func parseWpaScanResults(text string) []wpaScanResult { + var results []wpaScanResult + for _, line := range strings.Split(text, "\n") { + if line == "" || strings.HasPrefix(line, "bssid /") { + continue + } + + fields := strings.SplitN(line, "\t", 5) + if len(fields) < 4 { + continue + } + + freq, err := strconv.ParseUint(fields[1], 10, 32) + if err != nil { + continue + } + level, err := strconv.Atoi(fields[2]) + if err != nil { + continue + } + + ssid := "" + if len(fields) == 5 { + ssid = decodeWpaSSIDText(fields[4]) + } + + results = append(results, wpaScanResult{ + BSSID: fields[0], + Frequency: uint32(freq), + Level: level, + Flags: fields[3], + SSID: ssid, + }) + } + return results +} + +func wpaFlagsSecured(flags string) bool { + return strings.Contains(flags, "WPA") || strings.Contains(flags, "WEP") +} + +func wpaFlagsEnterprise(flags string) bool { + return strings.Contains(flags, "-EAP") +} + +func wpaWiFiNetworksFromScanResults(results []wpaScanResult) []WiFiNetwork { + best := make(map[string]WiFiNetwork, len(results)) + for _, result := range results { + if result.SSID == "" { + continue + } + + mode := "infrastructure" + if strings.Contains(result.Flags, "[IBSS]") { + mode = "adhoc" + } + + network := WiFiNetwork{ + SSID: result.SSID, + BSSID: result.BSSID, + Signal: signalPercentFromDbm(result.Level), + Secured: wpaFlagsSecured(result.Flags), + Enterprise: wpaFlagsEnterprise(result.Flags), + Frequency: result.Frequency, + Channel: frequencyToChannel(result.Frequency), + Mode: mode, + } + + if existing, ok := best[result.SSID]; ok && existing.Signal >= network.Signal { + continue + } + best[result.SSID] = network + } + + networks := make([]WiFiNetwork, 0, len(best)) + for _, network := range best { + networks = append(networks, network) + } + return networks +} + +func parseWpaStatus(text string) wpaStatus { + st := wpaStatus{networkID: -1} + for _, line := range strings.Split(text, "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + + switch key { + case "wpa_state": + st.wpaState = value + case "ssid": + st.ssid = decodeWpaSSIDText(value) + case "bssid": + st.bssid = value + case "ip_address": + st.ipAddress = value + case "key_mgmt": + st.keyMgmt = value + case "id": + if id, err := strconv.Atoi(value); err == nil { + st.networkID = id + } + } + } + return st +} + +func parseWpaSignalPollRSSI(text string) (int, bool) { + for _, line := range strings.Split(text, "\n") { + value, ok := strings.CutPrefix(line, "RSSI=") + if !ok { + continue + } + rssi, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + return rssi, true + } + return 0, false +} + +func parseWpaListNetworks(text string) []wpaSavedNetwork { + var networks []wpaSavedNetwork + for _, line := range strings.Split(text, "\n") { + if line == "" || strings.HasPrefix(line, "network id /") { + continue + } + + fields := strings.SplitN(line, "\t", 4) + if len(fields) < 3 { + continue + } + + id, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + + flags := "" + if len(fields) == 4 { + flags = fields[3] + } + + networks = append(networks, wpaSavedNetwork{ + id: id, + ssid: decodeWpaSSIDText(fields[1]), + bssid: fields[2], + current: strings.Contains(flags, "[CURRENT]"), + disabled: strings.Contains(flags, "[DISABLED]"), + tempDisabled: strings.Contains(flags, "[TEMP-DISABLED]"), + }) + } + return networks +} + +func (b *WpaSupplicantBackend) requestOK(cmd string) error { + reply, err := b.cmd.request(cmd) + if err != nil { + return err + } + if reply != "OK" { + return fmt.Errorf("%s failed: %s", strings.Fields(cmd)[0], reply) + } + return nil +} + +func (b *WpaSupplicantBackend) setNetworkValue(id int, name, value string) error { + return b.requestOK(fmt.Sprintf("SET_NETWORK %d %s %s", id, name, value)) +} + +func (b *WpaSupplicantBackend) getNetworkValue(id int, name string) (string, error) { + reply, err := b.cmd.request(fmt.Sprintf("GET_NETWORK %d %s", id, name)) + if err != nil { + return "", err + } + if reply == "FAIL" { + return "", fmt.Errorf("GET_NETWORK %d %s failed", id, name) + } + return reply, nil +} + +// SAVE_CONFIG is refused unless the daemon config sets update_config=1 +// (wpa_supplicant_ctrl_iface_save_config in contrib/wpa/wpa_supplicant/ +// ctrl_iface.c, freebsd-src); runtime changes still apply, so only warn. +func (b *WpaSupplicantBackend) saveConfig() { + reply, err := b.cmd.request("SAVE_CONFIG") + if err != nil { + log.Warnf("wpa_supplicant SAVE_CONFIG failed: %v", err) + return + } + if reply != "OK" { + log.Warnf("wpa_supplicant SAVE_CONFIG rejected (requires update_config=1): %s", reply) + } +} + +func (b *WpaSupplicantBackend) removeNetworkQuiet(id int) { + if err := b.requestOK(fmt.Sprintf("REMOVE_NETWORK %d", id)); err != nil { + log.Warnf("failed to remove wpa network %d: %v", id, err) + return + } + b.saveConfig() +} + +func (b *WpaSupplicantBackend) updateState() error { + if b.cmd == nil { + return nil + } + + reply, err := b.cmd.request("STATUS") + if err != nil { + return fmt.Errorf("STATUS failed: %w", err) + } + st := parseWpaStatus(reply) + connected := st.wpaState == "COMPLETED" + + var signal uint8 + if connected { + if pollReply, err := b.cmd.request("SIGNAL_POLL"); err == nil { + if rssi, ok := parseWpaSignalPollRSSI(pollReply); ok { + signal = signalPercentFromDbm(rssi) + } + } + } + + wifiIP := interfaceIPv4(b.ifname) + if wifiIP == "" { + wifiIP = st.ipAddress + } + + ethDevices := b.ethernetDevices() + var ethDevice, ethIP string + ethConnected := false + for _, dev := range ethDevices { + ethDevice = dev.Name + if dev.Connected { + ethConnected = true + ethIP = dev.IP + break + } + } + + b.stateMutex.Lock() + b.state.WiFiConnected = connected + b.state.WiFiSignal = signal + if connected { + b.state.WiFiSSID = st.ssid + b.state.WiFiBSSID = st.bssid + b.state.WiFiIP = wifiIP + } else { + b.state.WiFiSSID = "" + b.state.WiFiBSSID = "" + b.state.WiFiIP = "" + } + b.state.EthernetDevices = ethDevices + b.state.EthernetDevice = ethDevice + b.state.EthernetConnected = ethConnected + b.state.EthernetIP = ethIP + b.state.WiredConnections = wiredConnectionsFromEthernetDevices(ethDevices) + + switch { + case ethConnected && ethIP != "": + b.state.NetworkStatus = StatusEthernet + case connected: + b.state.NetworkStatus = StatusWiFi + default: + b.state.NetworkStatus = StatusDisconnected + } + b.stateMutex.Unlock() + + return nil +} + +func (b *WpaSupplicantBackend) GetWiFiEnabled() (bool, error) { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return b.state.WiFiEnabled, nil +} + +// Bringing the interface itself up/down needs ifconfig(8) as root on FreeBSD; +// there is no wpa_ctrl command for it, so this backend reports it unsupported. +func (b *WpaSupplicantBackend) SetWiFiEnabled(enabled bool) error { + return fmt.Errorf("WiFi radio control not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) ScanWiFi() error { + if b.cmd == nil { + return fmt.Errorf("no WiFi device available") + } + + reply, err := b.cmd.request("SCAN") + if err != nil { + return fmt.Errorf("scan request failed: %w", err) + } + + switch reply { + case "OK": + return nil + case "FAIL-BUSY": + return fmt.Errorf("scan already in progress") + default: + return fmt.Errorf("scan request failed: %s", reply) + } +} + +func (b *WpaSupplicantBackend) fetchSavedProfiles() (map[string]savedWiFiProfile, map[string]int, error) { + reply, err := b.cmd.request("LIST_NETWORKS") + if err != nil { + return nil, nil, fmt.Errorf("LIST_NETWORKS failed: %w", err) + } + + entries := parseWpaListNetworks(reply) + profiles := make(map[string]savedWiFiProfile, len(entries)) + ids := make(map[string]int, len(entries)) + + for _, entry := range entries { + if entry.ssid == "" { + continue + } + + keyMgmt, err := b.getNetworkValue(entry.id, "key_mgmt") + if err != nil { + keyMgmt = "" + } + scanSSID, _ := b.getNetworkValue(entry.id, "scan_ssid") + + profile := savedWiFiProfile{ + Autoconnect: !entry.disabled, + Hidden: scanSSID == "1", + Secured: keyMgmt != "" && keyMgmt != "NONE", + Enterprise: strings.Contains(keyMgmt, "EAP"), + Mode: "infrastructure", + } + + if existing, ok := profiles[entry.ssid]; ok { + profile.Autoconnect = profile.Autoconnect || existing.Autoconnect + profile.Hidden = profile.Hidden || existing.Hidden + profile.Secured = profile.Secured || existing.Secured + profile.Enterprise = profile.Enterprise || existing.Enterprise + } else { + ids[entry.ssid] = entry.id + } + + profiles[entry.ssid] = profile + } + + return profiles, ids, nil +} + +func (b *WpaSupplicantBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { + if b.cmd == nil { + return nil, fmt.Errorf("no WiFi device available") + } + + reply, err := b.cmd.request("SCAN_RESULTS") + if err != nil { + return nil, fmt.Errorf("SCAN_RESULTS failed: %w", err) + } + visibleNetworks := wpaWiFiNetworksFromScanResults(parseWpaScanResults(reply)) + + profiles, ids, err := b.fetchSavedProfiles() + if err != nil { + profiles = make(map[string]savedWiFiProfile) + } else { + b.storeSavedIDs(ids) + } + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + wifiSignal := b.state.WiFiSignal + b.stateMutex.RUnlock() + + networks, savedNetworks := refreshSavedWiFiState(visibleNetworks, profiles, currentSSID, wifiConnected) + networks = appendConnectedHiddenFallback(networks, profiles, currentSSID, wifiConnected, wifiSignal) + sortWiFiNetworks(networks) + + b.stateMutex.Lock() + b.state.WiFiNetworks = networks + b.state.SavedWiFiNetworks = savedNetworks + b.stateMutex.Unlock() + + now := time.Now() + b.recentScansMu.Lock() + for _, network := range networks { + b.recentScans[network.SSID] = now + } + b.recentScansMu.Unlock() + + return networks, nil +} + +func appendConnectedHiddenFallback(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool, wifiSignal uint8) []WiFiNetwork { + if !wifiConnected || currentSSID == "" { + return networks + } + for _, network := range networks { + if network.SSID == currentSSID { + return networks + } + } + + profile, saved := profiles[currentSSID] + return append(networks, WiFiNetwork{ + SSID: currentSSID, + Signal: wifiSignal, + Secured: profile.Secured || !saved, + Enterprise: profile.Enterprise, + Connected: true, + Saved: saved, + Autoconnect: profile.Autoconnect, + Hidden: true, + Mode: "infrastructure", + }) +} + +func (b *WpaSupplicantBackend) updateSavedWiFiNetworks() error { + profiles, ids, err := b.fetchSavedProfiles() + if err != nil { + return err + } + b.storeSavedIDs(ids) + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + b.stateMutex.RUnlock() + + wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, profiles, currentSSID, wifiConnected) + + b.stateMutex.Lock() + b.state.WiFiNetworks = wifiNetworks + b.state.SavedWiFiNetworks = savedNetworks + b.stateMutex.Unlock() + + return nil +} + +func (b *WpaSupplicantBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { + b.stateMutex.RLock() + networks := b.state.WiFiNetworks + b.stateMutex.RUnlock() + + var found *WiFiNetwork + for i := range networks { + if networks[i].SSID == ssid { + found = &networks[i] + break + } + } + + if found == nil { + return nil, fmt.Errorf("network not found: %s", ssid) + } + + return &NetworkInfoResponse{ + SSID: ssid, + Bands: []WiFiNetwork{*found}, + }, nil +} + +func (b *WpaSupplicantBackend) visibleNetwork(ssid string) (WiFiNetwork, bool) { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + + for _, network := range b.state.WiFiNetworks { + if network.SSID == ssid && !network.OutOfRange { + return network, true + } + } + return WiFiNetwork{}, false +} + +func (b *WpaSupplicantBackend) classifyAttempt(att *wpaConnectAttempt) string { + att.mu.Lock() + defer att.mu.Unlock() + + switch { + case att.sawTempDisabled: + return errdefs.ErrBadCredentials + case !b.seenInRecentScan(att.ssid): + return errdefs.ErrNoSuchSSID + default: + return errdefs.ErrAssocTimeout + } +} + +func (b *WpaSupplicantBackend) finalizeAttempt(att *wpaConnectAttempt, code string) { + att.mu.Lock() + if att.finalized { + att.mu.Unlock() + return + } + att.finalized = true + newlyAdded := att.newlyAdded + netID := att.netID + att.mu.Unlock() + + if code != "" && newlyAdded { + b.removeNetworkQuiet(netID) + } + + b.stateMutex.Lock() + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.LastError = code + b.stateMutex.Unlock() + + if err := b.updateState(); err != nil { + log.Warnf("failed to update wpa state after connect attempt: %v", err) + } + if err := b.updateSavedWiFiNetworks(); err != nil { + log.Warnf("failed to refresh saved networks after connect attempt: %v", err) + } + + if b.onStateChange != nil { + b.onStateChange() + } + + if code == errdefs.ErrBadCredentials { + b.maybeReplacePSK(att) + } +} + +func (b *WpaSupplicantBackend) maybeReplacePSK(att *wpaConnectAttempt) { + if b.promptBroker == nil { + return + } + + att.mu.Lock() + prompted := att.prompted + ssid := att.ssid + att.mu.Unlock() + if prompted { + return + } + + b.sigWG.Add(1) + go func() { + defer b.sigWG.Done() + + psk, ok := b.promptForPSK(ssid, "wrong-password") + if !ok { + return + } + + if err := b.ConnectWiFi(ConnectionRequest{SSID: ssid, Password: psk}); err != nil { + log.Warnf("failed to reconnect %s with replacement credentials: %v", ssid, err) + } + }() +} + +func (b *WpaSupplicantBackend) startAttemptWatchdog(att *wpaConnectAttempt) { + b.sigWG.Add(1) + go func() { + defer b.sigWG.Done() + + timer := time.NewTimer(time.Until(att.deadline)) + defer timer.Stop() + + select { + case <-timer.C: + att.mu.Lock() + finalized := att.finalized + att.mu.Unlock() + if finalized { + return + } + b.finalizeAttempt(att, b.classifyAttempt(att)) + case <-b.stopChan: + } + }() +} + +func (b *WpaSupplicantBackend) ConnectWiFi(req ConnectionRequest) error { + if b.cmd == nil { + b.setConnectError(errdefs.ErrWifiDisabled) + if b.onStateChange != nil { + b.onStateChange() + } + return fmt.Errorf("no WiFi device available") + } + + savedID, saved := b.savedNetworkID(req.SSID) + visible, inRange := b.visibleNetwork(req.SSID) + if !saved && !inRange && !req.Hidden { + b.setConnectError(errdefs.ErrNoSuchSSID) + if b.onStateChange != nil { + b.onStateChange() + } + return fmt.Errorf("network not found: %s", req.SSID) + } + + att := &wpaConnectAttempt{ + ssid: req.SSID, + netID: savedID, + saved: saved, + deadline: time.Now().Add(30 * time.Second), + } + + b.attemptMutex.Lock() + b.curAttempt = att + b.attemptMutex.Unlock() + + b.stateMutex.Lock() + b.state.IsConnecting = true + b.state.ConnectingSSID = req.SSID + b.state.LastError = "" + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + secured := visible.Secured || (req.Hidden && req.Password != "") + + b.sigWG.Add(1) + go func() { + defer b.sigWG.Done() + b.runConnectAttempt(att, req, secured) + }() + + return nil +} + +func (b *WpaSupplicantBackend) runConnectAttempt(att *wpaConnectAttempt, req ConnectionRequest, secured bool) { + password := req.Password + if password == "" && secured && !att.saved { + psk, ok := b.promptForPSK(req.SSID, "") + if !ok { + b.finalizeAttempt(att, errdefs.ErrUserCanceled) + return + } + att.mu.Lock() + att.prompted = true + att.mu.Unlock() + password = psk + } + + if err := b.applyConnectCommands(att, password, req.Hidden); err != nil { + log.Warnf("wpa_supplicant connect to %s failed: %v", req.SSID, err) + b.finalizeAttempt(att, errdefs.ErrConnectionFailed) + return + } + + b.startAttemptWatchdog(att) +} + +func (b *WpaSupplicantBackend) applyConnectCommands(att *wpaConnectAttempt, password string, hidden bool) error { + if att.saved { + if password != "" { + quoted, err := wpaQuotedString(password) + if err != nil { + return fmt.Errorf("invalid passphrase: %w", err) + } + if err := b.setNetworkValue(att.netID, "psk", quoted); err != nil { + return err + } + } + if err := b.requestOK(fmt.Sprintf("SELECT_NETWORK %d", att.netID)); err != nil { + return err + } + if password != "" { + b.saveConfig() + } + return nil + } + + reply, err := b.cmd.request("ADD_NETWORK") + if err != nil { + return fmt.Errorf("ADD_NETWORK failed: %w", err) + } + id, err := strconv.Atoi(reply) + if err != nil { + return fmt.Errorf("ADD_NETWORK failed: %s", reply) + } + + att.mu.Lock() + att.netID = id + att.newlyAdded = true + att.mu.Unlock() + + if err := b.setNetworkValue(id, "ssid", wpaHexString(att.ssid)); err != nil { + b.removeNetworkQuiet(id) + return err + } + if hidden { + if err := b.setNetworkValue(id, "scan_ssid", "1"); err != nil { + b.removeNetworkQuiet(id) + return err + } + } + + if password == "" { + if err := b.setNetworkValue(id, "key_mgmt", "NONE"); err != nil { + b.removeNetworkQuiet(id) + return err + } + } else { + quoted, err := wpaQuotedString(password) + if err != nil { + b.removeNetworkQuiet(id) + return fmt.Errorf("invalid passphrase: %w", err) + } + if err := b.setNetworkValue(id, "psk", quoted); err != nil { + b.removeNetworkQuiet(id) + return err + } + } + + if err := b.requestOK(fmt.Sprintf("ENABLE_NETWORK %d", id)); err != nil { + b.removeNetworkQuiet(id) + return err + } + b.saveConfig() + + return nil +} + +// DISCONNECT stays in effect until RECONNECT/REASSOCIATE +// (https://w1.fi/wpa_supplicant/devel/ctrl_iface_page.html); a later +// SELECT_NETWORK from ConnectWiFi also resumes association. +func (b *WpaSupplicantBackend) DisconnectWiFi() error { + if b.cmd == nil { + return fmt.Errorf("no WiFi device available") + } + + if err := b.requestOK("DISCONNECT"); err != nil { + return fmt.Errorf("failed to disconnect: %w", err) + } + + if err := b.updateState(); err != nil { + log.Warnf("failed to update wpa state after disconnect: %v", err) + } + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *WpaSupplicantBackend) ForgetWiFiNetwork(ssid string) error { + id, ok := b.savedNetworkID(ssid) + if !ok { + return fmt.Errorf("network not found") + } + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + isConnected := b.state.WiFiConnected + b.stateMutex.RUnlock() + + if err := b.requestOK(fmt.Sprintf("REMOVE_NETWORK %d", id)); err != nil { + return fmt.Errorf("failed to forget network: %w", err) + } + b.saveConfig() + + if isConnected && currentSSID == ssid { + b.stateMutex.Lock() + b.state.WiFiConnected = false + b.state.WiFiSSID = "" + b.state.WiFiBSSID = "" + b.state.WiFiSignal = 0 + b.state.WiFiIP = "" + b.state.NetworkStatus = StatusDisconnected + b.stateMutex.Unlock() + } + + if _, err := b.updateWiFiNetworks(); err != nil { + log.Warnf("failed to refresh networks after forget: %v", err) + } + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *WpaSupplicantBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + id, ok := b.savedNetworkID(ssid) + if !ok { + return fmt.Errorf("network not found") + } + + cmd := fmt.Sprintf("DISABLE_NETWORK %d", id) + if autoconnect { + cmd = fmt.Sprintf("ENABLE_NETWORK %d", id) + } + if err := b.requestOK(cmd); err != nil { + return fmt.Errorf("failed to set autoconnect: %w", err) + } + b.saveConfig() + + if err := b.updateSavedWiFiNetworks(); err != nil { + log.Warnf("failed to refresh saved networks after autoconnect change: %v", err) + } + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +// GET_NETWORK masks secrets (wpa_config_get_no_key), so the passphrase for QR +// codes must come from a readable wpa_supplicant config file. +func (b *WpaSupplicantBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + for _, path := range wpaConfigCandidatePaths(b.ifname) { + data, err := os.ReadFile(path) + if err != nil { + continue + } + + passphrase, err := parseWpaConfigPassphrase(string(data), ssid) + if err != nil { + continue + } + + return FormatWiFiQRString("WPA", ssid, passphrase), nil + } + + return "", fmt.Errorf("no readable wpa_supplicant config with passphrase for `%s`", ssid) +} + +// /etc/wpa_supplicant.conf is the FreeBSD default (wpa_supplicant(8), +// man.freebsd.org); the /etc/wpa_supplicant/ variants are the common Linux +// layout including systemd's wpa_supplicant@.service. +func wpaConfigCandidatePaths(ifname string) []string { + return []string{ + "/etc/wpa_supplicant.conf", + fmt.Sprintf("/etc/wpa_supplicant/wpa_supplicant-%s.conf", ifname), + "/etc/wpa_supplicant/wpa_supplicant.conf", + } +} + +func parseWpaConfigPassphrase(data, ssid string) (string, error) { + inBlock := false + var blockSSID, blockPSK string + + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + continue + } + + switch { + case strings.HasPrefix(line, "network={"): + inBlock = true + blockSSID = "" + blockPSK = "" + case inBlock && line == "}": + if blockSSID == ssid && blockPSK != "" { + return blockPSK, nil + } + inBlock = false + case inBlock: + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch strings.TrimSpace(key) { + case "ssid": + blockSSID = parseWpaConfigString(strings.TrimSpace(value)) + case "psk": + // Unquoted psk is a raw 256-bit key in hex, not a passphrase; + // only quoted values are usable for QR content. + trimmed := strings.TrimSpace(value) + if strings.HasPrefix(trimmed, `"`) { + blockPSK = parseWpaConfigString(trimmed) + } + } + } + } + + return "", fmt.Errorf("no passphrase found for `%s`", ssid) +} + +func parseWpaConfigString(value string) string { + if strings.HasPrefix(value, `"`) { + end := strings.LastIndexByte(value[1:], '"') + if end < 0 { + return "" + } + return value[1 : end+1] + } + + decoded, err := hex.DecodeString(value) + if err != nil { + return "" + } + return string(decoded) +} diff --git a/core/internal/server/network/detect.go b/core/internal/server/network/detect.go index 6031e42fa..02c79bdb4 100644 --- a/core/internal/server/network/detect.go +++ b/core/internal/server/network/detect.go @@ -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,10 +37,24 @@ 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 { - return nil, fmt.Errorf("connect system bus: %w", err) + // 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() @@ -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." } diff --git a/core/internal/server/network/detect_test.go b/core/internal/server/network/detect_test.go index 87e7635f2..5042cf328 100644 --- a/core/internal/server/network/detect_test.go +++ b/core/internal/server/network/detect_test.go @@ -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) { diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 8a7806744..b91ae50b8 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -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) } diff --git a/core/internal/server/trayrecovery/manager.go b/core/internal/server/trayrecovery/manager.go index 6053609d3..9da4c1c1c 100644 --- a/core/internal/server/trayrecovery/manager.go +++ b/core/internal/server/trayrecovery/manager.go @@ -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) -} diff --git a/core/internal/server/trayrecovery/suspend_freebsd.go b/core/internal/server/trayrecovery/suspend_freebsd.go new file mode 100644 index 000000000..820c7f600 --- /dev/null +++ b/core/internal/server/trayrecovery/suspend_freebsd.go @@ -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 +} diff --git a/core/internal/server/trayrecovery/suspend_linux.go b/core/internal/server/trayrecovery/suspend_linux.go new file mode 100644 index 000000000..ef2a58736 --- /dev/null +++ b/core/internal/server/trayrecovery/suspend_linux.go @@ -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) +} diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index f0a06a61e..0cd47fb6a 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -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 -} diff --git a/core/internal/trash/mounts_freebsd.go b/core/internal/trash/mounts_freebsd.go new file mode 100644 index 000000000..cfd3842ad --- /dev/null +++ b/core/internal/trash/mounts_freebsd.go @@ -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 +} diff --git a/core/internal/trash/mounts_linux.go b/core/internal/trash/mounts_linux.go new file mode 100644 index 000000000..6935e460d --- /dev/null +++ b/core/internal/trash/mounts_linux.go @@ -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 +} diff --git a/core/internal/trash/trash.go b/core/internal/trash/trash.go index ddbc9e73a..f130f8623 100644 --- a/core/internal/trash/trash.go +++ b/core/internal/trash/trash.go @@ -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 + for _, p := range []string{"/proc", "/sys", "/dev"} { + if mp == p || strings.HasPrefix(mp, p+"/") { + return true } - mp := fields[4] - if mp == "/" { - continue - } - skip := false - for _, p := range skipPrefixes { - if mp == p || strings.HasPrefix(mp, p+"/") { - skip = true - break - } - } - if skip || seen[mp] { - continue - } - seen[mp] = true - out = append(out, mp) } - return out + return false } func List() ([]Entry, error) { diff --git a/core/internal/wayland/shm/buffer.go b/core/internal/wayland/shm/buffer.go index 9cfee205b..e04c5896c 100644 --- a/core/internal/wayland/shm/buffer.go +++ b/core/internal/wayland/shm/buffer.go @@ -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 { diff --git a/core/internal/wayland/shm/fd_freebsd.go b/core/internal/wayland/shm/fd_freebsd.go new file mode 100644 index 000000000..8a9762a75 --- /dev/null +++ b/core/internal/wayland/shm/fd_freebsd.go @@ -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 +} diff --git a/core/internal/wayland/shm/fd_linux.go b/core/internal/wayland/shm/fd_linux.go new file mode 100644 index 000000000..9f0c60c08 --- /dev/null +++ b/core/internal/wayland/shm/fd_linux.go @@ -0,0 +1,7 @@ +package shm + +import "golang.org/x/sys/unix" + +func CreateAnonFd(name string) (int, error) { + return unix.MemfdCreate(name, 0) +} diff --git a/dank-qml-common b/dank-qml-common index 4fa313766..493b609cc 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit 4fa313766ab5cd2766be0f87a163305ca9af6faf +Subproject commit 493b609cc04659f1388f5738e8f65d6c531994fe diff --git a/flake.lock b/flake.lock index 881cf23af..a1e9ffa9d 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/quickshell/Modules/Settings/BatteryTab.qml b/quickshell/Modules/Settings/BatteryTab.qml index 424beedc6..415a44961 100644 --- a/quickshell/Modules/Settings/BatteryTab.qml +++ b/quickshell/Modules/Settings/BatteryTab.qml @@ -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 diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index f158fbb34..30156dddb 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -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; diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 9b9238dc5..9b595961c 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -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 }, { diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index aafa4200f..87f9b71bc 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -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")); diff --git a/quickshell/Services/UsersService.qml b/quickshell/Services/UsersService.qml index 604a00582..377786abc 100644 --- a/quickshell/Services/UsersService.qml +++ b/quickshell/Services/UsersService.qml @@ -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,7 +297,10 @@ Singleton { onStreamFinished: chpasswdProc.capturedErr = text || "" } onStarted: { - chpasswdProc.write(chpasswdProc.targetUser + ":" + chpasswdProc.targetPassword + "\n"); + if (root.isBSD) + chpasswdProc.write(chpasswdProc.targetPassword + "\n"); + else + chpasswdProc.write(chpasswdProc.targetUser + ":" + chpasswdProc.targetPassword + "\n"); } onExited: exitCode => { const op = chpasswdProc.op; @@ -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, diff --git a/quickshell/Services/WeatherService.qml b/quickshell/Services/WeatherService.qml index fbc41a477..aabb669ea 100644 --- a/quickshell/Services/WeatherService.qml +++ b/quickshell/Services/WeatherService.qml @@ -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: ({