Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ Thumbs.db
.env
*.key
*.pem

# Local reference clone of PowerToys (MWB protocol source) — never commit
/PowerToys/
14 changes: 12 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
.PHONY: build install uninstall clean test fmt lint check bump
.PHONY: build build-wayland install install-wayland install-only uninstall clean test fmt lint check bump

SYSTEMD_USER_DIR := $(HOME)/.config/systemd/user

build:
go build -o mwb ./cmd/mwb

install: build
# Wayland bidirectional support is opt-in: it needs cgo + libei
# (Arch: libei, Debian: libei-dev). Builds the same binary plus the portal driver.
build-wayland:
CGO_ENABLED=1 go build -tags wayland -o mwb ./cmd/mwb

# install — X11 build, then install the per-user service
# install-wayland — Wayland build, then install (does NOT rebuild as X11)
install: build install-only
install-wayland: build-wayland install-only

install-only:
install -D mwb $(HOME)/go/bin/mwb
install -d $(SYSTEMD_USER_DIR)
install -m 644 mwb.service $(SYSTEMD_USER_DIR)/mwb.service
Expand Down
227 changes: 151 additions & 76 deletions README.md

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions cmd/mwb/gui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package main

import (
"embed"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"

"github.com/BurntSushi/toml"
"github.com/lucky-verma/mwb-linux/internal/config"
)

//go:embed web/index.html
var webFS embed.FS

// runGUI serves the local configuration UI on 127.0.0.1 and opens a browser.
// ponytail: localhost-only is the trust boundary — single-user local tool, the
// handlers shell `systemctl --user` as the invoking user, so no auth is added.
func runGUI(args []string) {
fs := flag.NewFlagSet("gui", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:15199", "address to serve the GUI on")
cfgPath := fs.String("config", defaultConfigPath(), "path to config.toml")
open := fs.Bool("open", true, "open the GUI in a browser")
_ = fs.Parse(args)

mux := http.NewServeMux()

index, _ := webFS.ReadFile("web/index.html")
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(index)
})

mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Read without validation so a missing/incomplete config still
// renders the form (config.Load would reject a blank host/key).
var cfg config.Config
if _, err := toml.DecodeFile(*cfgPath, &cfg); err != nil && !os.IsNotExist(err) {
writeJSON(w, http.StatusOK, &cfg) // best effort — show what we can
return
}
writeJSON(w, http.StatusOK, &cfg)
case http.MethodPost:
var cfg config.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
if cfg.Host == "" || cfg.Key == "" {
http.Error(w, "host and key are required", http.StatusBadRequest)
return
}
if err := config.Save(*cfgPath, &cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"saved": *cfgPath})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})

mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
state, _ := exec.Command("systemctl", "--user", "is-active", "mwb").Output()
log, _ := exec.Command("journalctl", "--user", "-u", "mwb", "-n", "20", "--no-pager", "-o", "cat").Output()
st := string(trimNL(state))
writeJSON(w, http.StatusOK, map[string]any{
"active": st == "active",
"state": st,
"log": string(log),
})
})

mux.HandleFunc("/api/service", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
switch req.Action {
case "start", "stop", "restart", "enable", "disable":
default:
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
out, err := exec.Command("systemctl", "--user", req.Action, "mwb").CombinedOutput()
if err != nil {
http.Error(w, string(out)+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"ok": req.Action})
})

url := "http://" + *addr
fmt.Printf("mwb GUI serving at %s (config: %s)\n", url, *cfgPath)
if *open {
go func() { _ = exec.Command("xdg-open", url).Start() }()
}
if err := http.ListenAndServe(*addr, mux); err != nil {
fmt.Fprintf(os.Stderr, "gui server error: %v\n", err)
os.Exit(1)
}
}

func defaultConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "mwb", "config.toml")
}

func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}

func trimNL(b []byte) []byte {
for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
b = b[:len(b)-1]
}
return b
}
43 changes: 34 additions & 9 deletions cmd/mwb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import (
)

