1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
// mastodon-alt-text generates alt text for the image in the X11 clipboard and
// lets you pick between several wordings in a browser window.
//
// Invoked bare (from a keybinding) it restarts the systemd user service and
// opens a browser at it. The service is where the work happens; see serve.go.
//
//	mastodon-alt-text                 # restart the unit, open the picker
//	mastodon-alt-text serve [flags]   # the server itself (run by systemd)
package main

import (
	"flag"
	"fmt"
	"log"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
)

// defaultAddr binds an ephemeral port. Nothing outside this machine talks to
// the server and only one window is ever open on it, so reserving a fixed port
// number would be claiming a piece of a shared namespace for no reason — and
// losing whenever something else had already taken it. The port that was
// actually assigned is written to portFile for the launcher to read.
const defaultAddr = "127.0.0.1:0"

const defaultIdleTimeout = 60 * time.Second

// unitName is restarted rather than started: `systemctl start` on an
// already-running unit does nothing, and every invocation must re-read the
// clipboard. Restart also guarantees the previous window's server is gone, so
// the port is free.
const unitName = "mastodon-alt-text.service"

// portFile carries the ephemeral port from the server to the launcher.
//
// It lives in the runtime directory, which systemd clears on logout, so a
// stale file cannot survive a session. Writing it is ordered before the
// readiness notification and reading it after `systemctl restart` returns, so
// the launcher can never read the previous run's port.
func portFile() string {
	dir := os.Getenv("XDG_RUNTIME_DIR")
	if dir == "" {
		dir = os.TempDir()
	}
	return filepath.Join(dir, "mastodon-alt-text.port")
}

// writePortFile publishes the address the server actually bound.
//
// Written to a temporary name and renamed, because rename is atomic: a
// launcher reading concurrently sees either the old file or the new one, never
// a half-written line.
func writePortFile(addr string) error {
	path := portFile()
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, []byte(addr+"\n"), 0o600); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

func readPortFile() (string, error) {
	b, err := os.ReadFile(portFile())
	if err != nil {
		return "", err
	}
	addr := strings.TrimSpace(string(b))
	if addr == "" {
		return "", fmt.Errorf("%s is empty", portFile())
	}
	return addr, nil
}

func main() {
	log.SetFlags(0)
	log.SetPrefix("mastodon-alt-text: ")

	if len(os.Args) > 1 && os.Args[1] == "serve" {
		fs := flag.NewFlagSet("serve", flag.ExitOnError)
		addr := fs.String("addr", defaultAddr, "address to listen on")
		idle := fs.Duration("idle", defaultIdleTimeout, "exit after this long with no browser connected")
		_ = fs.Parse(os.Args[2:])

		if err := serve(*addr, *idle); err != nil {
			log.Fatal(err)
		}
		return
	}

	fs := flag.NewFlagSet("mastodon-alt-text", flag.ExitOnError)
	addr := fs.String("addr", "", "address to open, instead of the one the service reports")
	browser := fs.String("browser", "chromium", "browser binary to open the picker with")
	fs.Usage = usage
	_ = fs.Parse(os.Args[1:])

	if err := launch(*addr, *browser); err != nil {
		log.Fatal(err)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `mastodon-alt-text — generate Mastodon alt text for the clipboard image

usage:
  mastodon-alt-text [-addr ADDR] [-browser BIN]
        Restart the user service and open the picker in a browser.

  mastodon-alt-text serve [-addr ADDR] [-idle DURATION]
        Run the server. Normally started by systemd, not by hand.

The image is taken from the X11 CLIPBOARD selection. Picking a variant copies
it to both PRIMARY and CLIPBOARD. Close the browser window when you are done;
the server exits by itself once no page has been connected for the idle
timeout.

The Gemini API key comes from $GEMINI_API_KEY, or else from pass(1) at
internet/ai.google.dev/gemini/api-keys/gemini-2.5-flash.
`)
}

// launch restarts the service and points a browser at it.
func launch(addr, browser string) error {
	browserPath, err := exec.LookPath(browser)
	if err != nil {
		return fmt.Errorf("browser %q not found in PATH: %w", browser, err)
	}

	// Type=notify: this returns only once the server is accepting connections
	// and has published its port, so neither the read below nor the browser can
	// lose a race against it.
	cmd := exec.Command("systemctl", "--user", "restart", unitName)
	cmd.Stdout = os.Stderr
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("systemctl --user restart %s: %w "+
			"(is the unit installed? see mastodon-alt-text.service)", unitName, err)
	}

	if addr == "" {
		var err error
		addr, err = readPortFile()
		if err != nil {
			return fmt.Errorf("could not find out which port the service bound: %w", err)
		}
	}

	url := "http://" + addr + "/"

	// --app gives a window without tabs or a URL bar. The default profile is
	// reused deliberately, which means that if a browser is already running,
	// this process hands the URL over and exits immediately.
	c := exec.Command(browserPath, "--app="+url)
	c.Stdout = os.Stderr
	c.Stderr = os.Stderr
	if err := c.Start(); err != nil {
		return fmt.Errorf("start %s: %w", browserPath, err)
	}
	// Do not Wait: with a running browser this exits at once, and with a fresh
	// one it would otherwise block for the lifetime of the whole browser.
	return nil
}

// notifyReady implements the sd_notify(3) READY=1 handshake for Type=notify.
// It is a single datagram to a unix socket, so it is not worth a dependency.
// Outside systemd NOTIFY_SOCKET is unset and this does nothing.
func notifyReady() {
	sock := os.Getenv("NOTIFY_SOCKET")
	if sock == "" {
		return
	}
	// A leading '@' denotes an abstract socket, which Go spells as a leading
	// NUL byte.
	if sock[0] == '@' {
		sock = "\x00" + sock[1:]
	}
	conn, err := net.Dial("unixgram", sock)
	if err != nil {
		logf("sd_notify: %s", err)
		return
	}
	defer conn.Close()
	if _, err := conn.Write([]byte("READY=1")); err != nil {
		logf("sd_notify: %s", err)
	}
}