t193 二修:側邊欄版面+共用 CSS+App 內更新+兩支視覺機械閘

leo 08-04 看過 v0.17.0 後的四點,逐條回應:

## ① 「不會這裡又寫了一個新的 CSS?」——是,我又寫了一份
真兇:portal 的樣式內嵌在 HTML 裡,沒有獨立檔 ⇒ 四個站各抄一份、本來就在漂移。
解:抽出 InkStoneCo/arcrun-cis/css/arcrun-cis.css 當唯一真相源。
    daemon 的 style.css 現在**一個 hex 都沒有**,顏色全走 var(--...)。

## ② 「完全模仿 gdrive,左方側邊欄,在右方替換頁面」
底部 nav bar 拿掉(leo:「幾乎沒看過這種 nav bar 在下方的」)。
改成左側邊欄四頁:首頁/同步資料夾/AI 設定/版本與更新,右側換頁。
設定不再開 popup;只有「確認移除資料夾」才用覆蓋層。

## ③ 「既然是 Web,你做的時候無法檢視?」——可以,我之前沒做
新增 check-render.sh:headless Chrome 真的渲染一次並**量像素**。
它抓到一個肉眼容易誤判的真 bug:
  arcrun-lockup-h-paper-on-ink.png **自帶不透明 Ink 底板**(實測左上像素 RGBA=23,24,26,255),
  設計前提是貼在純 Ink 表面;我們背景有紙張紋理 ⇒ 出現突兀方塊。
  修:深色模式把品牌區做成純 Ink。實測落差 4(原本會是一個明顯方塊)。
過程中也發現自己對著**舊的 dist**除錯,浪費一輪——這支閘同時防這個。

## ④ 「檢查更新連到 docs,不正確,是直接在這裡進行」
新增「版本與更新」頁:顯示**現在版本 vs 最新版本**,
三段式全在 App 內完成——檢查/下載並安裝/重新啟動套用。
selfupdate 邏輯逐字沿用 arcrun-tray(含 t186 走 raw、t184 蓋正在跑的 .app),不重寫。

