Initial commit

parents
build/
builddir/
_build/
ximper-shell-waybar-wrapper
# ximper-shell-panel
Runtime component for the Ximper Shell Waybar panel.
`ximper-shell-panel` applies user panel settings to the Waybar panel. The
user-facing configuration UI is expected to live in `ximperconf shell panel`.
## Paths
- User config: `~/.config/ximper-shell/panel/config.json`
- System modules: `/usr/share/ximperdistro/wm/base/waybar/modules.json`
## Commands
```sh
ximper-shell-panel list-modules
ximper-shell-panel list-modules -o json
ximper-shell-panel start
ximper-shell-panel stop
ximper-shell-panel restart
```
Hidden debug command:
```sh
ximper-shell-panel generate
```
Supported positions:
```text
top, bottom, left, right
```
Supported panel types:
```text
panel, floating, islands
```
## Module Resolution
The config stores logical module names. At generation time, the panel resolves
them against `modules.json`.
Examples:
```text
workspaces -> niri/workspaces or hyprland/workspaces
clock -> clock#vertical for left/right panels, otherwise clock
```
Explicit module names such as `niri/workspaces`, `clock#vertical`, or
`group/volume` are used as-is.
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
const (
SystemConfigHome = "/usr/share/ximperdistro/wm/base"
SystemWaybarDir = SystemConfigHome + "/waybar"
ModulesFile = SystemWaybarDir + "/modules.json"
)
type Config struct {
Position string `json:"position"`
Type string `json:"type"`
ModulesLeft []string `json:"modules_left"`
ModulesCenter []string `json:"modules_center"`
ModulesRight []string `json:"modules_right"`
}
func DefaultConfig() Config {
return Config{
Position: "top",
Type: "panel",
ModulesLeft: []string{"image#menu"},
ModulesCenter: []string{"workspaces"},
ModulesRight: []string{"group/volume", "network", "clock"},
}
}
func LoadConfig(path string) (Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse panel config: %w", err)
}
return cfg, cfg.Validate()
}
func (c Config) Validate() error {
switch c.Position {
case "top", "bottom", "left", "right":
default:
return fmt.Errorf("unsupported position %q", c.Position)
}
switch c.Type {
case "panel", "floating", "islands":
default:
return fmt.Errorf("unsupported panel type %q", c.Type)
}
for _, name := range append(append([]string{}, c.ModulesLeft...), append(c.ModulesCenter, c.ModulesRight...)...) {
if name == "" {
return errors.New("module name cannot be empty")
}
}
return nil
}
func UserConfigPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "ximper-shell", "panel", "config.json")
}
func RuntimeDir() string {
return filepath.Join(os.TempDir(), "ximper-shell", "panel")
}
func RuntimeConfigPath() string {
return filepath.Join(RuntimeDir(), "config.jsonc")
}
func RuntimePIDPath() string {
return filepath.Join(RuntimeDir(), "waybar.pid")
}
func RuntimeLogPath() string {
return filepath.Join(RuntimeDir(), "waybar.log")
}
package main
import (
"encoding/json"
"os"
"path/filepath"
)
func GenerateConfig(cfg Config, registry Registry) ([]byte, error) {
out := map[string]any{
"layer": "top",
"position": cfg.Position,
"margin-left": 0,
"margin-top": 0,
"margin-right": 0,
"margin-bottom": 0,
"spacing": 10,
"exclusive": true,
"fixed-center": true,
"reload_style_on_change": true,
"include": []string{
ModulesFile,
},
"modules-left": []string{},
"modules-center": []string{},
"modules-right": []string{},
}
if cfg.Type != "panel" {
out["name"] = cfg.Type
}
if cfg.Type == "floating" || cfg.Type == "islands" {
out["margin-top"] = 8
out["margin-bottom"] = 8
out["margin-left"] = 8
out["margin-right"] = 8
}
left, err := resolveModuleList(cfg.ModulesLeft, cfg.Position, registry)
if err != nil {
return nil, err
}
center, err := resolveModuleList(cfg.ModulesCenter, cfg.Position, registry)
if err != nil {
return nil, err
}
right, err := resolveModuleList(cfg.ModulesRight, cfg.Position, registry)
if err != nil {
return nil, err
}
out["modules-left"] = left
out["modules-center"] = center
out["modules-right"] = right
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func GenerateRuntimeConfig() ([]byte, error) {
cfg, registry, err := loadInputs()
if err != nil {
return nil, err
}
return GenerateConfig(cfg, registry)
}
func GenerateAndWriteRuntimeConfig() (string, error) {
data, err := GenerateRuntimeConfig()
if err != nil {
return "", err
}
path, err := WriteRuntimeConfig(data)
if err != nil {
return "", err
}
return path, nil
}
func WriteRuntimeConfig(data []byte) (string, error) {
path := RuntimeConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return "", err
}
if err := os.WriteFile(path, data, 0644); err != nil {
return "", err
}
return path, nil
}
func loadInputs() (Config, Registry, error) {
cfg, err := LoadConfig(UserConfigPath())
if err != nil {
return Config{}, Registry{}, err
}
registry, err := LoadRegistry(ModulesFile)
if err != nil {
return Config{}, Registry{}, err
}
return cfg, registry, nil
}
func resolveModuleList(modules []string, position string, registry Registry) ([]string, error) {
resolved := make([]string, 0, len(modules))
for _, module := range modules {
name, err := registry.Resolve(module, position)
if err != nil {
return nil, err
}
resolved = append(resolved, name)
}
return resolved, nil
}
module ximper-shell-panel
go 1.25.0
require (
github.com/fatih/color v1.18.0
github.com/urfave/cli/v3 v3.4.1
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.25.0 // indirect
)
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package main
import "bytes"
func StripJSONC(data []byte) []byte {
data = stripComments(data)
return stripTrailingCommas(data)
}
func stripComments(data []byte) []byte {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(data); i++ {
c := data[i]
if inString {
out.WriteByte(c)
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = false
}
continue
}
if c == '"' {
inString = true
out.WriteByte(c)
continue
}
if c == '/' && i+1 < len(data) {
switch data[i+1] {
case '/':
i += 2
for i < len(data) && data[i] != '\n' {
i++
}
if i < len(data) {
out.WriteByte('\n')
}
continue
case '*':
i += 2
for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') {
if data[i] == '\n' {
out.WriteByte('\n')
}
i++
}
i++
continue
}
}
out.WriteByte(c)
}
return out.Bytes()
}
func stripTrailingCommas(data []byte) []byte {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(data); i++ {
c := data[i]
if inString {
out.WriteByte(c)
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = false
}
continue
}
if c == '"' {
inString = true
out.WriteByte(c)
continue
}
if c == ',' {
j := i + 1
for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') {
j++
}
if j < len(data) && (data[j] == '}' || data[j] == ']') {
continue
}
}
out.WriteByte(c)
}
return out.Bytes()
}
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
const version = "0.1.0"
func main() {
rootCommand := &cli.Command{
Name: "ximper-shell-panel",
Usage: "Ximper Shell panel runtime",
EnableShellCompletion: true,
Version: version,
Commands: []*cli.Command{
{
Name: "list-modules",
Usage: "List available Waybar modules",
Action: listModulesCommand,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "format",
Usage: "output format (text, json)",
Value: "text",
Aliases: []string{"o"},
},
},
},
{
Name: "generate",
Hidden: true,
Usage: "Generate runtime Waybar config",
Action: generateCommand,
},
{
Name: "start",
Usage: "Start the panel if it is not running",
Action: startCommand,
},
{
Name: "stop",
Usage: "Stop the running panel",
Action: stopCommand,
},
{
Name: "restart",
Usage: "Restart the panel",
Action: restartCommand,
},
{
Name: "help",
Aliases: []string{"h"},
Usage: "show help",
ArgsUsage: "[command]",
HideHelp: true,
},
},
}
applyCommandSetting(rootCommand)
if err := rootCommand.Run(context.Background(), os.Args); err != nil {
color.Red(err.Error())
os.Exit(1)
}
}
func applyCommandSetting(cliCommand *cli.Command) {
cliCommand.EnableShellCompletion = true
cliCommand.Suggest = true
cliCommand.HideHelpCommand = true
for _, sub := range cliCommand.Commands {
applyCommandSetting(sub)
}
}
func listModulesCommand(ctx context.Context, cmd *cli.Command) error {
registry, err := LoadRegistry(ModulesFile)
if err != nil {
return err
}
names := registry.Names()
switch cmd.String("format") {
case "json":
data, err := json.MarshalIndent(names, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
case "text":
for _, name := range names {
fmt.Println(name)
}
default:
return fmt.Errorf("unsupported output format %q", cmd.String("format"))
}
return nil
}
func generateCommand(ctx context.Context, cmd *cli.Command) error {
data, err := GenerateRuntimeConfig()
if err != nil {
return err
}
fmt.Print(string(data))
return nil
}
func startCommand(ctx context.Context, cmd *cli.Command) error {
return StartWaybar()
}
func stopCommand(ctx context.Context, cmd *cli.Command) error {
return StopWaybar()
}
func restartCommand(ctx context.Context, cmd *cli.Command) error {
return RestartWaybar()
}
project('ximper-shell-panel',
version: '0.1.0',
meson_version: '>= 1.0.0',
)
custom_target(
'go-build',
build_by_default: true,
build_always_stale: true,
output: meson.project_name(),
console: true,
install: true,
install_dir: get_option('bindir'),
command: [
'go', 'build',
'-o', '@OUTPUT@',
meson.current_source_dir(),
],
env: [
'CGO_ENABLED=0',
],
)
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
type Registry struct {
Modules map[string]json.RawMessage
}
func LoadRegistry(path string) (Registry, error) {
data, err := os.ReadFile(path)
if err != nil {
return Registry{}, err
}
var modules map[string]json.RawMessage
if err := json.Unmarshal(StripJSONC(data), &modules); err != nil {
return Registry{}, fmt.Errorf("parse modules registry: %w", err)
}
return Registry{Modules: modules}, nil
}
func (r Registry) Names() []string {
names := make([]string, 0, len(r.Modules))
for name := range r.Modules {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (r Registry) Resolve(name, position string) (string, error) {
explicit := strings.Contains(name, "/") || strings.Contains(name, "#")
if explicit {
if _, ok := r.Modules[name]; ok {
return name, nil
}
return "", fmt.Errorf("module %q not found", name)
}
wm := currentWM()
vertical := position == "left" || position == "right"
var candidates []string
if vertical {
if wm != "" {
candidates = append(candidates, wm+"/"+name+"#vertical")
}
candidates = append(candidates, name+"#vertical")
}
if wm != "" {
candidates = append(candidates, wm+"/"+name)
}
candidates = append(candidates, name)
for _, candidate := range candidates {
if _, ok := r.Modules[candidate]; ok {
return candidate, nil
}
}
matches := r.suffixMatches(name)
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return "", fmt.Errorf("module %q is ambiguous: %s", name, strings.Join(matches, ", "))
}
return "", fmt.Errorf("module %q not found", name)
}
func (r Registry) suffixMatches(name string) []string {
suffix := "/" + name
var matches []string
for candidate := range r.Modules {
if strings.HasSuffix(candidate, suffix) {
matches = append(matches, candidate)
}
}
sort.Strings(matches)
return matches
}
func currentWM() string {
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
switch {
case strings.Contains(desktop, "niri"):
return "niri"
case strings.Contains(desktop, "hyprland"):
return "hyprland"
}
if os.Getenv("NIRI_SOCKET") != "" {
return "niri"
}
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
sessionDesktop := strings.ToLower(os.Getenv("XDG_SESSION_DESKTOP"))
switch {
case strings.Contains(sessionDesktop, "niri"):
return "niri"
case strings.Contains(sessionDesktop, "hyprland"):
return "hyprland"
default:
return ""
}
}
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"time"
)
func StartWaybar() error {
if PanelIsRunning() {
return nil
}
configPath, err := GenerateAndWriteRuntimeConfig()
if err != nil {
return err
}
logFile, err := os.OpenFile(RuntimeLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
defer logFile.Close()
cmd := exec.Command("waybar", "-c", configPath)
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+SystemConfigHome)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return err
}
if err := os.WriteFile(RuntimePIDPath(), []byte(strconv.Itoa(cmd.Process.Pid)+"\n"), 0644); err != nil {
_ = cmd.Process.Kill()
return err
}
return cmd.Process.Release()
}
func StopWaybar() error {
return stopWaybar()
}
func RestartWaybar() error {
if err := StopWaybar(); err != nil {
return err
}
return StartWaybar()
}
func PanelIsRunning() bool {
pid, err := readPID()
if err != nil {
return false
}
if isPanelProcess(pid) {
return true
}
_ = os.Remove(RuntimePIDPath())
return false
}
func stopWaybar() error {
pid, err := readPID()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
proc, err := os.FindProcess(pid)
if err != nil {
_ = os.Remove(RuntimePIDPath())
return err
}
if !isPanelProcess(pid) {
_ = os.Remove(RuntimePIDPath())
return nil
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
_ = os.Remove(RuntimePIDPath())
return nil
}
return err
}
for range 20 {
if !processExists(pid) {
_ = os.Remove(RuntimePIDPath())
return nil
}
time.Sleep(50 * time.Millisecond)
}
_ = proc.Kill()
_ = os.Remove(RuntimePIDPath())
fmt.Fprintf(os.Stderr, "panel process %d did not stop after SIGTERM, killed\n", pid)
return nil
}
func readPID() (int, error) {
data, err := os.ReadFile(RuntimePIDPath())
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(string(bytes.TrimSpace(data)))
if err != nil {
return 0, fmt.Errorf("invalid pid file %s: %w", RuntimePIDPath(), err)
}
return pid, nil
}
func processExists(pid int) bool {
if pid <= 0 {
return false
}
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
return err == nil
}
func isPanelProcess(pid int) bool {
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
if err != nil {
return false
}
parts := bytes.Split(bytes.TrimRight(data, "\x00"), []byte{0})
if len(parts) == 0 {
return false
}
hasWaybar := bytes.Contains([]byte(filepath.Base(string(parts[0]))), []byte("waybar"))
hasConfig := false
for _, part := range parts[1:] {
if string(part) == RuntimeConfigPath() {
hasConfig = true
break
}
}
return hasWaybar && hasConfig
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment