Files
arcrun-collector/cmd/arcrun-app/apps.go
T

496 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// apps.go — 桌面小幫手的 App 啟動器後端(inkstone/arcrun-rag#137
//
// ─────────────────────────────────────────────────────────────────────────────
// 這個檔案在解什麼
// ─────────────────────────────────────────────────────────────────────────────
// leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的九宮格啟動界面,
// 每個 App 有一個 icon**這會運行在 portal 及 daemon**。」
//
// 桌面這半沒有自己的 KBDB,也**不准**在本機另存一份 App 清單
// (上游 `inkstone/Arcrun#82` 已定「安裝態只有一份真相源」=實例的 `{tenant}:app:{id}`)。
// ⇒ 本檔做的事只有一件:**去使用者連的那個實例問**,把答案原樣交給畫面。
// 沒有任何預設清單、沒有快取到磁碟、沒有第二份 schema。
//
// ─────────────────────────────────────────────────────────────────────────────
// 為什麼是「兩條路」而不是一條(這是本票的規格判斷,理由寫在這裡不寫在票上)
// ─────────────────────────────────────────────────────────────────────────────
// 上游同一份安裝態有兩個對外面:
//
// ① `/portal/data/apps*` 認 **portal session**Bearer)——Portal 那半走這條,
// 清單/詳情(含畫面 HTML)/動作三支齊全。
// ② `/apps` 認 **X-Arcrun-API-Key**(=實例 namespace)——CLI/操盤 AI 走這條,
// **只有清單**,沒有詳情、沒有動作。
//
// 小幫手手上本來就有 ②的鑰匙(`config.json` 的 `api_key`,連線時實例自己下發的),
// 但**沒有**①的 session(密碼刻意零落地,見 connect.go)。
//
// 所以:
// - **清單一律先走①、拿不到就退回②**。兩支回的都是 `summarizeApp` 的同一個形狀
// id/name/icon/has_ui/version),所以九宮格**在完全沒登入的情況下也是真資料**。
// - **詳情與動作只有①有**,所以那兩件需要一次 portal 登入。連線精靈當下順手換一張
// session`Connect` 已經有密碼在手,見 connect.go 的呼叫點),使用者通常不會再被問。
// session 過期(實例預設 7 天)才會在 App 頁上出現一次登入框。
//
// 🔴 密碼仍然零落地:存的是 session token,不是密碼(與 connect.go 同一條紅線)。
//
// ─────────────────────────────────────────────────────────────────────────────
// 不做輪詢
// ─────────────────────────────────────────────────────────────────────────────
// 畫面每秒打一次 `GetState()`,但**本檔的任何函式都不掛在那條線上**——
// App 清單只在「打開啟動器」與「按重新整理」時才去問實例一次。
// 把網路請求塞進每秒的 tick 等於自己造一個輪詢器,那是明令禁止的形狀。
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// appHTTPTimeoutApp 動作背後是一整條工作流(可能打 LLM),比單純讀清單久得多。
// 清單/詳情用短的,動作用長的——不讓「讀清單」被一條慢工作流的預算拖著。
const (
appReadTimeout = 20 * time.Second
appActionTimeout = 120 * time.Second
)
// ── 前端要的形狀 ───────────────────────────────────────────────────────────
// UIApp=九宮格上的一格。逐欄對應上游 `summarizeApp`,不多不少——
// 多一欄就是在桌面端發明一個實例不認得的概念。
type UIApp struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
HasUI bool `json:"hasUi"`
Version string `json:"version"`
}
// UIAppList=一個知識庫的啟動器內容。
//
// 🔴 Error 與「空清單」是兩件事,不准混成同一句話(arcrun-rag#10「寧可明顯失敗」):
// - `Error != ""` ⇒ 問不到(連不上/實例太舊沒有這支端點)——畫面要說「問不到」
// - `Error == "" && len(Apps) == 0` ⇒ 真的一個都沒裝——畫面要說「還沒有 App」
//
// 兩者長得像,但使用者該做的事完全相反。
type UIAppList struct {
AccIdx int `json:"accIdx"`
Account string `json:"account"` // 知識庫顯示名(畫面上的切換器用)
Host string `json:"host"`
Apps []UIApp `json:"apps"`
Error string `json:"error"`
// Source=這份清單是用哪把鑰匙問到的("session""apikey")。給診斷用,
// 畫面不依它分支——兩條路的內容是同一份安裝態。
Source string `json:"source"`
}
// UIAppWorkflowApp 的一條工作流(沒有自帶畫面的 App 就靠這個列表當預設畫面,
// 與 Portal 的 renderAppView 同一套呈現,不另立第二種)。
type UIAppWorkflow struct {
Name string `json:"name"`
Description string `json:"description"`
}
// UIAppDetail=點進一個 App 之後要的全部東西。
type UIAppDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Version string `json:"version"`
HasUI bool `json:"hasUi"`
UIHtml string `json:"uiHtml"`
Workflows []UIAppWorkflow `json:"workflows"`
Actions []string `json:"actions"`
// NeedsLogin=這台機器沒有(或已過期)這個知識庫的 portal session。
// 不是錯誤,是「還差一步」——畫面要長出登入框,不是紅字。
NeedsLogin bool `json:"needsLogin"`
Email string `json:"email"` // 登入框預填(帳號本來就存在 config 裡)
Error string `json:"error"`
}
// ── 內部:HTTP 小工具 ─────────────────────────────────────────────────────
// apiBaseOf 回傳這個帳號的 API basecypher origin)。
// 連線精靈存進來的 CypherURL 已經是 API 位址(normalizePortalURL 換算過),
// 這裡只做去尾斜線,**不重新發明第二套換算**。
func apiBaseOf(a accountCfg) string {
return strings.TrimRight(strings.TrimSpace(a.CypherURL), "/")
}
// accountAt 取第 idx 個帳號;越界回錯(前端傳來的索引可能落後於剛改過的設定)。
func accountAt(cfg *directConfig, idx int) (accountCfg, error) {
if cfg == nil || idx < 0 || idx >= len(cfg.Accounts) {
return accountCfg{}, errors.New("找不到這個知識庫")
}
return cfg.Accounts[idx], nil
}
// appDo 送一個請求並讀回 body。回傳 (status, body, err)err 只代表「連不上」,
// HTTP 4xx/5xx 一律當成「連得上但被拒絕」由呼叫端判讀——
// 把兩者混成一個 error 就分不出「網路斷了」與「密碼過期了」。
func appDo(req *http.Request, timeout time.Duration) (int, []byte, error) {
res, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return 0, nil, errors.New("連不上這個知識庫——請確認網路正常")
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) // App 自帶畫面可能不小,但給上限
return res.StatusCode, b, nil
}
// appErrMessage 從實例回的 JSON 撈 error 欄位;撈不到就用狀態碼講人話。
func appErrMessage(status int, body []byte) string {
var e struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &e) == nil && strings.TrimSpace(e.Error) != "" {
return e.Error
}
switch status {
case http.StatusNotFound:
return "這個知識庫還沒有 App 功能(實例版本較舊,更新後就會出現)"
case http.StatusUnauthorized, http.StatusForbidden:
return "這個知識庫的登入已過期"
}
return fmt.Sprintf("知識庫回了 HTTP %d", status)
}
// ── portal session(詳情與動作唯一的鑰匙)────────────────────────────────
// sessionValid:留 60 秒安全邊際,免得剛好在請求路上過期。
func sessionValid(a accountCfg) bool {
return strings.TrimSpace(a.PortalSession) != "" &&
(a.PortalSessionExp == 0 || time.Now().Unix() < a.PortalSessionExp-60)
}
// portalLogin 用帳密換一張 portal session token。
//
// 🔴 這支是**唯一**碰密碼的地方,而且密碼只活在參數裡:換完就丟,不寫檔、不 log。
func portalLogin(base, email, password string) (token string, expiresIn int64, err error) {
body, _ := json.Marshal(map[string]string{
"email": strings.ToLower(strings.TrimSpace(email)),
"password": password,
})
req, err := http.NewRequest(http.MethodPost, base+"/portal/login", bytes.NewReader(body))
if err != nil {
return "", 0, err
}
req.Header.Set("Content-Type", "application/json")
status, raw, err := appDo(req, appReadTimeout)
if err != nil {
return "", 0, err
}
var out struct {
Success bool `json:"success"`
SessionToken string `json:"session_token"`
SessionExpires int64 `json:"session_expires_in"`
Error string `json:"error"`
}
_ = json.Unmarshal(raw, &out)
if status != http.StatusOK || !out.Success || out.SessionToken == "" {
if strings.TrimSpace(out.Error) != "" {
return "", 0, errors.New(out.Error)
}
return "", 0, errors.New(appErrMessage(status, raw))
}
return out.SessionToken, out.SessionExpires, nil
}
// storeSession 把換到的 session 寫回設定(只存 token 與到期時間,不存密碼)。
func storeSession(idx int, token string, expiresIn int64) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if idx < 0 || idx >= len(cfg.Accounts) {
return errors.New("找不到這個知識庫")
}
cfg.Accounts[idx].PortalSession = token
if expiresIn > 0 {
cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + expiresIn
} else {
cfg.Accounts[idx].PortalSessionExp = 0
}
return saveCfg(cfg)
}
// clearSession 在實例說「這張 session 不算數」時把它丟掉。
// 留著一張已知無效的 token 只會讓下一次再撞一次 401,然後畫面又閃一下錯誤。
func clearSession(idx int) {
cfg, err := loadCfg()
if err != nil || idx < 0 || idx >= len(cfg.Accounts) {
return
}
if cfg.Accounts[idx].PortalSession == "" && cfg.Accounts[idx].PortalSessionExp == 0 {
return
}
cfg.Accounts[idx].PortalSession = ""
cfg.Accounts[idx].PortalSessionExp = 0
if err := saveCfg(cfg); err != nil {
appLog("清掉過期的知識庫登入失敗:%v", err)
}
}
// tryStoreSessionFromLogin 在連線精靈成功之後順手換一張 session(connect.go 呼叫)。
// **失敗不算連線失敗**——沒有 session 只是「App 詳情要再登入一次」,
// 而資料夾同步這條主線根本用不到它。
func tryStoreSessionFromLogin(cfg *directConfig, idx int, email, password string) {
if idx < 0 || idx >= len(cfg.Accounts) {
return
}
token, exp, err := portalLogin(apiBaseOf(cfg.Accounts[idx]), email, password)
if err != nil {
appLog("連線成功但沒換到知識庫的 App 登入(不影響同步):%v", err)
return
}
cfg.Accounts[idx].PortalSession = token
if exp > 0 {
cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + exp
}
}
// ── 前端呼叫的四支 ─────────────────────────────────────────────────────────
// ListApps 回一個知識庫上「實際裝了的」App。
//
// 兩條路的順序與理由見檔頭。**沒有任何寫死的清單**——問不到就誠實說問不到。
func (a *App) ListApps(accIdx int) UIAppList {
out := UIAppList{AccIdx: accIdx}
cfg, _ := loadCfg()
acc, err := accountAt(cfg, accIdx)
if err != nil {
out.Error = err.Error()
return out
}
out.Account = accountName(acc)
out.Host = shortHost(acc.CypherURL)
base := apiBaseOf(acc)
if base == "" {
out.Error = "這個知識庫沒有網址,請重新連線一次"
return out
}
// ① portal session(與 Portal 那半同一支端點、同一份權限)
if sessionValid(acc) {
req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps", nil)
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
status, raw, derr := appDo(req, appReadTimeout)
switch {
case derr != nil:
out.Error = derr.Error()
return out
case status == http.StatusOK:
out.Apps = parseAppList(raw)
out.Source = "session"
return out
case status == http.StatusUnauthorized || status == http.StatusForbidden:
clearSession(accIdx) // 過期了 ⇒ 掉到 ② 去,清單照樣看得到
default:
// 其他錯(例如 5xx)**不要**掉到 ②——那會把「實例出問題」偽裝成正常。
out.Error = appErrMessage(status, raw)
return out
}
}
// ② 實例 API key(小幫手本來就有的那把;只有清單,沒有詳情/動作)
if strings.TrimSpace(acc.APIKey) == "" {
out.Error = "這台電腦還沒有這個知識庫的鑰匙,請重新連線一次"
return out
}
req, _ := http.NewRequest(http.MethodGet, base+"/apps", nil)
req.Header.Set("X-Arcrun-API-Key", acc.APIKey)
status, raw, derr := appDo(req, appReadTimeout)
if derr != nil {
out.Error = derr.Error()
return out
}
if status != http.StatusOK {
out.Error = appErrMessage(status, raw)
return out
}
out.Apps = parseAppList(raw)
out.Source = "apikey"
return out
}
// parseAppList 讀 `{apps:[…]}`。兩支端點回的都是 summarizeApp 的形狀,所以只有一份解析。
// 純函式(單測用)。
func parseAppList(raw []byte) []UIApp {
var body struct {
Apps []struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
HasUI bool `json:"has_ui"`
Version string `json:"version"`
} `json:"apps"`
}
if json.Unmarshal(raw, &body) != nil {
return nil
}
apps := make([]UIApp, 0, len(body.Apps))
for _, x := range body.Apps {
name := x.Name
if strings.TrimSpace(name) == "" {
name = x.ID
}
apps = append(apps, UIApp{ID: x.ID, Name: name, Icon: x.Icon, HasUI: x.HasUI, Version: x.Version})
}
return apps
}
// GetApp 取一個 App 的詳情(自帶畫面/工作流清單/動作白名單)。
//
// 🔴 只有 portal session 這一條路——上游的 API key 面**沒有**詳情端點
//
// `/apps` 只有列表;見 `cypher-executor/src/routes/apps.ts`)。
// 所以沒有 session 時回 NeedsLogin,讓畫面長一次登入框,而不是回一句錯誤。
func (a *App) GetApp(accIdx int, id string) UIAppDetail {
out := UIAppDetail{ID: id}
cfg, _ := loadCfg()
acc, err := accountAt(cfg, accIdx)
if err != nil {
out.Error = err.Error()
return out
}
out.Email = acc.Email
if !sessionValid(acc) {
out.NeedsLogin = true
return out
}
base := apiBaseOf(acc)
req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps/"+urlPathEscape(id), nil)
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
status, raw, derr := appDo(req, appReadTimeout)
if derr != nil {
out.Error = derr.Error()
return out
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
clearSession(accIdx)
out.NeedsLogin = true
return out
}
if status != http.StatusOK {
out.Error = appErrMessage(status, raw)
return out
}
var d struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Version string `json:"version"`
HasUI bool `json:"has_ui"`
UIHtml string `json:"ui_html"`
Actions []string `json:"actions"`
Workflows []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"workflows"`
}
if json.Unmarshal(raw, &d) != nil {
out.Error = "這個知識庫回的內容看不懂(版本可能不相容)"
return out
}
out.ID, out.Name, out.Icon, out.Version = d.ID, d.Name, d.Icon, d.Version
out.HasUI, out.UIHtml, out.Actions = d.HasUI, d.UIHtml, d.Actions
for _, w := range d.Workflows {
out.Workflows = append(out.Workflows, UIAppWorkflow{Name: w.Name, Description: w.Description})
}
if strings.TrimSpace(out.Name) == "" {
out.Name = out.ID
}
return out
}
// RunAppAction 觸發 App 的一個白名單動作,回傳實例給的結果 JSON(原樣字串)。
//
// 🔴 白名單由**實例**裁決,不在這裡複製一份判斷(K6:前端/桌面端都不是裁決點)。
//
// 這裡不認得任何 action 名稱,只負責把使用者按的那顆按鈕送出去。
//
// payloadJSON 是前端組好的 JSON 物件字串;空字串=沒有 payload。
func (a *App) RunAppAction(accIdx int, id, action, payloadJSON string) (string, error) {
cfg, _ := loadCfg()
acc, err := accountAt(cfg, accIdx)
if err != nil {
return "", err
}
if strings.TrimSpace(action) == "" {
return "", errors.New("沒有指定要做什麼")
}
if !sessionValid(acc) {
return "", errors.New("這個知識庫的登入已過期,請在畫面上重新登入一次")
}
var payload any = map[string]any{}
if s := strings.TrimSpace(payloadJSON); s != "" {
if err := json.Unmarshal([]byte(s), &payload); err != nil {
return "", errors.New("送出的內容格式不對")
}
}
body, _ := json.Marshal(map[string]any{"action": action, "payload": payload})
req, err := http.NewRequest(http.MethodPost,
apiBaseOf(acc)+"/portal/data/apps/"+urlPathEscape(id)+"/action", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
status, raw, derr := appDo(req, appActionTimeout)
if derr != nil {
return "", derr
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
// 403 也可能是「這個動作不在白名單」——那句話由實例自己講,這裡照抄。
if status == http.StatusUnauthorized {
clearSession(accIdx)
}
return "", errors.New(appErrMessage(status, raw))
}
if status != http.StatusOK {
return "", errors.New(appErrMessage(status, raw))
}
return string(raw), nil
}
// PortalLogin 讓使用者在 App 頁上補一次登入(session 過期時)。
// email 用 config 裡存的那個(就是他連線時用的帳號),只問密碼。
func (a *App) PortalLogin(accIdx int, password string) error {
cfg, _ := loadCfg()
acc, err := accountAt(cfg, accIdx)
if err != nil {
return err
}
if strings.TrimSpace(acc.Email) == "" {
return errors.New("這個知識庫沒有記錄帳號,請用「新增知識庫帳號」重新連一次")
}
token, exp, err := portalLogin(apiBaseOf(acc), acc.Email, password)
if err != nil {
return err
}
return storeSession(accIdx, token, exp)
}
// urlPathEscapeApp id 的字元集上游已限死 `^[a-z][a-z0-9_-]{0,63}$`
// 但我們是**收方**不是**發方**——照樣 escape,不假設對面永遠守規矩。
func urlPathEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.', r == '~':
b.WriteRune(r)
default:
for _, c := range []byte(string(r)) {
fmt.Fprintf(&b, "%%%02X", c)
}
}
}
return b.String()
}