Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
46d6d37dc2
130 files changed
+26739
No files matched your search
@@ -0,0 +1,222 @@
|
||||
// Package instance lets a lazysecrets:// link go to a lazysecrets that is
|
||||
// already running, rather than a new window.
|
||||
//
|
||||
// Each running lazysecrets listens on a Unix socket of its own in a
|
||||
// directory only the user can read (Unix sockets work the same on macOS,
|
||||
// Linux and Windows 10 and later). The link handler asks each one when it
|
||||
// was last used and whether it can be brought into view, and hands the
|
||||
// link to the best one, which shows it and comes to the front.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Dir holds the sockets: $XDG_RUNTIME_DIR/lazysecrets if set, else
|
||||
// run/ under the state directory.
|
||||
func Dir(stateDir string) string {
|
||||
if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" {
|
||||
return filepath.Join(d, "lazysecrets")
|
||||
}
|
||||
return filepath.Join(stateDir, "run")
|
||||
}
|
||||
|
||||
// Status is what an instance tells the link handler about itself.
|
||||
type Status struct {
|
||||
PID int `json:"pid"`
|
||||
LastActive time.Time `json:"lastActive"` // last key press or focus
|
||||
// Reach is how readily it can be shown: 2 in view (outside tmux, or
|
||||
// in a tmux session a client shows), 1 in a tmux session that a tmux
|
||||
// client can be switched to, 0 not at all.
|
||||
Reach int `json:"reach"`
|
||||
// App is the terminal app it would show in (macOS), preferring the
|
||||
// app the link was clicked in.
|
||||
App string `json:"app,omitempty"`
|
||||
}
|
||||
|
||||
type request struct {
|
||||
Op string `json:"op"` // "status" or "open"
|
||||
Link string `json:"link,omitempty"`
|
||||
Front string `json:"front,omitempty"` // app the link was clicked in
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// Server is the running lazysecrets' end.
|
||||
type Server struct {
|
||||
ln net.Listener
|
||||
path string
|
||||
open func(link, front string) error // shows the link and comes to the front
|
||||
|
||||
mu sync.Mutex
|
||||
active time.Time
|
||||
}
|
||||
|
||||
// Listen starts listening in dir. open is called, from another goroutine,
|
||||
// with each link handed over and the app it was clicked in; it should
|
||||
// return quickly, as the link handler waits for it, and come to the front
|
||||
// (Raise) afterwards.
|
||||
func Listen(dir string, open func(link, front string) error) (*Server, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(dir, 0o700) // a pre-existing directory too
|
||||
path := filepath.Join(dir, strconv.Itoa(os.Getpid())+".sock")
|
||||
_ = os.Remove(path) // a crashed process with this PID
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{ln: ln, path: path, open: open, active: time.Now()}
|
||||
go s.serve()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Touch records that the user just used this instance.
|
||||
func (s *Server) Touch() {
|
||||
s.mu.Lock()
|
||||
s.active = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close stops listening and removes the socket.
|
||||
func (s *Server) Close() error {
|
||||
err := s.ln.Close()
|
||||
_ = os.Remove(s.path)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) serve() {
|
||||
for {
|
||||
c, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handle(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handle(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
var req request
|
||||
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
|
||||
return
|
||||
}
|
||||
var resp response
|
||||
switch req.Op {
|
||||
case "status":
|
||||
_, app, reach := place(req.Front)
|
||||
s.mu.Lock()
|
||||
resp.Status = Status{PID: os.Getpid(), LastActive: s.active, Reach: reach, App: app}
|
||||
s.mu.Unlock()
|
||||
case "open":
|
||||
if err := s.open(req.Link, req.Front); err != nil {
|
||||
resp.Err = err.Error()
|
||||
}
|
||||
default:
|
||||
resp.Err = fmt.Sprintf("unknown request %q", req.Op)
|
||||
}
|
||||
_ = json.NewEncoder(c).Encode(resp)
|
||||
}
|
||||
|
||||
// Found is a running instance.
|
||||
type Found struct {
|
||||
Status
|
||||
path string
|
||||
}
|
||||
|
||||
// Running asks every instance in dir for its status for a link clicked in
|
||||
// front, removing sockets no one answers on (left by a crash).
|
||||
func Running(ctx context.Context, dir, front string) []Found {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
out []Found
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".sock") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var resp response
|
||||
err := call(ctx, path, request{Op: "status", Front: front}, &resp)
|
||||
if errors.Is(err, errGone) {
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
if err != nil || resp.Err != "" {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
out = append(out, Found{resp.Status, path})
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
// Best is the instance to hand a link clicked in front to: the most
|
||||
// recently used of those most readily shown. A link clicked in a terminal
|
||||
// goes only to one showing in that terminal. ok is false if there's none.
|
||||
func Best(found []Found, front string, terminal bool) (Found, bool) {
|
||||
var best Found
|
||||
for _, f := range found {
|
||||
if terminal && f.App != front {
|
||||
continue
|
||||
}
|
||||
if f.Reach > best.Reach || (f.Reach == best.Reach && f.LastActive.After(best.LastActive)) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, best.Reach > 0
|
||||
}
|
||||
|
||||
// Open hands link, clicked in front, to f.
|
||||
func Open(ctx context.Context, f Found, link, front string) error {
|
||||
var resp response
|
||||
if err := call(ctx, f.path, request{Op: "open", Link: link, Front: front}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Err != "" {
|
||||
return errors.New(resp.Err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errGone = errors.New("no instance listening")
|
||||
|
||||
func call(ctx context.Context, path string, req request, resp *response) error {
|
||||
var d net.Dialer
|
||||
c, err := d.DialContext(ctx, "unix", path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errGone, err)
|
||||
}
|
||||
defer c.Close()
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = c.SetDeadline(dl)
|
||||
}
|
||||
if err := json.NewEncoder(c).Encode(req); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewDecoder(c).Decode(resp)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// shortDir is a temporary directory with a path short enough for a
|
||||
// socket (macOS allows 104 bytes).
|
||||
func shortDir(t *testing.T) string {
|
||||
d, err := os.MkdirTemp("", "ls")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(d) })
|
||||
return d
|
||||
}
|
||||
|
||||
func TestHandOver(t *testing.T) {
|
||||
t.Setenv("TMUX", "")
|
||||
dir := shortDir(t)
|
||||
got := make(chan string, 1)
|
||||
s, err := Listen(dir, func(link, front string) error { got <- link; return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if fi, _ := os.Stat(dir); fi.Mode().Perm() != 0o700 {
|
||||
t.Errorf("dir mode %v", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
// A socket left by a crashed instance.
|
||||
stale := filepath.Join(dir, "999999.sock")
|
||||
ln, err := net.Listen("unix", stale)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ln.(*net.UnixListener).SetUnlinkOnClose(false)
|
||||
ln.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
found := Running(ctx, dir, "")
|
||||
if len(found) != 1 || found[0].PID != os.Getpid() || found[0].Reach != 2 {
|
||||
t.Fatalf("found %+v", found)
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Error("stale socket left behind")
|
||||
}
|
||||
best, ok := Best(found, "", false)
|
||||
if !ok {
|
||||
t.Fatal("no instance to hand over to")
|
||||
}
|
||||
if err := Open(ctx, best, "lazysecrets://azure/kv.vault.azure.net", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := <-got; l != "lazysecrets://azure/kv.vault.azure.net" {
|
||||
t.Errorf("got %q", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBest(t *testing.T) {
|
||||
now := time.Now()
|
||||
f := func(reach int, ago time.Duration) Found {
|
||||
return Found{Status: Status{Reach: reach, LastActive: now.Add(-ago), PID: int(ago)}}
|
||||
}
|
||||
// In view beats more recently used but needing a tmux switch.
|
||||
if b, _ := Best([]Found{f(1, time.Second), f(2, time.Hour), f(2, time.Minute)}, "", false); b.PID != int(time.Minute) {
|
||||
t.Errorf("best = %+v", b)
|
||||
}
|
||||
if _, ok := Best([]Found{f(0, 0)}, "", false); ok {
|
||||
t.Error("handed over to an instance that can't be shown")
|
||||
}
|
||||
if _, ok := Best(nil, "", false); ok {
|
||||
t.Error("best of none")
|
||||
}
|
||||
|
||||
// Clicked in a terminal: only an instance showing there will do,
|
||||
// however recently another was used.
|
||||
kitty, dropdown := "/Applications/kitty.app", "/Applications/kitty.app/Contents/kitty-quick-access.app"
|
||||
inDropdown := f(2, time.Second)
|
||||
inDropdown.App = dropdown
|
||||
inKitty := f(1, time.Hour)
|
||||
inKitty.App = kitty
|
||||
if b, ok := Best([]Found{inDropdown, inKitty}, kitty, true); !ok || b.App != kitty {
|
||||
t.Errorf("clicked in kitty: %+v %v", b, ok)
|
||||
}
|
||||
if _, ok := Best([]Found{inDropdown}, kitty, true); ok {
|
||||
t.Error("clicked in kitty, went to the dropdown")
|
||||
}
|
||||
// Clicked in Slack: the one used last, wherever.
|
||||
if b, _ := Best([]Found{inDropdown, inKitty}, "/Applications/Slack.app", false); b.App != dropdown {
|
||||
t.Errorf("clicked in Slack: %+v", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/linkhandler"
|
||||
)
|
||||
|
||||
// Where an instance shows depends on its terminal app (on macOS): for an
|
||||
// instance in tmux, the app of a tmux client showing its session, or one
|
||||
// that could be switched to it. front is the app the link was clicked in;
|
||||
// clients in it are preferred, so a link clicked in a terminal opens
|
||||
// there.
|
||||
|
||||
// tmux runs tmux on the server this instance runs in.
|
||||
func tmux(args ...string) (string, error) {
|
||||
out, err := exec.Command("tmux", args...).Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
|
||||
|
||||
type client struct {
|
||||
name, session, activity string // activity: Unix seconds
|
||||
pid int
|
||||
app string
|
||||
}
|
||||
|
||||
// clients lists the tmux server's clients, the one last typed in first.
|
||||
func clients() []client {
|
||||
out, err := tmux("list-clients", "-F", "#{client_activity} #{client_pid} #{client_name} #{session_name}")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cs []client
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
f := strings.SplitN(l, " ", 4)
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(f[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
|
||||
}
|
||||
// Activity has the same width for centuries: strings compare like
|
||||
// numbers.
|
||||
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
|
||||
return cs
|
||||
}
|
||||
|
||||
// place is where this instance would show: the tmux client (zero outside
|
||||
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
|
||||
// a client on its session), 1 after switching a client, 0 nowhere.
|
||||
func place(front string) (client, string, int) {
|
||||
if !inTmux() {
|
||||
return client{}, linkhandler.HostApp(os.Getpid()), 2
|
||||
}
|
||||
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
|
||||
if err != nil {
|
||||
return client{}, "", 0
|
||||
}
|
||||
cs := clients()
|
||||
// Best first: in the front app on its session, in the front app,
|
||||
// on its session, any.
|
||||
rank := func(c client) int {
|
||||
r := 0
|
||||
if front != "" && c.app == front {
|
||||
r += 2
|
||||
}
|
||||
if c.session == session {
|
||||
r++
|
||||
}
|
||||
return r
|
||||
}
|
||||
var best client
|
||||
found := false
|
||||
for _, c := range cs {
|
||||
if !found || rank(c) > rank(best) {
|
||||
best, found = c, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return client{}, "", 0
|
||||
}
|
||||
if best.session == session {
|
||||
return best, best.app, 2
|
||||
}
|
||||
return best, best.app, 1
|
||||
}
|
||||
|
||||
// Raise brings this instance into view for a link clicked in front: in
|
||||
// tmux its window and pane, switching a client to its session if needed;
|
||||
// then its app to the front, showing a hidden dropdown terminal.
|
||||
func Raise(front string) {
|
||||
c, app, reach := place(front)
|
||||
if reach == 0 {
|
||||
return
|
||||
}
|
||||
if inTmux() {
|
||||
pane := os.Getenv("TMUX_PANE")
|
||||
_, _ = tmux("select-window", "-t", pane)
|
||||
_, _ = tmux("select-pane", "-t", pane)
|
||||
if reach == 1 {
|
||||
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case app == "" || app == front:
|
||||
// already in front (or unknown)
|
||||
case linkhandler.IsDropdown(app):
|
||||
linkhandler.ShowDropdown(app) // it isn't front, so hidden: this shows it
|
||||
default:
|
||||
// The bundle, not the name: "kitty" may pick an instance without
|
||||
// windows, which then opens a new one.
|
||||
linkhandler.Activate(app)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user