* feat(gui): choose macOS session terminal * session terminal: build the darwin CLI without cgo, drop the dead macOS 11 path, and open Terminal when the .command default is not a terminal - terminal_apps_darwin.go imports "C" but had no cgo tag, and terminal_apps_other.go was !darwin only, so `CGO_ENABLED=0 GOOS=darwin go build -tags nogui .` (make release-cli's darwin targets) failed with undefined discoverTerminals. The cgo file and its test are now darwin && cgo; the stub is !darwin || !cgo. - magpie needs macOS 12 (LSMinimumSystemVersion, -mmacosx-version-min=12.0), so the macOS 11 branch (temp .command file, deprecated LSCopyApplicationURLsForURL, CFRelease) never ran; it is gone along with the CoreServices link. - With no saved choice, a default .command handler that is not a listed terminal, or a failed lookup, now opens Terminal as before the PR instead of failing; Settings labels the system option "System default (Terminal)" then, and the unused "System default (unavailable)" string is dropped. - session-terminal.test.cjs covers an editor as the default in Chromium and WebKit, en and zh, and is on the README's node --test line. --------- Co-authored-by: yetone <yetoneful@gmail.com>
256 lines
7.4 KiB
Go
256 lines
7.4 KiB
Go
package gui
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/agent"
|
|
"github.com/yetone/magpie/internal/proc"
|
|
"github.com/yetone/magpie/internal/sessions"
|
|
"github.com/yetone/magpie/internal/settings"
|
|
"github.com/yetone/magpie/internal/usage"
|
|
)
|
|
|
|
// sessionJSON is a session with what the UI needs to draw its agent.
|
|
type sessionJSON struct {
|
|
sessions.Session
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
// Via: the models the gateway sent its calls to, at the reasoning
|
|
// each was asked for, when it went through magpie
|
|
Via []usage.Via `json:"via,omitempty"`
|
|
}
|
|
|
|
type sessionsJSON struct {
|
|
Sessions []sessionJSON `json:"sessions"`
|
|
// Terminal is set where magpie can open Terminal on the session: the
|
|
// Mac app, not a browser tab that may be on another computer.
|
|
Terminal bool `json:"terminal"`
|
|
Dirs []string `json:"dirs"` // where they were read from
|
|
}
|
|
|
|
func sessionRoutes(mux *http.ServeMux, w Windows) {
|
|
warmSessions()
|
|
mux.HandleFunc("GET /api/sessions", func(rw http.ResponseWriter, r *http.Request) {
|
|
n, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
agents := map[string]*agent.Agent{}
|
|
for _, a := range agent.Clients() {
|
|
agents[a.ID] = a
|
|
}
|
|
out := sessionsJSON{Sessions: []sessionJSON{}, Terminal: runtime.GOOS == "darwin" && !isWeb(w),
|
|
Dirs: []string{}}
|
|
for _, d := range sessions.Dirs() {
|
|
out.Dirs = append(out.Dirs, tilde(d))
|
|
}
|
|
list := sessions.List(n)
|
|
since := time.Now()
|
|
for _, s := range list {
|
|
if !s.Start.IsZero() && s.Start.Before(since) {
|
|
since = s.Start
|
|
}
|
|
}
|
|
vias := usage.Vias(since.Add(-time.Minute))
|
|
for _, s := range list {
|
|
j := sessionJSON{Session: s, Name: s.Agent, Icon: "generic", Via: vias[s.Agent+"|"+s.ID]}
|
|
if a := agents[s.Agent]; a != nil {
|
|
j.Name, j.Icon = a.Name, a.Icon
|
|
}
|
|
j.Path = tilde(j.Path)
|
|
out.Sessions = append(out.Sessions, j)
|
|
}
|
|
writeJSON(rw, out)
|
|
})
|
|
// stats is every session's usage by day over the last ?days=N days
|
|
// (every day when 0 or none), cut by agent, folder and model for the
|
|
// page to filter; names are the agents' as the page shows them.
|
|
mux.HandleFunc("GET /api/sessions/stats", func(rw http.ResponseWriter, r *http.Request) {
|
|
n, _ := strconv.Atoi(r.URL.Query().Get("days"))
|
|
st := statsFor(n)
|
|
names := map[string]string{}
|
|
for _, a := range agent.Clients() {
|
|
names[a.ID] = a.Name
|
|
}
|
|
agents := map[string]string{}
|
|
for _, d := range st.Days {
|
|
for _, u := range d.Usage {
|
|
agents[u.Agent] = cmp.Or(names[u.Agent], u.Agent)
|
|
}
|
|
for _, a := range d.Active {
|
|
agents[a.Agent] = cmp.Or(names[a.Agent], a.Agent)
|
|
}
|
|
}
|
|
writeJSON(rw, struct {
|
|
sessions.Stats
|
|
Agents map[string]string `json:"agents"`
|
|
}{st, agents})
|
|
})
|
|
// overview sums up the range's sessions under the page's filters:
|
|
// how many, what the middle one spent, how many each day, and the ones
|
|
// that spent the most.
|
|
mux.HandleFunc("GET /api/sessions/overview", func(rw http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
n, _ := strconv.Atoi(q.Get("days"))
|
|
writeJSON(rw, statsFor(n).Overview(q.Get("agent"), q.Get("model"), q.Get("cwd"), 8))
|
|
})
|
|
// progress is how far the reading of the session files has got, for
|
|
// the page to show while its first read takes a while.
|
|
mux.HandleFunc("GET /api/sessions/progress", func(rw http.ResponseWriter, r *http.Request) {
|
|
writeJSON(rw, sessions.Indexing())
|
|
})
|
|
// one is a session by its stats key, read however long ago it was at
|
|
// work: the page's top sessions reach past the latest List reads.
|
|
mux.HandleFunc("GET /api/sessions/one", func(rw http.ResponseWriter, r *http.Request) {
|
|
s, ok := sessions.Get(r.URL.Query().Get("key"))
|
|
if !ok {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
rw.WriteHeader(http.StatusNotFound)
|
|
_ = json.NewEncoder(rw).Encode(map[string]string{"error": "no such session"})
|
|
return
|
|
}
|
|
j := sessionJSON{Session: s, Name: s.Agent, Icon: "generic"}
|
|
for _, a := range agent.Clients() {
|
|
if a.ID == s.Agent {
|
|
j.Name, j.Icon = a.Name, a.Icon
|
|
}
|
|
}
|
|
since := s.Start
|
|
if since.IsZero() {
|
|
since = s.Last
|
|
}
|
|
j.Via = usage.Vias(since.Add(-time.Minute))[s.Agent+"|"+s.ID]
|
|
j.Path = tilde(j.Path)
|
|
writeJSON(rw, j)
|
|
})
|
|
// terminal opens Terminal on a session's resume command. The command is
|
|
// made here from the session as listed, never taken from the page.
|
|
mux.HandleFunc("POST /api/sessions/terminal", func(rw http.ResponseWriter, r *http.Request) {
|
|
var in struct{ Agent, ID string }
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
fail(rw, err)
|
|
return
|
|
}
|
|
if runtime.GOOS != "darwin" || isWeb(w) {
|
|
fail(rw, errors.New("opening Terminal is only for the Mac app"))
|
|
return
|
|
}
|
|
s, ok := sessions.Find(in.Agent, in.ID)
|
|
if !ok || s.Resume == "" {
|
|
fail(rw, errors.New("no such session"))
|
|
return
|
|
}
|
|
if err := openTerminal(s.Resume, settings.Load().SessionTerminal); err != nil {
|
|
fail(rw, err)
|
|
return
|
|
}
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
})
|
|
}
|
|
|
|
// statsFor is sessions.StatsFor for a range, kept: the page asks for the
|
|
// stats and the overview one after the other, and for the same range again
|
|
// each time it is shown. What was read is answered at once; when it is more
|
|
// than a few seconds old it is read again behind, for the next ask.
|
|
var statsMemo struct {
|
|
sync.Mutex
|
|
m map[int]*statsKept
|
|
}
|
|
|
|
type statsKept struct {
|
|
at time.Time
|
|
st sessions.Stats
|
|
busy bool
|
|
}
|
|
|
|
func statsFor(days int) sessions.Stats {
|
|
days = max(0, days)
|
|
statsMemo.Lock()
|
|
if k := statsMemo.m[days]; k != nil {
|
|
if !k.busy && time.Since(k.at) > 10*time.Second {
|
|
k.busy = true
|
|
go func() {
|
|
st := sessions.StatsFor(days)
|
|
statsMemo.Lock()
|
|
k.at, k.st, k.busy = time.Now(), st, false
|
|
statsMemo.Unlock()
|
|
}()
|
|
}
|
|
st := k.st
|
|
statsMemo.Unlock()
|
|
return st
|
|
}
|
|
statsMemo.Unlock()
|
|
st := sessions.StatsFor(days)
|
|
statsMemo.Lock()
|
|
if statsMemo.m == nil {
|
|
statsMemo.m = map[int]*statsKept{}
|
|
}
|
|
if statsMemo.m[days] == nil {
|
|
statsMemo.m[days] = &statsKept{at: time.Now(), st: st}
|
|
}
|
|
statsMemo.Unlock()
|
|
return st
|
|
}
|
|
|
|
// warmSessions reads every session file once magpie is up, so the Sessions
|
|
// page opens on the kept index and not on a first read of them all.
|
|
func warmSessions() {
|
|
if testing.Testing() {
|
|
return
|
|
}
|
|
go func() {
|
|
time.Sleep(3 * time.Second)
|
|
statsFor(0)
|
|
statsFor(30)
|
|
}()
|
|
}
|
|
|
|
// openTerminal runs a command in the chosen Mac terminal through a .command
|
|
// file, as it would open from Finder: no Automation consent.
|
|
// The shell is left open when the agent quits.
|
|
func openTerminal(command, choice string) error {
|
|
var found terminalDiscovery
|
|
if choice != terminalBundleID {
|
|
var err error
|
|
// the system default falls back to Terminal when nothing is found
|
|
if found, err = discoverTerminals(); err != nil && choice != "" && choice != "system" {
|
|
return err
|
|
}
|
|
}
|
|
args, err := terminalOpenArgs(choice, found)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.CreateTemp("", "magpie-resume-*.command")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
script := "#!/bin/sh\n" + command + "\nexec \"${SHELL:-/bin/zsh}\" -l\n"
|
|
_, err = f.WriteString(script)
|
|
if cerr := f.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err == nil {
|
|
err = os.Chmod(f.Name(), 0o700)
|
|
}
|
|
if err != nil {
|
|
os.Remove(f.Name())
|
|
return err
|
|
}
|
|
args = append(args, f.Name())
|
|
if err := proc.Command("open", args...).Run(); err != nil {
|
|
os.Remove(f.Name())
|
|
return err
|
|
}
|
|
// Terminal has read it long before
|
|
time.AfterFunc(time.Minute, func() { os.Remove(f.Name()) })
|
|
return nil
|
|
}
|