Files
magpie/internal/gui/app.go
T

316 lines
9.6 KiB
Go

package gui
import (
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"log"
"net/http"
"os"
"runtime"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/update"
)
//go:embed tray.png
var trayIcon []byte // black glyph, tinted by the macOS menu bar
//go:embed icon.png
var appIcon []byte // coloured, for other trays and the about box
type host struct {
app *application.App
panel *application.WebviewWindow
main *application.WebviewWindow
tray *application.SystemTray
panelHeight int
query string // what the windows' URLs carry (a forced theme)
ready chan struct{} // closed once the main window can be shown
readyOnce sync.Once
}
// whenReady runs fn once the main window can be shown safely.
func (h *host) whenReady(fn func()) {
go func() {
<-h.ready
fn()
}()
}
func (h *host) HidePanel() { h.panel.Hide() }
func (h *host) ShowMain(view string) {
h.panel.Hide()
if view != "" {
h.main.SetURL("/?view=" + view + h.query)
}
h.main.Show()
h.main.Focus()
}
// Import opens the window on an import link, for the user to confirm.
func (h *host) Import(link string) {
id := stash(link)
h.whenReady(func() {
h.panel.Hide()
h.main.SetURL("/?view=providers&import=" + id + h.query)
h.main.Show()
h.main.Focus()
})
}
func (h *host) Quit() { h.app.Quit() }
func (h *host) OpenURL(url string) { _ = h.app.Browser.OpenURL(url) }
func (h *host) OpenFolder(path string) { _ = h.app.Env.OpenFileManager(path, false) }
const panelWidth, panelMin, panelMax = 440, 220, 720
// FitPanel grows or shrinks the panel to its content and keeps it anchored
// under the tray icon.
func (h *host) FitPanel(height int) {
height = max(panelMin, min(panelMax, height))
if h.panelHeight == height {
return
}
h.panelHeight = height
h.panel.SetSize(panelWidth, height)
if h.panel.IsVisible() {
_ = h.tray.PositionWindow(h.panel, 6)
}
}
// Run starts the desktop app: a menu bar icon whose click drops down a compact
// panel, plus a regular window for when you want it to stay around.
// showMain opens the window immediately; otherwise only the tray icon appears.
// link is a magpie:// link the app was started with, to confirm and import.
func Run(version string, showMain bool, link string) error {
Version = version
// `make dev` runs the backend on its own, so a Go change restarts only
// that, behind windows that stay up.
if devRole() == "backend" {
return devBackend(func(w Windows) http.Handler { return Handler(w, startBackend()) })
}
// After an update off the Mac, the old process starts this one and then
// quits; let it go before looking for the gateway.
update.AwaitPredecessor()
go func() {
if err := registerScheme(); err != nil {
log.Println("magpie:// links:", err)
}
}()
// MAGPIE_THEME=light|dark forces the palette; handy for screenshots.
theme := ""
if t := os.Getenv("MAGPIE_THEME"); t != "" {
theme = "&theme=" + t
}
h := &host{query: theme, ready: make(chan struct{})}
handler := devShell(h)
if handler == nil {
handler = Handler(h, startBackend())
}
h.app = application.New(application.Options{
// Windows and Linux start a new process for a magpie:// link (or a
// second launch); it hands its arguments to the running one and quits.
// The Mac sends the link to the running app itself.
SingleInstance: singleInstance(h),
Name: "magpie",
Description: "one place to pick every agent's model",
Icon: appIcon,
Assets: application.AssetOptions{Handler: handler},
Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyAccessory},
Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true},
// A version downloaded but not restarted into is installed on the
// way out, so the next launch is the new one.
OnShutdown: func() { updates.install() },
// Wails exits on some webview errors; say why before it does.
ErrorHandler: func(err error) { log.Println("magpie:", err) },
})
h.panel = h.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "panel",
Title: "magpie",
URL: "/?mode=panel" + theme,
Width: panelWidth,
Height: 520,
Hidden: true,
Frameless: true,
AlwaysOnTop: true,
DisableResize: true,
HideOnEscape: true,
HideOnFocusLost: true,
BackgroundType: application.BackgroundTypeTranslucent,
Mac: application.MacWindow{
Backdrop: application.MacBackdropTranslucent,
CornerRadius: 12,
},
Windows: application.WindowsWindow{HiddenOnTaskbar: true},
})
h.main = h.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "magpie",
URL: "/?" + theme,
Width: 660,
Height: 600,
MinWidth: 560,
MinHeight: 420,
Hidden: true,
Mac: application.MacWindow{
// no InvisibleTitleBarHeight: that strip drags from anywhere in
// it, tabs included; the header marks what drags instead
TitleBar: application.MacTitleBarHiddenInset,
},
})
// Closing the window keeps the tray alive; quitting is a menu action.
h.main.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
h.main.Hide()
e.Cancel()
})
menu := h.app.NewMenu()
menu.Add("Open magpie").OnClick(func(*application.Context) { h.ShowMain("") })
menu.AddSeparator()
menu.Add("Version " + version).SetEnabled(false)
restart := menu.Add("Restart to Update").SetHidden(true)
restart.OnClick(func(*application.Context) {
if restartToUpdate() {
h.app.Quit()
}
})
menu.Add("Quit magpie").OnClick(func(*application.Context) { h.app.Quit() })
updates.onReady = func(v string) {
application.InvokeSync(func() {
restart.SetLabel("Restart to Update to " + v).SetHidden(false)
menu.Update()
})
}
updates.start()
h.tray = h.app.SystemTray.New()
h.tray.SetTooltip("magpie")
if runtime.GOOS == "darwin" {
h.tray.SetTemplateIcon(trayIcon)
} else {
h.tray.SetIcon(appIcon)
}
h.tray.SetMenu(menu)
h.tray.AttachWindow(h.panel).WindowOffset(6)
// the quick panel by the icon, or the main window if the user would
// rather (Settings → Tray icon)
h.tray.OnClick(func() {
if settings.Load().Tray == "window" {
h.ShowMain("")
return
}
h.tray.ToggleWindow()
})
// Wails shows a Windows webview 3s after Show whether or not WebView2
// has made its controller yet, and a slow first start then crashes on
// the nil controller; there, wait for the first page.
markReady := func() { h.readyOnce.Do(func() { close(h.ready) }) }
if runtime.GOOS == "windows" {
h.main.OnWindowEvent(events.Windows.WebViewNavigationCompleted, func(*application.WindowEvent) { markReady() })
} else {
h.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { markReady() })
}
if showMain {
h.whenReady(func() { h.ShowMain("") })
}
if link != "" {
h.Import(link)
}
// Windows and Linux also report the start's own link as an event.
var skip sync.Once
h.app.Event.OnApplicationEvent(events.Common.ApplicationLaunchedWithUrl, func(e *application.ApplicationEvent) {
u := ImportLink([]string{e.Context().URL()})
dup := false
if u == link {
skip.Do(func() { dup = true })
}
if u != "" && !dup {
h.Import(u)
}
})
return h.app.Run()
}
// startBackend starts what serves the page and the agents: the gateway,
// unless another magpie has it (then that one serves and this one only
// shows its status, and gw is nil), and the model lists kept warm.
func startBackend() (gw *gateway.Server) {
if !gateway.Running() {
gw = gateway.New()
go func() {
if err := gw.ListenAndServe(context.Background()); err != nil {
log.Println("gateway:", err)
}
}()
}
// Model lists are fetched, never compiled in: whatever the agents can see
// comes from the models.dev catalog plus each vendor's own /models answer.
// Keep both halves warm without making the user click anything.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if catalog.Stale() {
if err := catalog.Sync(ctx); err != nil {
log.Println("catalog:", err)
}
}
cancel()
// A signed-in agent's list exists only at the vendor; fill it in the
// first time so the picker never shows a stale snapshot.
for _, p := range provider.All() {
if p.Account == nil || !p.Ready() {
continue
}
if _, ok := p.Fetched(); ok {
continue
}
c, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if _, err := p.Fetch(c); err != nil {
log.Println(p.ID + ": " + err.Error())
}
cancel()
}
}()
return gw
}
// singleInstance makes a second launch hand over to this one, off the Mac.
// The id covers the executable and the config dir, so a build elsewhere or
// a sandboxed HOME runs on its own.
func singleInstance(h *host) *application.SingleInstanceOptions {
if runtime.GOOS == "darwin" || !sessionBus() {
return nil
}
exe, _ := os.Executable()
sum := sha256.Sum256([]byte(exe + "\x00" + settings.Dir()))
return &application.SingleInstanceOptions{
UniqueID: "ai.usemagpie.app.i" + hex.EncodeToString(sum[:6]),
OnSecondInstanceLaunch: func(d application.SecondInstanceData) {
args := d.Args
if len(args) > 0 {
args = args[1:]
}
switch {
case ImportLink(args) != "":
h.Import(ImportLink(args))
case len(args) == 1 && args[0] == "tray":
default:
h.whenReady(func() { h.ShowMain("") })
}
},
}
}