internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into 15 per-resource files: - actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget, executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate) - checks.go, classifications.go, executions.go, approvals.go, patterns.go, skills.go, approval_rules.go, autonomy.go, risk_classes.go, relationships.go, entity_types.go, metrics.go, agent_activity.go, helpers.go — one file per resource domain, each with its own imports. internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations) refactored to a registry pattern: - internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33 tool definitions. Handler logic moved verbatim — no changes to tool names, descriptions, schemas, or behavior. - server.go: newServer is now 9 lines (iterate registry, AddTool each). -699 lines. No function logic, names, or signatures changed. go vet, build, and all tests pass (httpapi, mcp, db, policy).
789 lines
19 KiB
Go
789 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
"github.com/wailsapp/wails/v3/pkg/events"
|
|
"github.com/zalando/go-keyring"
|
|
)
|
|
|
|
//go:embed frontend/dist
|
|
var assets embed.FS
|
|
|
|
//go:embed icon.png
|
|
var iconPNG []byte
|
|
|
|
const (
|
|
keyringService = "com.hubris.oikos-desktop"
|
|
keyringUser = "oikos"
|
|
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
|
pollInterval = 30 * time.Second
|
|
updateInterval = 6 * time.Hour
|
|
oidcCallbackPort = 18901
|
|
)
|
|
|
|
// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
|
|
// (Makefile desktop target). The default keeps a non-empty fallback for
|
|
// `go build ./cmd/desktop` without ldflags.
|
|
var version = "0.1.0-dev"
|
|
|
|
type OikosConfig struct {
|
|
ApiUrl string `json:"apiUrl"`
|
|
Token string `json:"token,omitempty"`
|
|
IsDesktop bool `json:"isDesktop"`
|
|
}
|
|
|
|
// ---- ConfigService ----
|
|
|
|
type ConfigService struct{}
|
|
|
|
func (c *ConfigService) Name() string { return "config" }
|
|
|
|
func (c *ConfigService) SaveConfig(apiUrl, token string) error {
|
|
cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
|
|
data, _ := json.Marshal(cfg)
|
|
return keyring.Set(keyringService, keyringUser, string(data))
|
|
}
|
|
|
|
func (c *ConfigService) ClearConfig() error {
|
|
return keyring.Delete(keyringService, keyringUser)
|
|
}
|
|
|
|
func (c *ConfigService) GetStoredConfig() *OikosConfig {
|
|
return loadConfig()
|
|
}
|
|
|
|
func (c *ConfigService) EnableAutoStart() error {
|
|
if runtime.GOOS != "darwin" {
|
|
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
|
}
|
|
usr, _ := user.Current()
|
|
dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
|
|
os.MkdirAll(dir, 0755)
|
|
|
|
exe, _ := os.Executable()
|
|
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>com.hubris.oikos-desktop</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>%s</string>
|
|
</array>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<false/>
|
|
</dict>
|
|
</plist>`, exe)
|
|
|
|
return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
|
|
}
|
|
|
|
func (c *ConfigService) DisableAutoStart() error {
|
|
if runtime.GOOS != "darwin" {
|
|
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
|
}
|
|
usr, _ := user.Current()
|
|
path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
|
|
return os.Remove(path)
|
|
}
|
|
|
|
// ---- Local OIDC server (runs alongside the webview) ----
|
|
|
|
type oidcSession struct {
|
|
apiUrl string
|
|
verifier string
|
|
state string
|
|
ch chan string
|
|
}
|
|
|
|
var (
|
|
oidcSessionsMu sync.Mutex
|
|
oidcSessions = make(map[string]*oidcSession)
|
|
)
|
|
|
|
func startOIDCServer() *http.Server {
|
|
mux := http.NewServeMux()
|
|
|
|
cors := func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}
|
|
|
|
h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
|
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w, r)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
handler(w, r)
|
|
})
|
|
}
|
|
|
|
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
|
|
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
|
returnURL := r.URL.Query().Get("ret")
|
|
if apiUrl == "" {
|
|
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if returnURL == "" {
|
|
returnURL = "/?desktop=1"
|
|
}
|
|
|
|
oidcCfg, err := fetchOIDCConfig(apiUrl)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
verifier, challenge, _ := pkceParams()
|
|
state := randomString(32)
|
|
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
|
|
|
|
ch := make(chan string, 1)
|
|
oidcSessionsMu.Lock()
|
|
sessionID := randomString(16)
|
|
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
|
|
oidcSessionsMu.Unlock()
|
|
|
|
authURL := fmt.Sprintf("%s?%s",
|
|
oidcCfg.AuthorizationEndpoint,
|
|
url.Values{
|
|
"response_type": {"code"},
|
|
"client_id": {oidcCfg.ClientID},
|
|
"redirect_uri": {redirectURI},
|
|
"code_challenge": {challenge},
|
|
"code_challenge_method": {"S256"},
|
|
"state": {state},
|
|
"scope": {"openid profile email"},
|
|
}.Encode(),
|
|
)
|
|
|
|
exec.Command("open", authURL).Start()
|
|
|
|
select {
|
|
case token := <-ch:
|
|
if token != "" {
|
|
c := &ConfigService{}
|
|
c.SaveConfig(apiUrl, token)
|
|
returnURL += "&token=" + url.QueryEscape(token)
|
|
}
|
|
case <-time.After(5 * time.Minute):
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
|
<meta http-equiv="refresh" content="0;url=%s">
|
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
|
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
|
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
|
|
})
|
|
|
|
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
code := r.URL.Query().Get("code")
|
|
gotState := r.URL.Query().Get("state")
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
oidcSessionsMu.Lock()
|
|
var session *oidcSession
|
|
var sessionID string
|
|
for id, s := range oidcSessions {
|
|
if s.state == gotState {
|
|
session = s
|
|
sessionID = id
|
|
break
|
|
}
|
|
}
|
|
oidcSessionsMu.Unlock()
|
|
|
|
if session == nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("Invalid state."))
|
|
return
|
|
}
|
|
|
|
token, err := exchangeCode(
|
|
session.apiUrl,
|
|
code, session.verifier,
|
|
fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
|
|
)
|
|
|
|
oidcSessionsMu.Lock()
|
|
delete(oidcSessions, sessionID)
|
|
oidcSessionsMu.Unlock()
|
|
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintf(w, "Token exchange failed: %v", err)
|
|
session.ch <- ""
|
|
return
|
|
}
|
|
|
|
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
|
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
|
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
|
|
session.ch <- token
|
|
})
|
|
|
|
mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
|
|
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
|
if apiUrl == "" {
|
|
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
cfg, err := fetchOIDCConfig(apiUrl)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(cfg)
|
|
})
|
|
|
|
mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
|
|
latest := fetchLatestRelease()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
if latest == nil {
|
|
json.NewEncoder(w).Encode(map[string]string{"current": version})
|
|
return
|
|
}
|
|
hasAsset := false
|
|
for _, a := range latest.Assets {
|
|
if strings.Contains(a.Name, "darwin") {
|
|
hasAsset = true
|
|
updater.mu.Lock()
|
|
updater.latestURL = a.BrowserDownloadURL
|
|
updater.mu.Unlock()
|
|
break
|
|
}
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"current": version,
|
|
"latest": latest.Version,
|
|
"has_asset": fmt.Sprintf("%t", hasAsset),
|
|
})
|
|
})
|
|
|
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
|
|
if err != nil {
|
|
log.Printf("OIDC server: %v", err)
|
|
return nil
|
|
}
|
|
log.Printf("OIDC server listening on %s", listener.Addr())
|
|
srv := &http.Server{Handler: mux}
|
|
go srv.Serve(listener)
|
|
return srv
|
|
}
|
|
|
|
// ---- Window persistence ----
|
|
|
|
type windowState struct {
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
}
|
|
|
|
func windowStatePath() string {
|
|
usr, _ := user.Current()
|
|
return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
|
|
}
|
|
|
|
func loadWindowState() *windowState {
|
|
data, err := os.ReadFile(windowStatePath())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var ws windowState
|
|
if err := json.Unmarshal(data, &ws); err != nil {
|
|
return nil
|
|
}
|
|
if ws.Width < 200 || ws.Height < 200 {
|
|
return nil
|
|
}
|
|
return &ws
|
|
}
|
|
|
|
func saveWindowState(w application.Window) {
|
|
x, y := w.Position()
|
|
width, height := w.Size()
|
|
ws := windowState{X: x, Y: y, Width: width, Height: height}
|
|
data, _ := json.Marshal(ws)
|
|
|
|
usr, _ := user.Current()
|
|
dir := filepath.Join(usr.HomeDir, ".config", "oikos")
|
|
os.MkdirAll(dir, 0755)
|
|
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
|
|
}
|
|
|
|
func loadConfig() *OikosConfig {
|
|
data, err := keyring.Get(keyringService, keyringUser)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var cfg OikosConfig
|
|
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
|
|
return nil
|
|
}
|
|
cfg.IsDesktop = true
|
|
return &cfg
|
|
}
|
|
|
|
type oidcConfig struct {
|
|
Issuer string `json:"issuer"`
|
|
ClientID string `json:"client_id"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
}
|
|
|
|
func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
|
|
resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
|
|
}
|
|
var cfg oidcConfig
|
|
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func pkceParams() (verifier, challenge string, _ error) {
|
|
v := randomString(64)
|
|
h := sha256.Sum256([]byte(v))
|
|
return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
|
|
}
|
|
|
|
func randomString(n int) string {
|
|
b := make([]byte, n)
|
|
rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
|
|
body, _ := json.Marshal(map[string]string{
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"code_verifier": verifier,
|
|
"redirect_uri": redirectURI,
|
|
})
|
|
|
|
resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
|
|
}
|
|
|
|
var tokens struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
|
|
return "", err
|
|
}
|
|
if tokens.AccessToken == "" {
|
|
return "", fmt.Errorf("no access_token in response")
|
|
}
|
|
return tokens.AccessToken, nil
|
|
}
|
|
|
|
// ---- Notifications ----
|
|
|
|
type dashboardSummary struct {
|
|
ApprovalsPending int `json:"approvals_pending"`
|
|
Signals struct {
|
|
Critical int `json:"critical"`
|
|
} `json:"signals_by_severity"`
|
|
}
|
|
|
|
func (d *dashboardSummary) alertCount() int {
|
|
return d.ApprovalsPending + d.Signals.Critical
|
|
}
|
|
|
|
func notify(title, subtitle string) {
|
|
if runtime.GOOS != "darwin" {
|
|
return
|
|
}
|
|
script := fmt.Sprintf(
|
|
`display notification "%s" with title "%s" sound name "default"`,
|
|
strings.ReplaceAll(subtitle, `"`, `\"`),
|
|
strings.ReplaceAll(title, `"`, `\"`),
|
|
)
|
|
exec.Command("osascript", "-e", script).Run()
|
|
}
|
|
|
|
func pollDashboard(cfg *OikosConfig) {
|
|
if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
|
|
return
|
|
}
|
|
|
|
var lastCount int
|
|
first := true
|
|
|
|
for {
|
|
req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
|
|
if err != nil {
|
|
time.Sleep(pollInterval)
|
|
continue
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
time.Sleep(pollInterval)
|
|
continue
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
var summary dashboardSummary
|
|
if err := json.Unmarshal(body, &summary); err != nil {
|
|
time.Sleep(pollInterval)
|
|
continue
|
|
}
|
|
|
|
if first {
|
|
lastCount = summary.alertCount()
|
|
first = false
|
|
} else {
|
|
current := summary.alertCount()
|
|
if current > lastCount {
|
|
notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
|
|
}
|
|
lastCount = current
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
}
|
|
|
|
// ---- Auto-update ----
|
|
|
|
type giteaRelease struct {
|
|
TagName string `json:"tag_name"`
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
} `json:"assets"`
|
|
}
|
|
|
|
type updateState struct {
|
|
mu sync.Mutex
|
|
latestURL string
|
|
}
|
|
|
|
var updater = &updateState{}
|
|
|
|
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
|
|
// the download URL and returns the latest version string (empty if current).
|
|
func (c *ConfigService) CheckForUpdates() string {
|
|
latest := fetchLatestRelease()
|
|
if latest == nil || latest.Version == version {
|
|
return ""
|
|
}
|
|
for _, a := range latest.Assets {
|
|
if strings.Contains(a.Name, "darwin") {
|
|
updater.mu.Lock()
|
|
updater.latestURL = a.BrowserDownloadURL
|
|
updater.mu.Unlock()
|
|
return latest.Version
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// InstallUpdate downloads the stored update, replaces the app, and restarts.
|
|
func (c *ConfigService) InstallUpdate() error {
|
|
updater.mu.Lock()
|
|
url := updater.latestURL
|
|
updater.mu.Unlock()
|
|
if url == "" {
|
|
return fmt.Errorf("no update available")
|
|
}
|
|
return doUpdate(url)
|
|
}
|
|
|
|
type latestRelease struct {
|
|
Version string
|
|
Assets []struct {
|
|
Name string
|
|
BrowserDownloadURL string
|
|
}
|
|
}
|
|
|
|
func fetchLatestRelease() *latestRelease {
|
|
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var releases []giteaRelease
|
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
|
|
return nil
|
|
}
|
|
|
|
r := releases[0]
|
|
v := strings.TrimPrefix(r.TagName, "v")
|
|
if v == version {
|
|
return nil
|
|
}
|
|
|
|
lr := &latestRelease{Version: v}
|
|
for _, a := range r.Assets {
|
|
lr.Assets = append(lr.Assets, struct {
|
|
Name string
|
|
BrowserDownloadURL string
|
|
}{a.Name, a.BrowserDownloadURL})
|
|
}
|
|
return lr
|
|
}
|
|
|
|
func doUpdate(downloadURL string) error {
|
|
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
|
|
resp, err := http.Get(downloadURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
|
return err
|
|
}
|
|
tmp.Close()
|
|
|
|
extractDir, err := os.MkdirTemp("", "oikos-extract")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(extractDir)
|
|
|
|
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("unzip: %w: %s", err, out)
|
|
}
|
|
|
|
newApp := filepath.Join(extractDir, "Oikos.app")
|
|
if _, err := os.Stat(newApp); err != nil {
|
|
return fmt.Errorf("extracted app not found: %w", err)
|
|
}
|
|
|
|
currentApp := "/Applications/Oikos.app"
|
|
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
|
|
if exe, err := os.Executable(); err == nil {
|
|
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
|
|
}
|
|
}
|
|
|
|
script := fmt.Sprintf(`#!/bin/bash
|
|
sleep 2
|
|
rm -rf "%s"
|
|
mv "%s" "%s"
|
|
open "%s"
|
|
rm "$0"
|
|
`, currentApp, newApp, currentApp, currentApp)
|
|
|
|
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
app := application.Get()
|
|
exec.Command("open", scriptPath).Start()
|
|
if app != nil {
|
|
app.Quit()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkUpdates() {
|
|
for {
|
|
time.Sleep(updateInterval)
|
|
|
|
latest := fetchLatestRelease()
|
|
if latest == nil {
|
|
continue
|
|
}
|
|
|
|
for _, a := range latest.Assets {
|
|
if strings.Contains(a.Name, "darwin") {
|
|
updater.mu.Lock()
|
|
updater.latestURL = a.BrowserDownloadURL
|
|
updater.mu.Unlock()
|
|
|
|
app := application.Get()
|
|
if app == nil {
|
|
continue
|
|
}
|
|
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
|
|
app.Dialog.Info().
|
|
SetTitle("Update Available").
|
|
SetMessage(msg).
|
|
Show()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Main ----
|
|
|
|
func main() {
|
|
oidcSrv := startOIDCServer()
|
|
defer oidcSrv.Close()
|
|
|
|
distFS, err := fs.Sub(assets, "frontend/dist")
|
|
if err != nil {
|
|
log.Fatalf("embedded assets: %v", err)
|
|
}
|
|
|
|
app := application.New(application.Options{
|
|
Name: "Oikos",
|
|
Description: "Homelab Control Room",
|
|
Services: []application.Service{
|
|
application.NewService(&ConfigService{}),
|
|
},
|
|
Assets: application.AssetOptions{
|
|
Handler: application.AssetFileServerFS(distFS),
|
|
},
|
|
Mac: application.MacOptions{
|
|
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
|
},
|
|
})
|
|
|
|
systemTray := app.SystemTray.New()
|
|
systemTray.SetTooltip("Oikos")
|
|
systemTray.SetIcon(iconPNG)
|
|
|
|
trayMenu := application.NewMenu()
|
|
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
|
|
for _, w := range app.Window.GetAll() {
|
|
w.Show()
|
|
w.Focus()
|
|
}
|
|
})
|
|
trayMenu.AddSeparator()
|
|
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
|
|
go func() {
|
|
latest := fetchLatestRelease()
|
|
if latest == nil {
|
|
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
|
return
|
|
}
|
|
for _, a := range latest.Assets {
|
|
if strings.Contains(a.Name, "darwin") {
|
|
updater.mu.Lock()
|
|
updater.latestURL = a.BrowserDownloadURL
|
|
updater.mu.Unlock()
|
|
msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
|
|
d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
|
|
yes := d.AddButton("Install")
|
|
yes.OnClick(func() { doUpdate(updater.latestURL) })
|
|
no := d.AddButton("Later")
|
|
d.SetDefaultButton(yes)
|
|
d.SetCancelButton(no)
|
|
d.Show()
|
|
return
|
|
}
|
|
}
|
|
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
|
}()
|
|
})
|
|
trayMenu.AddSeparator()
|
|
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
|
app.Quit()
|
|
})
|
|
systemTray.SetMenu(trayMenu)
|
|
|
|
ws := loadWindowState()
|
|
width, height := 1400, 900
|
|
if ws != nil {
|
|
width = ws.Width
|
|
height = ws.Height
|
|
}
|
|
|
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
|
Title: "Oikos",
|
|
Width: width,
|
|
Height: height,
|
|
MinWidth: 1024,
|
|
MinHeight: 700,
|
|
URL: "/?desktop=1",
|
|
})
|
|
|
|
if ws != nil {
|
|
window.SetPosition(ws.X, ws.Y)
|
|
} else {
|
|
window.Center()
|
|
}
|
|
|
|
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
|
window.Hide()
|
|
e.Cancel()
|
|
})
|
|
|
|
window.Show()
|
|
|
|
systemTray.AttachWindow(window)
|
|
systemTray.Run()
|
|
|
|
app.OnShutdown(func() {
|
|
saveWindowState(window)
|
|
})
|
|
|
|
go pollDashboard(loadConfig())
|
|
go checkUpdates()
|
|
|
|
err = app.Run()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|