f267c430d9
Three features plus the design-system swap. Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches the app's asInvoker manifest, and it is also what lets the in-app updater replace the .exe later without elevation — a Program Files install could not. It offers a desktop shortcut and an optional start-with-Windows entry, and reuses the app's own single-instance mutex as AppMutex so setup notices a running copy, since a running .exe cannot be overwritten. Uninstall leaves presets.json, config.json and the log in place. Launching. Opening ipswap opens the editor in a browser, starting the tray first if it is not already up; if it is, the running copy opens the browser and the second process exits. internal/instance does this with a loopback control listener whose port and token live in a mode-600 session.json, plus a session-local named mutex on Windows to settle a launch race. A stale session file from a crash is detected by a failed call and treated as "no primary", so it can never wedge startup. The Run-key entry now passes --background, because a browser tab at every login is not wanted. Switching from the browser. New endpoints for active state, apply preview and apply. The browser confirms against the same before/after text the tray's MessageBox shows, built from a live read at prompt time. Apply requires the session token in a header like every other mutation — it changes the machine's network, so it is not a weaker case than editing a preset. A netsh refusal for lack of elevation comes back as 409 with a flag, so the page can point at "Relaunch as administrator" instead of showing a generic failure. To avoid two copies of that sequence, internal/switcher now owns everything between "the user said yes" and "the adapter changed", and both front ends call it. It serialises applies: two interleaved netsh sequences on one adapter would leave it matching neither preset, and there are now two ways to start one. CSS. apointless.css is vendored from bsncubed/css and left untouched; the previous file was the boarding-pass stylesheet and its class names did not match this markup. ipswap.css adds only what the design system does not ship — page chrome, tables, modals, stat tiles, a success alert — on its tokens, so re-pulling apointless.css restyles the app. Not verified: the installer is uncompiled (no Inno Setup or wine on this box) and the UI is not visually rendered (headless Firefox hangs here). Both are checked as far as the tooling allows — assets and endpoints serve, and every DOM id the JS touches exists in the markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
7.2 KiB
Go
266 lines
7.2 KiB
Go
// Package instance makes ipswap single-instance and gives a second launch a
|
|
// way to talk to the first.
|
|
//
|
|
// The behaviour it implements: double-clicking the shortcut should open the
|
|
// editor in a browser. If ipswap is not running yet, that means starting the
|
|
// tray and opening the browser. If it is already running, it means asking the
|
|
// running copy to open the browser — not starting a second tray icon, and not
|
|
// silently doing nothing.
|
|
//
|
|
// The mechanism is a small loopback control listener owned by the primary
|
|
// instance, whose port and token are published in a session file. A second
|
|
// launch reads that file, calls the listener and exits. The session file is
|
|
// authoritative about *how to talk to* the primary; on Windows a named mutex
|
|
// additionally settles *who is* the primary, closing the race between two
|
|
// launches starting at the same moment.
|
|
package instance
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.apointless.space/bsncubed/ipswap/internal/config"
|
|
)
|
|
|
|
// dialTimeout bounds every call to a possibly-dead primary. A stale session
|
|
// file must not add a visible delay to startup.
|
|
const dialTimeout = 1500 * time.Millisecond
|
|
|
|
// ErrNoPrimary means no other instance is running.
|
|
var ErrNoPrimary = errors.New("no running ipswap instance")
|
|
|
|
// session is the contents of session.json.
|
|
type session struct {
|
|
Port int `json:"port"`
|
|
Token string `json:"token"`
|
|
PID int `json:"pid"`
|
|
}
|
|
|
|
func sessionPath(paths config.Paths) string {
|
|
return filepath.Join(paths.Dir, "session.json")
|
|
}
|
|
|
|
// Primary is the control listener owned by the running instance.
|
|
type Primary struct {
|
|
path string
|
|
token string
|
|
|
|
mu sync.Mutex
|
|
ln net.Listener
|
|
srv *http.Server
|
|
lock releaser
|
|
}
|
|
|
|
// releaser is whatever the platform uses to claim single-instance ownership.
|
|
type releaser interface{ release() }
|
|
|
|
// Acquire makes this process the primary instance and starts its control
|
|
// listener. onOpenEditor is called when another launch asks for the editor.
|
|
//
|
|
// It returns ErrAlreadyRunning if another instance already holds the slot; the
|
|
// caller should then call Signal and exit.
|
|
func Acquire(paths config.Paths, onOpenEditor func()) (*Primary, error) {
|
|
// Take the OS-level lock first: it is the only thing that makes two
|
|
// simultaneous launches deterministic.
|
|
lock, err := lockProcess()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
|
|
lock.release()
|
|
return nil, fmt.Errorf("creating the data directory: %w", err)
|
|
}
|
|
|
|
token, err := newToken()
|
|
if err != nil {
|
|
lock.release()
|
|
return nil, err
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
lock.release()
|
|
return nil, fmt.Errorf("binding the control port: %w", err)
|
|
}
|
|
|
|
p := &Primary{path: sessionPath(paths), token: token, ln: ln, lock: lock}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /ping", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
mux.HandleFunc("POST /open", func(w http.ResponseWriter, r *http.Request) {
|
|
// Answer first, then act: the caller is a process waiting to exit, and
|
|
// opening a browser can take a moment.
|
|
w.WriteHeader(http.StatusOK)
|
|
if onOpenEditor != nil {
|
|
go onOpenEditor()
|
|
}
|
|
})
|
|
|
|
p.srv = &http.Server{
|
|
Handler: p.withToken(mux),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
if err := p.writeSession(ln.Addr().(*net.TCPAddr).Port, token); err != nil {
|
|
ln.Close()
|
|
lock.release()
|
|
return nil, err
|
|
}
|
|
|
|
// Capture the handles before serving: Release nils the fields under the
|
|
// mutex, and the serving goroutine must not read them concurrently.
|
|
srv := p.srv
|
|
go func() {
|
|
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("control listener stopped: %v", err)
|
|
}
|
|
}()
|
|
|
|
log.Printf("control listener on 127.0.0.1:%d", ln.Addr().(*net.TCPAddr).Port)
|
|
return p, nil
|
|
}
|
|
|
|
func (p *Primary) withToken(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Loopback plus a per-run token: enough to stop a stray page in the
|
|
// browser poking the control channel, which is all it needs to resist.
|
|
if r.Header.Get("X-Ipswap-Control") != p.token {
|
|
http.Error(w, "unauthorised", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// writeSession publishes the control address atomically, so a second launch
|
|
// can never read a half-written file.
|
|
func (p *Primary) writeSession(port int, token string) error {
|
|
b, err := json.MarshalIndent(session{Port: port, Token: token, PID: os.Getpid()}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(filepath.Dir(p.path), ".session-*.json")
|
|
if err != nil {
|
|
return fmt.Errorf("creating the session file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
|
|
// 0600: the token in here is what authorises control calls.
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, p.path)
|
|
}
|
|
|
|
// Release shuts the control listener down and removes the session file.
|
|
func (p *Primary) Release() {
|
|
p.mu.Lock()
|
|
srv, lock := p.srv, p.lock
|
|
p.srv, p.ln, p.lock = nil, nil, nil
|
|
p.mu.Unlock()
|
|
|
|
if srv != nil {
|
|
_ = srv.Close()
|
|
}
|
|
// Remove the session file before dropping the lock, so a launch that gets
|
|
// the lock next never sees this instance's stale address.
|
|
_ = os.Remove(p.path)
|
|
if lock != nil {
|
|
lock.release()
|
|
}
|
|
}
|
|
|
|
// Signal asks an already-running instance to open the editor. It returns
|
|
// ErrNoPrimary when nothing is listening, which is the caller's cue to start
|
|
// up normally.
|
|
func Signal(paths config.Paths) error {
|
|
s, err := readSession(sessionPath(paths))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return call(s, "/open")
|
|
}
|
|
|
|
// IsRunning reports whether another instance answers on the control channel.
|
|
func IsRunning(paths config.Paths) bool {
|
|
s, err := readSession(sessionPath(paths))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return call(s, "/ping") == nil
|
|
}
|
|
|
|
func readSession(path string) (session, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return session{}, ErrNoPrimary
|
|
}
|
|
var s session
|
|
if err := json.Unmarshal(b, &s); err != nil || s.Port == 0 || s.Token == "" {
|
|
return session{}, ErrNoPrimary
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func call(s session, path string) error {
|
|
url := fmt.Sprintf("http://127.0.0.1:%d%s", s.Port, path)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("X-Ipswap-Control", s.Token)
|
|
|
|
client := &http.Client{Timeout: dialTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
// A session file left behind by a crash points at a port nothing is
|
|
// listening on, or one since reused by an unrelated process.
|
|
return ErrNoPrimary
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return ErrNoPrimary
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newToken() (string, error) {
|
|
var b [32]byte
|
|
if _, err := readRandom(b[:]); err != nil {
|
|
return "", fmt.Errorf("generating a control token: %w", err)
|
|
}
|
|
const hex = "0123456789abcdef"
|
|
var sb strings.Builder
|
|
for _, c := range b {
|
|
sb.WriteByte(hex[c>>4])
|
|
sb.WriteByte(hex[c&0x0f])
|
|
}
|
|
return sb.String(), nil
|
|
}
|