Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/comm/handlers/subscriberequesthandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ func init() {
p = "bluetooth"
}

if strings.HasPrefix(p, "wifi:") {
p = "wifi"
}

toDelete := []uint32{}

for k, v := range subs {
Expand Down
48 changes: 48 additions & 0 deletions internal/providers/wifi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
### Elephant WiFi

WiFi network management. Scan, connect, disconnect, and forget networks.

Connecting to a new secured network will prompt for the password using the configured authentication agent.

#### Requirements

One of the following utils/backend:

- `nmcli` - Network Manager

#### Password Prompts

When connecting to a new password-protected network, a password prompt is used to request the password.

If the configured prompt is not available, all presets are tried, with `terminal` as the final fallback.

- `walker` - Walker in dmenu mode
- `rofi` - Rofi in dmenu mode
- `wofi` - Wofi in dmenu mode
- `terminal` - Opens a terminal window for password input
- `custom` - Uses a custom command defined in `custom_prompt_command`

#### Configuration

- `backend` string - WiFi backend (default: `auto`)
- `auto` - Will try all backends until one works
- `nm` - Network Manager
- `password_prompt` string - Password prompt method (default: `walker`)
- `auto` - Will try all presets, then fall back to terminal
- `walker`, `rofi`, `wofi` - Use a specific tool
- `terminal` - Terminal-based password prompt
- `custom` - Custom command
- `custom_prompt_command` string - Custom command for the `custom` password prompt. Use `%PROMPT%` as placeholder for the prompt text
- e.g. `rofi -dmenu -password -p %PROMPT%`
- `message_time` int - Seconds to show status messages (default: `1`)
- `error_time` int - Seconds to show error messages (default: `3`)
- `reopen_after_fail` bool - Reopen wifi menu after connection failure (default: `true`)
- `reopen_after_connect` bool - Reopen wifi menu after successful connection (default: `false`)
- `show_password_dots` bool - Show dots while typing password in terminal (default: `true`)
- `notify` bool - Show desktop notifications (default: `true`)
- `subtext_format` string - Format string for the subtext displayed under each network
- `%LOCK%` - security icon: 🔓 (secured + saved), 🔒 (secured), 🌐 (open)
- `%STATUS%` - connection status: `Connected`, `Saved`, or empty
- `%SIGNAL%` - signal strength percentage (e.g. `80%`)
- `%FREQUENCY%` - frequency band (e.g. `5 GHz`)
- `%SECURITY%` - security type (e.g. `WPA2`)
40 changes: 40 additions & 0 deletions internal/providers/wifi/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import "log/slog"

type Backend interface {
Available() bool
CheckWifiState() bool
SetWifiEnabled(enabled bool) error
GetNetworks() []Network
Connect(ssid string, password string) error
Disconnect(ssid string) error
Forget(ssid string) error
WaitForNetworks()
}

var backends = map[string]Backend{}

func detectBackend(preference string) Backend {
if preference != "auto" {
if b, ok := backends[preference]; ok {
if b.Available() {
slog.Info(Name, "detectBackend", preference)
return b
}
slog.Warn(Name, "detectBackend", preference+" not available, trying others")
}
}

for name, b := range backends {
if name == preference {
continue
}
if b.Available() {
slog.Info(Name, "detectBackend", name)
return b
}
}

return nil
}
39 changes: 39 additions & 0 deletions internal/providers/wifi/makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
DESTDIR ?=
CONFIGDIR = $(DESTDIR)/etc/xdg/elephant/providers

GO_BUILD_FLAGS = -buildvcs=false -buildmode=plugin -trimpath
PLUGIN_NAME = wifi.so

.PHONY: all build install uninstall clean

all: build

build:
go build $(GO_BUILD_FLAGS)

install: build
# Install plugin
install -Dm 755 $(PLUGIN_NAME) $(CONFIGDIR)/$(PLUGIN_NAME)

uninstall:
rm -f $(CONFIGDIR)/$(PLUGIN_NAME)

clean:
go clean
rm -f $(PLUGIN_NAME)

dev-install: install

help:
@echo "Available targets:"
@echo " all - Build the plugin (default)"
@echo " build - Build the plugin"
@echo " install - Install the plugin"
@echo " uninstall - Remove installed plugin"
@echo " clean - Clean build artifacts"
@echo " help - Show this help"
@echo ""
@echo "Variables:"
@echo " DESTDIR - Destination directory for staged installs"
@echo ""
@echo "Note: This builds a Go plugin (.so file) for elephant"
193 changes: 193 additions & 0 deletions internal/providers/wifi/networkManager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package main

import (
"log/slog"
"os/exec"
"strconv"
"strings"
"time"
)

func init() {
backends["nm"] = &NmcliBackend{}
}

type NmcliBackend struct{}

func (b *NmcliBackend) Available() bool {
p, err := exec.LookPath("nmcli")
return p != "" && err == nil
}

func (b *NmcliBackend) CheckWifiState() bool {
cmd := exec.Command("nmcli", "radio", "wifi")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}

return strings.TrimSpace(string(out)) == "enabled"
}