## 交貨閘(leo:「你做為檢查有嗎?沒有怎麼交貨?」)
check-cis.sh 擴充:版面檔不准出現任何 hex + 共用底層色票逐項比對。
兩支閘實跑全過。
This commit is contained in:
2026-08-04 23:09:54 +08:00
parent bd0cead00e
commit 3d13e5a54e
9 changed files with 893 additions and 226 deletions
+4 -2
View File
@@ -135,8 +135,9 @@ type UIState struct {
StatusSub string `json:"statusSub"`
Syncing bool `json:"syncing"`
Accounts []UIAccount `json:"accounts"`
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」
}
// GetState 是前端每秒拉一次的單一入口。
@@ -161,6 +162,7 @@ func (a *App) GetState() UIState {
}
sync := loadSyncStatus()
st.ExtractedOK = sync.ExtractedOK
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
return st
}
+8 -2
View File
@@ -6,7 +6,9 @@
# ⇒ 交貨前先跑這支。不過就不准交。
set -uo pipefail
cd "$(dirname "$0")"
CSS=frontend/src/style.css
# 色票/字體/紋理在共用底層;版面在 style.css ⇒ 兩份都要看
CSS=frontend/src/arcrun-cis.css
LAYOUT=frontend/src/style.css
HTML=frontend/index.html
FAIL=0
ok(){ printf " ✅ %s\n" "$1"; }
@@ -21,8 +23,12 @@ for t in "#FDFCFB:Paper" "#F2F1ED:Canvas" "#17181A:Ink" "#B04A2F:Relation" "#D97
done
# ② 不准自創顏色:抓不在白名單內的 hex
# 版面檔**一個 hex 都不准有**(顏色一律 var(--...)
STRAY_LAYOUT=$(grep -oiE '#[0-9a-f]{6}' "$LAYOUT" || true)
[ -z "$STRAY_LAYOUT" ] && ok "版面檔沒有寫死顏色" || ng "版面檔出現 hex$STRAY_LAYOUT"
STRAY=$(grep -oiE '#[0-9a-f]{6}' "$CSS" | tr 'a-f' 'A-F' | sort -u \
| grep -vE '#(FDFCFB|F2F1ED|17181A|B04A2F|D9784F|1D7A48|7FE0A8|B03A26|E58575|1B1C1E|1E1F21)' || true)
| grep -vE '#(FDFCFB|F2F1ED|17181A|B04A2F|D9784F|1D7A48|7FE0A8|B03A26|E58575|1B1C1E|1E1F21|1D6B40|A8E8C4)' || true)
[ -z "$STRAY" ] && ok "沒有自創顏色" || ng "出現非 CIS 色:$(echo $STRAY | tr '\n' ' ')"
# ③ Logo 必須真的放進去(leo 點名的那條)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# check-render.sh — **看得見的驗收**:用 headless Chrome 真的渲染一次並量像素(t193)
#
# 🔴 leo 2026-08-04:「既然是 Web,你做的時候**無法檢視**?例如用 chrome dev tool mcp?」
# ⇒ 對,可以。以後改完 UI 就跑這支,不要再拿沒看過的畫面交件。
#
# 它驗兩件肉眼容易漏、但機器一量就知道的事:
# ① lockup 底板與品牌區背景是否接合(paper-on-ink.png 自帶不透明 Ink 底板,
# 背景不對就會出現一個突兀方塊——leo 截圖裡看到的就是它)
# ② 深色模式的規則有沒有真的進到 build(曾發生 dist 是舊的、我卻對著舊圖除錯)
set -uo pipefail
cd "$(dirname "$0")"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
[ -x "$CHROME" ] || { echo "❌ 找不到 Chrome,跳過渲染檢查"; exit 0; }
[ -d frontend/dist ] || { echo "❌ 沒有 frontend/dist——先 wails build"; exit 1; }
W=$(mktemp -d); trap 'rm -rf "$W"; kill %1 2>/dev/null' EXIT
cp -R frontend/dist/* "$W/"
cat > "$W/mock.js" <<'JS'
window.go={main:{App:{GetState:async()=>({version:'v0',statusBig:'看守中',statusSub:'',
syncing:false,engine:'workers-ai',geminiKey:'',extractedOK:0,accounts:[]}),
SyncNow:async()=>{},PickFolder:async()=>'',AddFolder:async()=>{},RemoveFolder:async()=>{},
SetAI:async()=>{},OpenURL:()=>{},Connect:async()=>{},CheckUpdate:async()=>({})}}};
JS
python3 - "$W" <<'PY'
import re,sys
p=sys.argv[1]+'/index.html'; s=open(p,encoding='utf-8').read()
s=s.replace('src="/assets/','src="./assets/').replace('href="/assets/','href="./assets/')
s=s.replace('<script type="module"','<script src="./mock.js"></script>\n<script type="module"',1)
s=re.sub(r'<html[^>]*>','<html lang="zh-Hant" data-theme="dark">',s,count=1)
open(p,'w',encoding='utf-8').write(s)
PY
(cd "$W" && python3 -m http.server 8799 >/dev/null 2>&1) &
sleep 2
"$CHROME" --headless --disable-gpu --screenshot="$W/s.png" --window-size=1120,780 \
--hide-scrollbars --virtual-time-budget=3000 "http://localhost:8799/index.html" >/dev/null 2>&1
python3 - "$W/s.png" <<'PY'
import zlib,struct,sys
d=open(sys.argv[1],'rb').read(); pos=8; idat=b''; w=h=ct=0
while pos<len(d):
ln=struct.unpack('>I',d[pos:pos+4])[0]; t=d[pos+4:pos+8]
if t==b'IHDR': w,h,_,ct=struct.unpack('>IIBB',d[pos+8:pos+18])
if t==b'IDAT': idat+=d[pos+8:pos+8+ln]
pos+=12+ln
raw=zlib.decompress(idat); ch=4 if ct==6 else 3; stride=w*ch+1
rows=[]; prev=bytearray(w*ch)
for y in range(h):
f=raw[y*stride]; line=bytearray(raw[y*stride+1:(y+1)*stride])
for i in range(len(line)):
a=line[i-ch] if i>=ch else 0; b=prev[i]; c=prev[i-ch] if i>=ch else 0
if f==1: line[i]=(line[i]+a)&255
elif f==2: line[i]=(line[i]+b)&255
elif f==3: line[i]=(line[i]+(a+b)//2)&255
elif f==4:
pp=a+b-c; pa,pb,pc=abs(pp-a),abs(pp-b),abs(pp-c)
pr=a if (pa<=pb and pa<=pc) else (b if pb<=pc else c)
line[i]=(line[i]+pr)&255
rows.append(bytes(line)); prev=line
px=lambda x,y:tuple(rows[y][x*ch:x*ch+3])
brand=px(10,10); logo=px(60,47)
diff=max(abs(a-b) for a,b in zip(brand,logo))
print(f" 品牌區背景 {brand} logo 底板 {logo} 落差 {diff}")
if diff > 12:
print(" ❌ logo 底板與背景不接合——會出現方塊(leo 截圖裡那個)"); sys.exit(1)
print(" ✅ logo 底板與背景接合,無方塊")
# 深色模式有沒有生效:整體應該偏暗
if sum(px(600,400))/3 > 120:
print(" ❌ 深色模式沒生效(右側內容區偏亮)"); sys.exit(1)
print(" ✅ 深色模式生效")
PY
+29 -19
View File
@@ -4,37 +4,47 @@
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Arcrun</title>
<!-- 🔴 共用底層:色票/字體/紙張紋理的**唯一真相源**是 arcrun-cis/css/arcrun-cis.css。
leo 08-04:「不會這裡又寫了一個新的 CSS?前面剛剛才說過不要創造新的?」
⇒ 這份是複製進來給 build 用的,**要改配色去改 arcrun-cis/css/ 那份**。 -->
<link rel="stylesheet" href="./src/arcrun-cis.css"/>
<link rel="stylesheet" href="./src/style.css"/>
</head>
<body>
<div id="app">
<header>
<!-- CIS lockup(硬要求):官方 PNG,淺色用 ink 版、深色用 paper 版。
⚠️ 不可用自產 SVG 描邊版——字腔會缺,08-01 已作廢(arcrun-cis/README.md)。 -->
<!-- ── 左側邊欄(Google Drive 風格;leo:「左方側邊欄,在右方替換頁面」)── -->
<aside id="side">
<div class="brand">
<img class="wm-ink" src="./src/assets/arcrun-lockup-h-ink.png" alt="Arcrun"/>
<img class="wm-paper" src="./src/assets/arcrun-lockup-h-paper-on-ink.png" alt="Arcrun"/>
</div>
<div class="status">
<div class="big" id="statusBig">啟動中…</div>
<div class="sub" id="statusSub"></div>
<nav>
<div class="nav on" data-page="home"> <span class="ic"></span>首頁</div>
<div class="nav" data-page="folders"> <span class="ic"></span>同步資料夾</div>
<div class="nav" data-page="ai"> <span class="ic"></span>AI 設定</div>
<div class="nav" data-page="update"> <span class="ic"></span>版本與更新</div>
</nav>
<div class="side-foot">
<div class="ver" id="ver"></div>
</div>
<button class="primary" id="btnSync">立刻同步</button>
</header>
</aside>
<main id="list"></main>
<footer>
<button id="btnAddFolder">加入資料夾</button>
<button id="btnAddAccount">新增知識庫帳號</button>
<button id="btnAI">AI 設定</button>
<button id="btnUpdate">檢查更新</button>
<span class="spacer"></span>
<span class="ver" id="ver"></span>
</footer>
<!-- ── 右側內容區(換頁)── -->
<section id="content">
<header>
<div class="status">
<div class="big" id="statusBig">啟動中…</div>
<div class="sub" id="statusSub"></div>
</div>
<button class="primary" id="btnSync">立刻同步</button>
</header>
<main id="page"></main>
</section>
</div>
<!-- 內嵌覆蓋層,不另開小視窗(leo:「每個功能都開一個小小的 popup,非常缺乏整體感」) -->
<!-- 內嵌覆蓋層,不另開小視窗 -->
<div class="overlay" id="overlay"><div class="sheet" id="sheet"></div></div>
<script src="./src/main.js" type="module"></script>
@@ -0,0 +1,33 @@
/* arcrun-cis.css — Arcrun 全站共用樣式底層(唯一真相源)
*
* 🔴 leo 2026-08-04:「不會這裡又寫了一個新的 CSS?前面剛剛才說過**不要創造新的**?」
*
* 病根:portal 的樣式**內嵌在 HTML 裡**(單一 <style>),沒有獨立檔
* ⇒ portallandinginstalldaemon **各自抄一份** ⇒ 必然漂移。
* ⇒ 抽出這一份,四邊都引用它。**要改配色只改這裡。**
*
* 內容=CIS 官方色票(arcrun-cis/README.md 的 Colour 表)+字體+紙張紋理。
* 這裡**只放共用底層**,各站自己的版面/元件樣式仍在各站。
*/
:root {
--paper-a: #FDFCFB; --paper-b: #F2F1ED;
--ink: #17181A; --ink-rgb: 23,24,26;
--amber: #B04A2F; --amber-rgb: 176,74,47;
--ok: #1d7a48; --ok-rgb: 29,122,72; --ok-soft: #1d6b40;
--err: #b03a26; --err-rgb: 176,58,38;
--well: rgba(23,24,26,.06); --well2: rgba(23,24,26,.08);
--bar-bg: #F2F1ED; --track: rgba(23,24,26,.12); --knob: #FDFCFB;
}
:root[data-theme="dark"] {
--paper-a: #1b1c1e; --paper-b: #1e1f21;
--ink: #FDFCFB; --ink-rgb: 253,252,251;
--amber: #D9784F; --amber-rgb: 217,120,79;
--ok: #7fe0a8; --ok-rgb: 63,190,120; --ok-soft: #a8e8c4;
--err: #e58575; --err-rgb: 217,95,76;
--well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);
--bar-bg: #17181A; --track: rgba(255,255,255,.08); --knob: #FDFCFB;
}
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
font-family: -apple-system, "IBM Plex Sans", "PingFang TC", "Noto Sans TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
+211 -105
View File
@@ -1,62 +1,70 @@
// Arcrun 桌面 App — 前端(t193
//
// 全部畫面都在**同一個視窗**裡:狀態、清單、設定都用內嵌覆蓋層,
// 不再像 fyne 版那樣「每個功能開一個小小的 popup」(leo 08-04 點破)。
import './style.css';
// 版面照 leo 08-04 指定(附 Google Drive 截圖):
// 「可以完全模仿 gdrive,**左方側邊欄,在右方替換頁面**」
// ⇒ 設定不再開 popup,全部是右側的頁;只有「確認刪除」才用覆蓋層。
import './arcrun-cis.css'; // 共用底層(色票/字體/紋理)——唯一真相源在 arcrun-cis/css/
import './style.css'; // 本 App 的版面
const go = window.go.main.App;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// 跟隨系統深淺色——CIS 兩套色票都定義好了,這裡只負責切 data-theme
// 跟隨系統深淺色CIS 兩套色票都在 arcrun-cis.css 定義好了)
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = () =>
document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
mq.addEventListener('change', applyTheme);
applyTheme();
// ── 覆蓋層(取代 popup 視窗)──
let state = null;
let page = 'home';
let updateInfo = null; // { current, latest, available, notes, staged }
// ── 覆蓋層(只給必須打斷的確認)──
function openSheet(html, wire) {
$('sheet').innerHTML = html;
$('overlay').classList.add('on');
if (wire) wire();
$('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire();
}
function closeSheet() { $('overlay').classList.remove('on'); }
$('overlay').addEventListener('click', (e) => { if (e.target.id === 'overlay') closeSheet(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// ── 側邊欄換頁 ──
document.querySelectorAll('#side .nav').forEach((el) => {
el.onclick = () => {
page = el.dataset.page;
document.querySelectorAll('#side .nav').forEach((n) => n.classList.toggle('on', n === el));
renderPage();
};
});
// ── 主畫面渲染 ──
let state = null;
// ── 各頁 ──
function pageHome(s) {
const folders = (s.accounts || []).reduce((n, a) => n + (a.folders || []).length, 0);
if (!s.accounts || !s.accounts.length) return onboarding();
return `
<div class="card">
<h3>目前狀況</h3>
<div class="kv" style="margin-top:10px">
<div><div class="big-num">${s.accounts.length}</div><div class="k">個知識庫</div></div>
<div><div class="big-num">${folders}</div><div class="k">個資料夾在看守</div></div>
<div><div class="big-num">${s.extractedOK || 0}</div><div class="k">份已整理</div></div>
</div>
</div>
<div class="card">
<h3>快速動作</h3>
<div class="acts">
<button id="qAdd">加入資料夾</button>
<button id="qPortal">開啟知識庫網頁</button>
<button id="qDocs">使用說明</button>
</div>
</div>`;
}
function render(s) {
state = s;
$('ver').textContent = s.version ? '版本 ' + s.version : '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
const list = $('list');
if (!s.accounts || s.accounts.length === 0) {
// 空狀態=onboarding,不是空白面板
list.innerHTML = `
<div class="empty">
<div class="t">先連上你的知識庫</div>
<div class="d">
Arcrun 會看守你指定的資料夾,<br/>
自動把文件整理成 AI 查得到的知識卡。<br/>
還沒有知識庫?先到 install.arcrun.dev 裝一個(免費)。
</div>
<button class="primary" id="obConnect">新增知識庫帳號</button>
<button id="obInstall">我還沒有知識庫</button>
</div>`;
$('obConnect').onclick = showConnect;
$('obInstall').onclick = () => go.OpenURL('https://install.arcrun.dev/');
return;
}
list.innerHTML = s.accounts.map((a) => `
function pageFolders(s) {
if (!s.accounts || !s.accounts.length) return onboarding();
return (s.accounts).map((a) => `
<div class="acct"><span class="name">${esc(a.name)}</span><span class="host">${esc(a.host)}</span></div>
${(a.folders || []).map((f) => `
<div class="folder">
@@ -64,100 +72,198 @@ function render(s) {
<span class="tag">自動同步中</span>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
</div>`).join('')
|| `<div class="folder"><span class="path dim">還沒有資料夾——按下面的「加入資料夾」</span></div>`}
`).join('');
|| `<div class="folder"><span class="path dim">還沒有資料夾</span></div>`}
`).join('') + `
<div class="acts">
<button class="primary" id="fAdd">加入資料夾</button>
<button id="fAcct">新增知識庫帳號</button>
</div>`;
}
list.querySelectorAll('[data-rm]').forEach((b) => {
function pageAI(s) {
const gem = s.engine === 'gemma';
return `
<div class="card">
<h3>用哪個 AI 幫你整理文件?</h3>
<label class="radio"><input type="radio" name="ai" value="cloud" ${gem ? '' : 'checked'}/>
<span><b>雲端 AI</b>(推薦・不必申請任何金鑰)<br/>
<span class="d">用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰、不必去別的網站申請。</span></span></label>
<label class="radio"><input type="radio" name="ai" value="gemini" ${gem ? 'checked' : ''}/>
<span><b>Google Gemini</b>(需要自己申請金鑰)<br/>
<span class="d">進階選項。要自己去 aistudio.google.com 申請一把 API Key。</span></span></label>
<div class="field">
<div class="lb">Gemini 金鑰${s.geminiKey ? '(目前已設定,清空並儲存即可刪除)' : ''}</div>
<input type="text" id="k" placeholder="貼上你的 Gemini API Key"/>
</div>
<div class="err" id="aiErr" style="display:none"></div>
<div class="acts"><button class="primary" id="aiSave">儲存</button></div>
</div>`;
}
// leo:「檢查更新連到 docs 的檢查更新,不正確,**是直接在這裡進行檢查更新**,
// 應該是跳到一個頁面顯示**現在版本與最新版本**,然後**下載可以進行安裝**」
function pageUpdate(s) {
const u = updateInfo;
const latest = u ? (u.latest || '查詢中…') : '查詢中…';
let action = `<button class="primary" id="uCheck">檢查更新</button>`;
let note = '';
if (u && u.staged) {
action = `<button class="primary" id="uApply">重新啟動以完成更新</button>`;
note = `<div class="d">新版 ${esc(u.latest)} 已下載完成,重新啟動就會套用。</div>`;
} else if (u && u.available) {
action = `<button class="primary" id="uDownload">下載並安裝 ${esc(u.latest)}</button>`;
note = u.notes ? `<div class="d">${esc(u.notes)}</div>` : '';
} else if (u && u.err) {
note = `<div class="err">${esc(u.err)}</div>`;
} else if (u) {
note = `<div class="d">你已經是最新版本。</div>`;
}
return `
<div class="card">
<h3>版本與更新</h3>
<div class="kv" style="margin-top:12px">
<div><div class="big-num">${esc(s.version || '—')}</div><div class="k">目前版本</div></div>
<div><div class="big-num">${esc(latest)}</div><div class="k">最新版本</div></div>
</div>
<div style="margin-top:14px">${note}</div>
<div class="acts">${action}</div>
</div>
<div class="card">
<h3>需要協助?</h3>
<div class="d">安裝、更新、把知識庫接到你的 AI,說明文件都寫在這裡。</div>
<div class="acts"><button id="uDocs">開啟使用說明</button></div>
</div>`;
}
function onboarding() {
return `
<div class="empty">
<div class="t">先連上你的知識庫</div>
<div class="d">
Arcrun 會看守你指定的資料夾,<br/>
自動把文件整理成 AI 查得到的知識卡。<br/>
還沒有知識庫?先到 install.arcrun.dev 裝一個(免費)。
</div>
<button class="primary" id="obConnect">新增知識庫帳號</button>
<button id="obInstall">我還沒有知識庫</button>
</div>`;
}
// ── 渲染 ──
function renderPage() {
if (!state) return;
const el = $('page');
el.innerHTML = page === 'folders' ? pageFolders(state)
: page === 'ai' ? pageAI(state)
: page === 'update' ? pageUpdate(state)
: pageHome(state);
wire();
}
function wire() {
const on = (id, fn) => { const e = $(id); if (e) e.onclick = fn; };
on('qAdd', addFolder); on('fAdd', addFolder);
on('qDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/'));
on('qPortal', () => {
const a = state.accounts && state.accounts[0];
if (a) go.OpenURL('https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/');
});
on('fAcct', showConnect); on('obConnect', showConnect);
on('obInstall', () => go.OpenURL('https://install.arcrun.dev/'));
on('aiSave', saveAI);
on('uCheck', checkUpdate);
on('uDownload', downloadUpdate);
on('uApply', applyUpdate);
document.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
}
function render(s) {
const first = !state;
state = s;
$('ver').textContent = s.version ? s.version : '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
// 只有狀態列每秒更新;頁面內容不重繪,避免打斷輸入
if (first) renderPage();
}
async function tick() {
try { render(await go.GetState()); } catch (e) { /* 後端還沒起來,下一輪再試 */ }
try { render(await go.GetState()); } catch (e) { /* 後端還沒起來 */ }
}
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
$('btnAddFolder').onclick = async () => {
async function addFolder() {
if (!state || !state.accounts.length) { showConnect(); return; }
const p = await go.PickFolder(); // 系統原生面板(macOS powerbox 會授權)
const p = await go.PickFolder();
if (!p) return;
await go.AddFolder(0, p);
tick();
};
state = await go.GetState(); renderPage();
}
function confirmRemove(accIdx, path) {
openSheet(`
<h2>移除這個資料夾?</h2>
<p>「${esc(path)}」不再自動同步。<br/>已經上傳的知識卡不會被刪除。</p>
<div class="acts">
<button id="c1">取消</button>
<button class="primary" id="c2">移除</button>
</div>`, () => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => { await go.RemoveFolder(accIdx, path); closeSheet(); tick(); };
});
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">移除</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
await go.RemoveFolder(accIdx, path); closeSheet();
state = await go.GetState(); renderPage();
};
});
}
function showConnect() {
openSheet(`
<h2>連上你的知識庫</h2>
<p>貼上你的知識庫網址,再輸入你在網站上設定的帳號密碼。</p>
<label>知識庫網址</label>
<input type="text" id="u" placeholder="https://arcrun-cypher-executor.xxxx.workers.dev"/>
<label>帳號(Email</label>
<input type="text" id="e" placeholder="you@example.com"/>
<label>密碼</label>
<input type="password" id="p"/>
<div class="field"><div class="lb">知識庫網址</div>
<input type="text" id="u" placeholder="https://arcrun-cypher-executor.xxxx.workers.dev"/></div>
<div class="field"><div class="lb">帳號(Email</div>
<input type="text" id="e" placeholder="you@example.com"/></div>
<div class="field"><div class="lb">密碼</div><input type="password" id="p"/></div>
<div class="err" id="err" style="display:none"></div>
<div class="acts">
<button id="c1">取消</button>
<button class="primary" id="c2">連線</button>
</div>`, () => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
const err = $('err');
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
closeSheet(); tick();
} catch (ex) {
err.textContent = String(ex); err.style.display = 'block';
}
};
});
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">連線</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
closeSheet(); state = await go.GetState(); renderPage();
} catch (ex) { $('err').textContent = String(ex); $('err').style.display = 'block'; }
};
});
}
$('btnAddAccount').onclick = showConnect;
$('btnAI').onclick = () => {
const gem = state && state.engine === 'gemma';
openSheet(`
<h2>用哪個 AI 幫你整理文件?</h2>
<label class="radio"><input type="radio" name="ai" value="cloud" ${gem ? '' : 'checked'}/>
<span><b>雲端 AI</b>(推薦・不必申請任何金鑰)<br/>
<span class="muted">用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰。</span></span></label>
<label class="radio"><input type="radio" name="ai" value="gemini" ${gem ? 'checked' : ''}/>
<span><b>Google Gemini</b>(需要自己申請金鑰)</span></label>
<label>Gemini 金鑰${state && state.geminiKey ? '(目前已設定,清空即可刪除)' : ''}</label>
<input type="text" id="k" placeholder="貼上你的 Gemini API Key"/>
<div class="err" id="err" style="display:none"></div>
<div class="acts">
<button id="c1">取消</button>
<button class="primary" id="c2">儲存</button>
</div>`, () => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
const useGemini = document.querySelector('input[name=ai]:checked').value === 'gemini';
const err = $('err');
try {
await go.SetAI(useGemini, $('k').value.trim());
closeSheet(); tick();
} catch (ex) { err.textContent = String(ex); err.style.display = 'block'; }
};
});
};
async function saveAI() {
const useGemini = document.querySelector('input[name=ai]:checked').value === 'gemini';
try {
await go.SetAI(useGemini, $('k').value.trim());
state = await go.GetState(); renderPage();
} catch (ex) { $('aiErr').textContent = String(ex); $('aiErr').style.display = 'block'; }
}
$('btnUpdate').onclick = () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/');
// 更新:**在 App 裡完成**leo 指定),不是丟去 docs
async function checkUpdate() {
updateInfo = { latest: '查詢中…' }; renderPage();
try { updateInfo = await go.CheckUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
renderPage();
}
async function downloadUpdate() {
updateInfo = Object.assign({}, updateInfo, { notes: '下載中…請稍候' }); renderPage();
try { updateInfo = await go.DownloadUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
renderPage();
}
async function applyUpdate() {
try { await go.ApplyUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; renderPage(); }
}
tick();
setInterval(tick, 1000); // 每秒刷新 ⇒ 同步中看得到在動
setInterval(tick, 1000);
+88 -98
View File
@@ -1,72 +1,62 @@
/* Arcrun 桌面 App 樣式t193
/* Arcrun 桌面 App **版面**樣式t193
*
* 🔴 leo 2026-08-04我的要求是**符合 CIS**在風格上** portal 一樣**
* CIS 已經提供規範你做的** Logo 都沒放上去**這跟美不美有關係嗎
* 要求放進 CIS **硬要求**你做為檢查有嗎沒有怎麼交貨
* 🔴 色票字體紙張紋理**不在這裡**那些在 `arcrun-cis.css`唯一真相源
* InkStoneCo/arcrun-cis/css/arcrun-cis.cssportallandinginstall 共用同一份
* leo 08-04不會這裡又寫了一個新的 CSS前面剛剛才說過**不要創造新的**
* 本檔**只准出現版面與元件**一律用 var(--...)**不准寫任何 hex 色碼**
*
* 本檔的色票字體背景紋理**逐字取自 portal**
* matrix/arcrun:console-ui/public/portal/index.html :root 區塊
* **不自創任何顏色**要改就去改 portal 那份再同步過來
*
* 這也是換掉 fyne 的理由fyne 全自繪CSS 套不進去
* 換成 WailsWebView daemon portal 才可能是**同一套視覺**
* 版面leo 08-04 指定 Google Drive 截圖
* 可以完全模仿 gdrive**左方側邊欄在右方替換頁面**
* 你現在做成在底下產生 nav bar幾乎沒看過這種 nav bar 在下方的會令人十分不習慣
*/
:root {
--paper-a: #FDFCFB; --paper-b: #F2F1ED;
--ink: #17181A; --ink-rgb: 23,24,26;
--amber: #B04A2F; --amber-rgb: 176,74,47;
--ok: #1d7a48; --ok-rgb: 29,122,72;
--err: #b03a26; --err-rgb: 176,58,38;
--well: rgba(23,24,26,.06); --well2: rgba(23,24,26,.08);
}
:root[data-theme="dark"] {
--paper-a: #1b1c1e; --paper-b: #1e1f21;
--ink: #FDFCFB; --ink-rgb: 253,252,251;
--amber: #D9784F; --amber-rgb: 217,120,79;
--ok: #7fe0a8; --ok-rgb: 63,190,120;
--err: #e58575; --err-rgb: 217,95,76;
--well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);
}
html, body { overflow: hidden; height: 100%; }
/* portal 的紙張紋理背景——三站一致的識別(不是裝飾,是 CIS 的一部分) */
html, body {
margin: 0; height: 100%;
background: repeating-linear-gradient(0deg,
var(--paper-a) 0px, var(--paper-a) 3px,
var(--paper-b) 3px, var(--paper-b) 4px);
color: var(--ink);
font-family: -apple-system, "IBM Plex Sans", "PingFang TC", "Noto Sans TC",
"Microsoft JhengHei", system-ui, sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
input, textarea, button, select { font-family: inherit; }
::placeholder { color: rgba(var(--ink-rgb), .35); }
input:focus, select:focus { outline: 2px solid rgba(var(--amber-rgb), .5); outline-offset: 1px; }
a { color: var(--amber); }
.muted { color: rgba(var(--ink-rgb), .55); }
.dim { color: rgba(var(--ink-rgb), .4); }
/* ── 骨架:左欄固定、右側換頁 ── */
#app { display: flex; height: 100vh; }
/* ── 版面:上(品牌+狀態)/中(可捲清單)/下(動作列)── */
#app { display: flex; flex-direction: column; height: 100vh; }
#side {
width: 232px; flex: none;
display: flex; flex-direction: column;
padding: 22px 0 14px;
border-right: 1px solid rgba(var(--ink-rgb), .1);
}
/* 🔴 lockup 底色實測發現`arcrun-lockup-h-paper-on-ink.png` **自帶不透明 Ink 底板**
左上像素 RGBA=23,24,26,255它的設計前提是貼在純 Ink 表面上
我們的背景有紙張紋理 直接放會看到一個突兀的方塊leo 截圖裡那個白/黑框
深色模式下把品牌區做成純 Ink 表面與該圖的底板無縫接合
淺色模式用 `-ink.png`左上像素 alpha=0透明 不需要處理 */
#side .brand { padding: 14px 22px 18px; }
#side .brand img { height: 22px; display: block; }
:root[data-theme="dark"] #side .brand { background: var(--ink); }
#side .brand .wm-paper { display: none; }
:root[data-theme="dark"] #side .brand .wm-ink { display: none; }
:root[data-theme="dark"] #side .brand .wm-paper { display: block; }
/* 側邊欄項目:選中用 Relation(CIS 唯一強調色,≤5% 畫面 ⇒ 一次只有一項) */
#side nav { display: flex; flex-direction: column; gap: 2px; padding: 0 12px; }
#side .nav {
display: flex; align-items: center; gap: 11px;
padding: 10px 14px; border-radius: 999px;
font-size: 14.5px; color: rgba(var(--ink-rgb), .62);
cursor: pointer; user-select: none;
}
#side .nav:hover { background: var(--well); color: var(--ink); }
#side .nav.on { background: var(--amber); color: var(--knob); font-weight: 600; }
#side .nav .ic { width: 18px; text-align: center; font-size: 15px; opacity: .9; }
#side .side-foot { margin-top: auto; padding: 12px 22px 0; }
#side .ver { font-size: 12px; color: rgba(var(--ink-rgb), .35); font-family: ui-monospace, Menlo, monospace; }
#content { flex: 1; min-width: 0; display: flex; flex-direction: column; }
header {
padding: 18px 24px 14px;
padding: 22px 28px 16px;
display: flex; align-items: flex-start; gap: 18px;
border-bottom: 1px solid rgba(var(--ink-rgb), .1);
display: flex; align-items: flex-start; gap: 20px;
}
/* CIS lockup淺色用 ink 深色用 paper portal 同一組 PNG同一套切換規則
不可用自產的 SVG 描邊版字腔會缺08-01 已作廢 arcrun-cis/README.md */
.brand img { height: 26px; display: block; }
.brand .wm-paper { display: none; }
:root[data-theme="dark"] .brand .wm-ink { display: none; }
:root[data-theme="dark"] .brand .wm-paper { display: block; }
.status { flex: 1; min-width: 0; }
.status .big { font-size: 17px; font-weight: 600; letter-spacing: .02em; }
.status .sub { margin-top: 4px; font-size: 13.5px; color: rgba(var(--ink-rgb), .5); }
.status .big { font-size: 18px; font-weight: 600; letter-spacing: .02em; }
.status .sub { margin-top: 5px; font-size: 13.5px; color: rgba(var(--ink-rgb), .5); }
/* 同步中會呼吸——「看得出來在動」(issue #17) */
.status .big.syncing::after {
content: ""; display: inline-block; width: 7px; height: 7px; margin-left: 9px;
@@ -75,54 +65,63 @@ header {
}
@keyframes pulse { 0%,100% { opacity: .25 } 50% { opacity: 1 } }
/* 按鈕:Relation 是**唯一重點色**,CIS 規定 ≤ 一個畫面的 5% ⇒ 只有主要動作用它 */
main { flex: 1; overflow-y: auto; padding: 20px 28px 28px; }
/* ── 元件 ── */
button {
border: 1px solid rgba(var(--ink-rgb), .16);
background: transparent; color: var(--ink);
padding: 8px 14px; border-radius: 8px; font-size: 14px; cursor: pointer;
padding: 8px 15px; border-radius: 8px; font-size: 14px; cursor: pointer;
transition: background .12s, border-color .12s;
}
button:hover { background: var(--well); }
button.primary {
background: var(--amber); border-color: var(--amber);
color: #FDFCFB; font-weight: 600;
}
button.primary { background: var(--amber); border-color: var(--amber); color: var(--knob); font-weight: 600; }
button.primary:hover { filter: brightness(1.06); }
button.ghost { border-color: transparent; color: rgba(var(--ink-rgb), .5); padding: 6px 8px; }
button.ghost { border-color: transparent; color: rgba(var(--ink-rgb), .45); padding: 6px 9px; font-size: 13px; }
button.ghost:hover { color: var(--err); background: transparent; }
/* ── 中:資料夾清單(leo:「幾十個,根本塞不下」⇒ 這一區才捲動)── */
main { flex: 1; overflow-y: auto; padding: 6px 0; }
.acct {
padding: 16px 24px 6px;
display: flex; align-items: baseline; gap: 10px;
.card {
background: var(--well); border-radius: 12px;
padding: 18px 20px; margin-bottom: 14px;
}
.acct .name { font-size: 15px; font-weight: 600; }
.acct .host { font-size: 12.5px; color: rgba(var(--ink-rgb), .4); font-family: ui-monospace, Menlo, monospace; }
.card h3 { margin: 0 0 4px; font-size: 15px; }
.card .d { font-size: 13.5px; color: rgba(var(--ink-rgb), .55); line-height: 1.8; }
.card .row { display: flex; align-items: center; gap: 12px; }
.card .row .g { flex: 1; min-width: 0; }
.acct { padding: 4px 2px 8px; display: flex; align-items: baseline; gap: 10px; }
.acct .name { font-size: 14.5px; font-weight: 600; }
.acct .host { font-size: 12px; color: rgba(var(--ink-rgb), .4); font-family: ui-monospace, Menlo, monospace; }
.folder {
margin: 0 16px 6px; padding: 11px 14px;
padding: 11px 15px; margin-bottom: 7px;
background: var(--well); border-radius: 10px;
display: flex; align-items: center; gap: 12px;
}
.folder .path { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder .tag { font-size: 12px; color: var(--ok); white-space: nowrap; }
/* 空狀態=onboarding(不是空白面板) */
.empty { padding: 56px 32px; text-align: center; }
.big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; }
.kv { display: flex; gap: 26px; margin-top: 4px; }
.kv .k { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }
.empty { padding: 52px 24px; text-align: center; }
.empty .t { font-size: 17px; font-weight: 600; margin-bottom: 10px; }
.empty .d { font-size: 14px; color: rgba(var(--ink-rgb), .55); line-height: 1.9; margin-bottom: 22px; }
.empty .d { font-size: 14px; color: rgba(var(--ink-rgb), .55); line-height: 1.9; margin-bottom: 20px; }
footer {
padding: 12px 24px; border-top: 1px solid rgba(var(--ink-rgb), .1);
display: flex; align-items: center; gap: 8px;
label.radio { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0; font-size: 14px; cursor: pointer; }
label.radio input { margin-top: 3px; }
input[type=text], input[type=password] {
width: 100%; box-sizing: border-box; padding: 9px 12px; font-size: 14px;
border: 1px solid rgba(var(--ink-rgb), .18); border-radius: 8px;
background: var(--paper-b); color: var(--ink);
}
footer .spacer { flex: 1; }
footer .ver { font-size: 12.5px; color: rgba(var(--ink-rgb), .35); font-family: ui-monospace, Menlo, monospace; }
.field { margin: 14px 0; }
.field .lb { font-size: 13px; margin-bottom: 6px; color: rgba(var(--ink-rgb), .6); }
.err { color: var(--err); font-size: 13px; margin-top: 10px; }
.acts { display: flex; gap: 8px; margin-top: 18px; }
/* 對話框**內嵌覆蓋層**不是另開一個小 popup 視窗
leo每個功能都開一個小小的 popup 視窗非常缺乏整體感 */
/* 覆蓋層:只給「確認刪除」這類必須打斷的動作,設定頁一律走右側換頁 */
.overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.42);
display: flex; align-items: center; justify-content: center;
@@ -130,21 +129,12 @@ footer .ver { font-size: 12.5px; color: rgba(var(--ink-rgb), .35); font-family:
}
.overlay.on { opacity: 1; pointer-events: auto; }
.sheet {
width: min(520px, calc(100vw - 64px));
width: min(460px, calc(100vw - 64px));
background: var(--paper-a); color: var(--ink);
border: 1px solid rgba(var(--ink-rgb), .12);
border-radius: 14px; padding: 24px;
border-radius: 14px; padding: 22px 24px;
box-shadow: 0 18px 50px rgba(0,0,0,.28);
}
.sheet h2 { margin: 0 0 6px; font-size: 17px; }
.sheet p { margin: 0 0 16px; font-size: 14px; color: rgba(var(--ink-rgb), .55); line-height: 1.7; }
.sheet label { display: block; font-size: 13px; margin: 12px 0 5px; color: rgba(var(--ink-rgb), .6); }
.sheet input[type=text], .sheet input[type=password] {
width: 100%; box-sizing: border-box; padding: 9px 11px; font-size: 14px;
border: 1px solid rgba(var(--ink-rgb), .18); border-radius: 8px;
background: var(--paper-b); color: var(--ink);
}
.sheet .radio { display: flex; align-items: flex-start; gap: 9px; margin: 10px 0; font-size: 14px; cursor: pointer; }
.sheet .radio input { margin-top: 3px; }
.sheet .acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }
.sheet .err { color: var(--err); font-size: 13px; margin-top: 10px; }
.sheet h2 { margin: 0 0 6px; font-size: 16.5px; }
.sheet p { margin: 0 0 4px; font-size: 14px; color: rgba(var(--ink-rgb), .55); line-height: 1.7; }
.sheet .acts { justify-content: flex-end; }
+375
View File
@@ -0,0 +1,375 @@
package main
// ⚠️ 本檔**逐字取自 arcrun-tray/selfupdate.go**(只拿掉 fyne 專屬的選單函式)。
// 理由:那裡面有踩過坑才長出來的東西——jsDelivr @main 的 ref 解析會卡死(t186 改走 raw)、
// 自更新要蓋「正在跑的那個 .app」而非寫死 /Applicationst184Oscar 靜默失敗的真兇)。
// **兩邊哪天要改就兩邊一起改。**
// selfupdate.go — 小幫手自我更新(t1502026-07-29 leo 拍板)
//
// 為什麼一定要做(leo 原話):
// 「小白不會動不動就刪除再裝新的」
// 「如果有自動更新,就不用讓測試者一直重新下載,他去按一下檢查更新然後更新就好」
// 「**我要他下載多幾次他就放棄了,所以抓到一個客戶後不能讓他有機會離開**」
//
// ⇒ 每一次「請你重新下載」都是流失點。修好的東西到不了用戶手上=等於沒修
// (t149 實例:多帳號同步修好了,leo 手上仍是壞的,因為沒有更新機制)。
//
// 設計(leo 選 1+3):**背景自動更新為主,但一定要有提醒與手動「檢查更新」**——
// daemon 常駐不重啟,完全自動才符合實際使用;但仍要看得見、按得動,
// 否則使用者不知道發生什麼事(違反「視覺化明示原則」)。
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// manifestBase 是 bundle manifest(含 daemon 區塊)。與安裝器同源,不另開發佈通道。
//
// 🔴 t1862026-08-04):**改打 raw.githubusercontent,不再用 jsDelivr `@main`。**
//
// ── 這推翻了 t150 二修(07-29 `142f1d5`)的判斷,理由如下 ──
//
// 那一輪的真兇是「jsDelivr 對 `@main` 回 7 天**檔案**快取」,解法是加 `?t=<unix>`。
// **今天的病不同**:卡住的是「main → 哪個 commit」這層 **ref 解析**
// 而 `?t=` 與 purge **都清不掉 ref 解析**。08-04 實測三條路同時比對:
// @main → 1.4.9 / v0.15.6 ← 卡住(age 持續增長、purge 回 finished 也沒用)
// @<sha> → 1.4.10 / v0.15.7 ← 正確
// raw → 1.4.10 / v0.15.7 ← 正確
// `x-cache: MISS, MISS` 卻仍吐舊內容 ⇒ 坐實不是檔案快取,是 ref 解析被快取。
// 同一天發作三次(win zip → mac zip → manifest)。zip 能靠「改帶版本號檔名」繞開,
// **manifest.json 不行**——檔名寫死在這裡,換不了 ⇒ 只剩「換來源」一條路。
//
// ── D20 合規(t150 那輪否決 raw 的理由是誤判)──
// 舊註解寫「raw 是實名讀 GitHub,受 D20 頻率閘管制」。
// 但 D20 對實名的定義是「URL 帶 `token@``user:pass@`、或 clone 自己 private 殼」,
// **daemon 這個請求不帶任何憑證=匿名讀**,
// D20 明列「匿名讀(curl 公開 release…)✅ 放行,不計數」
// `system-dev/docs/2-architecture/decisions/D20-github-contact-protocol.md:22`)。
// repo 是公開的,GitHub 不知道請求者是誰 ⇒ 零 flag 風險。
//
// 仍保留 `?t=<unix>`:擋瀏覽器/中間層快取,成本為零。
const manifestBase = "https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/manifest.json"
func manifestURLNoCache() string {
return manifestBase + "?t=" + strconv.FormatInt(time.Now().Unix(), 10)
}
// updateCheckInterval:背景檢查頻率。守「不輪詢」紅線——這是**單一 CDN 靜態檔**、
// 每天一次、非 GitHub API,不構成 fan-out 或高頻寫入。
const updateCheckInterval = 24 * time.Hour
type daemonRelease struct {
Version string `json:"version"`
Built string `json:"built"`
Notes string `json:"notes"`
Mac struct {
File string `json:"file"`
SHA256 string `json:"sha256"`
} `json:"mac"`
Win struct {
File string `json:"file"`
SHA256 string `json:"sha256"`
} `json:"win"`
}
type bundleManifest struct {
Daemon *daemonRelease `json:"daemon,omitempty"`
}
// updateState 是檢查結果(給選單讀)。
type updateState struct {
Available bool // 有新版
Latest string // 最新版本號
Notes string // 更新說明
CheckedAt time.Time
Err string // 檢查失敗原因(顯示用,不擋工作)
}
var currentUpdate updateState
// fetchLatestRelease 抓 manifest 的 daemon 區塊。
func fetchLatestRelease() (*daemonRelease, error) {
cli := &http.Client{Timeout: 20 * time.Second}
resp, err := cli.Get(manifestURLNoCache())
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifest HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
var m bundleManifest
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
if m.Daemon == nil || strings.TrimSpace(m.Daemon.Version) == "" {
return nil, fmt.Errorf("manifest 沒有 daemon 版本欄位")
}
return m.Daemon, nil
}
// newerThanCurrent 比較版本。dev(本機隨手編)一律不提示更新——
// 開發中的機器被提示「有新版」只會造成困惑。
func newerThanCurrent(latest string) bool {
cur := strings.TrimSpace(version)
if cur == "" || cur == "dev" {
return false
}
return strings.TrimSpace(latest) != cur
}
// checkForUpdate 執行一次檢查並更新 currentUpdate。
func checkForUpdate() updateState {
st := updateState{CheckedAt: time.Now()}
rel, err := fetchLatestRelease()
if err != nil {
st.Err = err.Error()
currentUpdate = st
return st
}
st.Latest = rel.Version
st.Notes = rel.Notes
st.Available = newerThanCurrent(rel.Version)
currentUpdate = st
return st
}
// downloadURLFor 回傳本平台的下載網址。
func downloadURLFor(rel *daemonRelease) string {
// t186:與 manifestBase 同一個理由改走 raw——`@main` 的 ref 解析會卡住,
// 抓到舊 zip ⇒ sha256 校驗不符 ⇒ **更新永遠失敗且靜默**(正是 08-04 撞的病)。
// portal 的下載按鈕本來就走 rawMac zip 曾大於 jsDelivr 20MB 單檔上限),
// 這裡改過來後**兩條下載路徑同源**,不會再出現「下載頁對、檢查更新錯」的分歧。
base := "https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/"
suffix := "?t=" + strconv.FormatInt(time.Now().Unix(), 10)
if isWindows() {
return base + rel.Win.File + suffix
}
return base + rel.Mac.File + suffix
}
// updateStagePath 是下載暫存位置(~/.arcrun-rag/updates/)。
func updateStagePath(name string) string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".arcrun-rag", "updates")
_ = os.MkdirAll(dir, 0o755)
return filepath.Join(dir, name)
}
// ── 下載與套用(leo 07-29:「如果它都掛着,那準備好就告訴他重啓更新」)─────────
//
// 設計要點:daemon 常駐不重啟 ⇒ **不能中途替換自己正在跑的執行檔**。
// 所以分兩段:① 背景默默下載+驗章,備妥後只改選單文案(不打擾)
// ② 使用者按「重新啟動以完成更新」才真的替換並重啟。
// ⇒ 使用者**完全不必再下載任何東西**(leo:「我要他下載多幾次他就放棄了」)。
// stagedUpdate 記錄「已下載備妥、等重啟套用」的版本。
type stagedUpdate struct {
Version string `json:"version"`
ZipPath string `json:"zip_path"`
Ready bool `json:"ready"`
}
var currentStaged stagedUpdate
// stagedMarkerPath 讓「備妥狀態」跨重啟可見(重啟後才好知道要不要套用)。
func stagedMarkerPath() string { return updateStagePath("staged.json") }
func saveStaged(s stagedUpdate) {
b, err := json.Marshal(s)
if err != nil {
return
}
_ = os.WriteFile(stagedMarkerPath(), b, 0o644)
}
func loadStaged() stagedUpdate {
var s stagedUpdate
b, err := os.ReadFile(stagedMarkerPath())
if err != nil {
return s
}
_ = json.Unmarshal(b, &s)
return s
}
// downloadUpdate 下載新版 zip 到暫存區並驗 sha256。
// 驗章失敗=寧可不更新(壞掉的 .app 會讓小幫手開不起來,比舊版更糟)。
func downloadUpdate(rel *daemonRelease) (string, error) {
url := downloadURLFor(rel)
want := rel.Mac.SHA256
if isWindows() {
want = rel.Win.SHA256
}
cli := &http.Client{Timeout: 10 * time.Minute}
resp, err := cli.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("下載 HTTP %d", resp.StatusCode)
}
// 檔名要用「去掉查詢字串」的網址算,否則會變成 ...zip?t=1785... 這種怪檔名。
cleanURL := strings.SplitN(url, "?", 2)[0]
dst := updateStagePath(rel.Version + "-" + filepath.Base(cleanURL))
f, err := os.Create(dst)
if err != nil {
return "", err
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
f.Close()
return "", err
}
f.Close()
got := hex.EncodeToString(h.Sum(nil))
if want != "" && got != want {
_ = os.Remove(dst)
return "", fmt.Errorf("檔案校驗不符(可能下載不完整),已丟棄")
}
return dst, nil
}
// prepareUpdateInBackground:檢查 → 有新版就下載備妥 → 記錄 staged。
// 全程不打擾使用者;備妥後由選單顯示「重新啟動以完成更新」。
func prepareUpdateInBackground() {
st := checkForUpdate()
if !st.Available {
return
}
rel, err := fetchLatestRelease()
if err != nil {
return
}
zip, err := downloadUpdate(rel)
if err != nil {
currentUpdate.Err = "自動下載失敗:" + err.Error()
return
}
currentStaged = stagedUpdate{Version: rel.Version, ZipPath: zip, Ready: true}
saveStaged(currentStaged)
}
// startUpdateWatcher 啟動背景更新檢查(每日一次;啟動後先等 1 分鐘避免搶開機資源)。
func startUpdateWatcher() {
go func() {
time.Sleep(time.Minute)
for {
prepareUpdateInBackground()
time.Sleep(updateCheckInterval)
}
}()
}
// applyStagedAndRestart 套用已備妥的更新並重啟。
// 只有使用者按下「重新啟動以完成更新」才會走到這裡(不偷偷替換正在跑的自己)。
//
// 做法:解壓到暫存 → 用 ditto 覆蓋 /Applications/Arcrun.app → 重新 open → 結束自己。
// 失敗時保留原版不動(寧可停在舊版,也不要弄出開不起來的 .app)。
func applyStagedAndRestart() error {
s := loadStaged()
if !s.Ready || s.ZipPath == "" {
return fmt.Errorf("沒有已備妥的更新")
}
if _, err := os.Stat(s.ZipPath); err != nil {
return fmt.Errorf("更新檔不見了,請再檢查一次更新")
}
if isWindows() {
// Windows 版走各自的安裝流程(本階段先開資料夾讓使用者取用)。
return openPath(filepath.Dir(s.ZipPath))
}
work := updateStagePath("unpack")
_ = os.RemoveAll(work)
if err := os.MkdirAll(work, 0o755); err != nil {
return err
}
if out, err := runCmd("ditto", "-x", "-k", s.ZipPath, work); err != nil {
return fmt.Errorf("解壓失敗:%v %s", err, out)
}
newApp := filepath.Join(work, "Arcrun.app")
if _, err := os.Stat(newApp); err != nil {
return fmt.Errorf("更新檔內容不符(找不到 Arcrun.app")
}
// 🔴 t184leo 08-04 實撞:Oscar 按更新→顯示「新版 v0.15.6 已就緒」→重啟後**又跳回 v0.15.4**):
//
// 原本寫死 `/Applications/Arcrun.app`。但使用者**不一定把 app 放在 Applications**
// (Oscar 就是從下載資料夾/桌面直接跑)。這時 `ditto` 會**自動建出**
// `/Applications/Arcrun.app` 並**回傳成功**(實測 exit 0,不是報錯)
// ⇒ 新版被寫到一個他根本沒在跑的路徑,他重開的還是原地那份舊的
// ⇒ 畫面說「更新完成」、版本卻永遠是舊的=**靜默失敗**,最難查的那種。
//
// 改成更新**當前這個真的在跑的 app**os.Executable() 往上推 .app):
// 放哪都能更新,也不再無中生有一個 /Applications 的副本。
target, err := runningAppBundlePath()
if err != nil {
return err
}
if out, err := runCmd("ditto", newApp, target); err != nil {
return fmt.Errorf("覆蓋失敗(可能需要權限):%v %s", err, out)
}
// 清掉 staged 標記,避免重啟後又提示一次
_ = os.Remove(stagedMarkerPath())
currentStaged = stagedUpdate{}
if out, err := runCmd("open", "-n", target); err != nil {
return fmt.Errorf("重新啟動失敗:%v %s", err, out)
}
go func() { time.Sleep(2 * time.Second); os.Exit(0) }()
return nil
}
// runningAppBundlePath 回傳**現在正在跑的**那個 .app 路徑(t184)。
//
// 路徑長相:<某處>/Arcrun.app/Contents/MacOS/arcrun-tray
// ⇒ 從執行檔往上三層就是 .app 本體。
//
// 為什麼不寫死 /Applications:使用者常常就地執行(下載資料夾/桌面/隨身碟),
// 寫死會把新版蓋到他沒在跑的地方,而且 ditto 還會「成功」(見 applyStagedAndRestart 的註解)。
// 找不到 .app 結構時**誠實回錯**,不要猜一個路徑亂蓋——蓋錯地方比不更新更難查。
func runningAppBundlePath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("找不到自己的執行檔位置:%w", err)
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved // 走過 symlink 才是真身
}
// .../Arcrun.app/Contents/MacOS/arcrun-tray → 上三層
app := filepath.Dir(filepath.Dir(filepath.Dir(exe)))
if filepath.Ext(app) != ".app" {
return "", fmt.Errorf("這份 Arcrun RAG 不是從 .app 啟動的(%s)⇒ 請改用下載頁的 .app 版本再更新", exe)
}
return app, nil
}
// runCmd 執行外部命令並回傳合併輸出(本檔自用;package 內先前沒有同類 helper)。
func runCmd(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
// openPath 用系統預設方式開啟路徑(Windows 版更新暫以「開資料夾」收尾)。
func openPath(p string) error {
if isWindows() {
_, err := runCmd("cmd", "/c", "start", "", p)
return err
}
_, err := runCmd("open", p)
return err
}
+74
View File
@@ -0,0 +1,74 @@
package main
// update_api.go — 「版本與更新」頁的後端(t193)
//
// 🔴 leo 2026-08-04:「檢查更新連到 docs 的檢查更新,**不正確**,
// 是**直接在這裡進行檢查更新**,應該是跳到一個頁面顯示**現在版本與最新版本**,
// 然後**下載可以進行安裝**」
//
// ⇒ 三段式,全部在 App 內完成,不把人丟去網頁:
// ① CheckUpdate 查最新版 → 前端顯示「現在版本 vs 最新版本」
// ② DownloadUpdate 下載+sha256 校驗 → 備妥
// ③ ApplyUpdate 覆蓋正在跑的 .app 並重啟
import (
"runtime"
"strings"
)
// UpdateInfo 是「版本與更新」頁要顯示的一切。
type UpdateInfo struct {
Current string `json:"current"`
Latest string `json:"latest"`
Available bool `json:"available"`
Notes string `json:"notes,omitempty"`
Staged bool `json:"staged"` // 已下載備妥、等重啟套用
Err string `json:"err,omitempty"` // 誠實回報,不假裝成功
}
func (a *App) CheckUpdate() UpdateInfo {
info := UpdateInfo{Current: version}
rel, err := fetchLatestRelease()
if err != nil {
info.Err = "暫時連不上更新伺服器:" + err.Error()
return info
}
info.Latest = rel.Version
info.Notes = rel.Notes
info.Available = newerThanCurrent(rel.Version)
if s := loadStaged(); s.Ready && s.Version == rel.Version {
info.Staged = true
}
return info
}
func (a *App) DownloadUpdate() UpdateInfo {
info := UpdateInfo{Current: version}
rel, err := fetchLatestRelease()
if err != nil {
info.Err = "暫時連不上更新伺服器:" + err.Error()
return info
}
info.Latest, info.Notes = rel.Version, rel.Notes
info.Available = newerThanCurrent(rel.Version)
zip, err := downloadUpdate(rel)
if err != nil {
// 誠實:下載/校驗失敗就說失敗,不留下半個檔假裝好了
info.Err = "下載失敗:" + err.Error()
return info
}
currentStaged = stagedUpdate{Version: rel.Version, ZipPath: zip, Ready: true}
saveStaged(currentStaged)
info.Staged = true
return info
}
func (a *App) ApplyUpdate() error {
if err := applyStagedAndRestart(); err != nil {
return err
}
return nil
}
// isWindows 供 selfupdate.go 使用(原本在 arcrun-tray 的 main.go)。
func isWindows() bool { return strings.EqualFold(runtime.GOOS, "windows") }