func main() {
// `mwb gui` launches the local web configuration UI instead of the daemon.
if len(os.Args) > 1 && os.Args[1] == "gui" {
runGUI(os.Args[2:])
return
}

configPath := flag.String("config", "", "path to config.toml")
debug := flag.Bool("debug", false, "enable debug logging")
edgeSide := flag.String("edge", "", "screen edge to switch: left or right (overrides config)")
Expand Down Expand Up @@ -56,13 +62,17 @@ func main() {
*edgeSide = "right" // final fallback
}

// Bidirectional turns on if either the flag or config requests it, so the
// GUI can toggle it via config without editing the systemd unit.
bidi := *bidirectional || cfg.Bidirectional

// Clipboard runs by default. Either config (clipboard = false) or the
// --no-clipboard flag disables it; the flag wins over config.
clipboardEnabled := cfg.ClipboardEnabled() && !*noClipboard
keyboardLayout := input.ResolveKeyboardLayout(cfg.KeyboardLayout)

slog.Debug("debug logging enabled")
slog.Info("mwb starting", "host", cfg.Host, "port", cfg.MessagePort(), "name", cfg.Name, "bidirectional", *bidirectional, "edge", *edgeSide, "clipboard", clipboardEnabled, "keyboard_layout", keyboardLayout)
slog.Info("mwb starting", "host", cfg.Host, "port", cfg.MessagePort(), "name", cfg.Name, "bidirectional", bidi, "edge", *edgeSide, "clipboard", clipboardEnabled, "keyboard_layout", keyboardLayout)

mouse, err := input.CreateVirtualMouse("mwb-mouse")
if err != nil {
Expand Down Expand Up @@ -140,22 +150,29 @@ func main() {
// Start clipboard sharing on the auto-detected display unless disabled.
var clipMgr *clipboard.Manager
if clipboardEnabled {
clipMgr = clipboard.NewManager(conn, capture.DetectDisplay())
clipMgr = clipboard.NewManager(conn, capture.DetectDisplay(), cfg.Key, cfg.Host, cfg.Port)
handler.Clipboard = clipMgr
clipMgr.Start()
}

// Start bidirectional capture if enabled
// Start bidirectional capture if enabled. On Wayland the X11
// xdotool/xinput path can't work, so use the InputCapture portal driver.
var cap *capture.Capturer
if *bidirectional {
var capStop func()
if bidi && isWayland() {
edges := cfg.EnabledEdges()
if len(edges) == 0 {
slog.Info("edge switching disabled (edges = none); not capturing")
} else if c, stop, err := capture.RunWayland(conn, handler, *edgeSide, edges, cfg.SwitchModifier, cfg.AccelMultiplier); err != nil {
slog.Error("wayland capture start failed", "err", err)
} else {
cap, capStop = c, stop
}
} else if bidi {
screen := capture.GetScreenSizeXrandr()
slog.Info("screen detected", "width", screen.Width, "height", screen.Height)

cap = capture.New(conn, screen, *edgeSide)
// Wire remote screen dimensions from config so virtual cursor
// coordinate mapping is correct for non-1080p Windows displays.
cap.SetRemoteScreen(int32(cfg.RemoteWidth), int32(cfg.RemoteHeight))
slog.Info("remote screen configured", "width", cfg.RemoteWidth, "height", cfg.RemoteHeight)
// Cursor speed: the only acceleration knob lives here (Windows
// applies none of its own), so honor the configured multiplier.
cap.SetAccelMultiplier(cfg.AccelMultiplier)
Expand Down Expand Up @@ -201,7 +218,9 @@ func main() {
}

// Stop capture first — prevents in-flight SendPacket after conn.Close()
if cap != nil {
if capStop != nil {
capStop() // Wayland: tears down the portal + libei
} else if cap != nil {
cap.Stop()
}

Expand All @@ -217,3 +236,9 @@ func main() {
sig := <-sigCh
slog.Info("shutting down", "signal", sig)
}

// isWayland reports whether we're running under a Wayland session, in which
// case the X11 xdotool/xinput bidi path won't work and we use the portal driver.
func isWayland() bool {
return os.Getenv("XDG_SESSION_TYPE") == "wayland" || os.Getenv("WAYLAND_DISPLAY") != ""
}
Loading