func (b *NmcliBackend) SetWifiEnabled(enabled bool) error {
state := "off"
if enabled {
state = "on"
}

cmd := exec.Command("nmcli", "radio", "wifi", state)
if out, err := cmd.CombinedOutput(); err != nil {
slog.Error(Name, "nmcli_SetWifiEnabled", string(out))
return err
}

return nil
}

func (b *NmcliBackend) GetNetworks() []Network {
var result []Network

known := make(map[string]string)
cmd := exec.Command("nmcli", "-t", "-f", "NAME,UUID,TYPE", "connection", "show")
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error(Name, "nmcli_GetNetworks", err)
}

for l := range strings.Lines(strings.TrimSpace(string(out))) {
l = strings.TrimSpace(l)
if l == "" {
continue
}
fields := nmcliSplitFields(l, 3)
if len(fields) == 3 && fields[2] == "802-11-wireless" {
known[fields[0]] = fields[1]
}
}

cmd = exec.Command("nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE,FREQ", "device", "wifi", "list")
out, err = cmd.CombinedOutput()
if err != nil {
slog.Error(Name, "nmcli_GetNetworks", err)
return result
}

seen := make(map[string]struct{})

for l := range strings.Lines(strings.TrimSpace(string(out))) {
l = strings.TrimSpace(l)
if l == "" {
continue
}

fields := nmcliSplitFields(l, 5)
if len(fields) < 5 {
continue
}

ssid := fields[0]
if ssid == "" {
continue
}

if _, ok := seen[ssid]; ok {
continue
}
seen[ssid] = struct{}{}

signal, _ := strconv.Atoi(strings.TrimSpace(fields[1]))
freq, _ := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(fields[4]), " MHz")))

n := Network{
SSID: ssid,
Signal: signal,
Security: fields[2],
InUse: strings.TrimSpace(fields[3]) == "*",
Frequency: freq,
}

if _, ok := known[ssid]; ok {
n.Known = true
n.UUID = known[ssid]
}

result = append(result, n)
}

return result
}

func (b *NmcliBackend) Connect(ssid string, password string) error {
cmd := exec.Command("nmcli", "device", "wifi", "connect", ssid)
if password != "" {
cmd.Args = append(cmd.Args, "password", password)
}

if out, err := cmd.CombinedOutput(); err != nil {
slog.Error(Name, "nmcli_Connect", string(out))
return err
}

return nil
}

func (b *NmcliBackend) Disconnect(ssid string) error {
cmd := exec.Command("nmcli", "connection", "down", ssid)
if out, err := cmd.CombinedOutput(); err != nil {
slog.Error(Name, "nmcli_Disconnect", string(out))
return err
}

return nil
}

func (b *NmcliBackend) Forget(ssid string) error {
cmd := exec.Command("nmcli", "connection", "delete", ssid)
if out, err := cmd.CombinedOutput(); err != nil {
slog.Error(Name, "nmcli_Forget", string(out))
return err
}

return nil
}

func (b *NmcliBackend) WaitForNetworks() {
maxTime := 5 //sec
delay := 500 //ms
for range int(maxTime * 1000 / delay) {
out, err := exec.Command("nmcli", "-t", "-f", "SSID", "device", "wifi", "list", "--rescan", "yes").CombinedOutput()
if err == nil && strings.TrimSpace(string(out)) != "" {
return
}
time.Sleep(time.Duration(delay) * time.Millisecond)
}
slog.Warn(Name, "nmcli_WaitForNetworks", "max retries reached")
}

// nmcliSplitFields splits an nmcli terse-mode line on unescaped colons.
func nmcliSplitFields(line string, n int) []string {
var fields []string
var buf strings.Builder
escaped := false

for _, r := range line {
if escaped {
buf.WriteRune(r)
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == ':' && (n <= 0 || len(fields) < n-1) {
fields = append(fields, buf.String())
buf.Reset()
continue
}
buf.WriteRune(r)
}

fields = append(fields, buf.String())
return fields
}
Loading