server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物

This commit is contained in:
Qiufeng
2026-08-21 10:53:32 +08:00
parent 0daa9782c9
commit 3585c39bab
103 changed files with 5842 additions and 3197 deletions
@@ -135,6 +135,11 @@ func (s *Server) handleSolve(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -143,11 +148,18 @@ func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
Code string `json:"code"`
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "请求格式不合法"})
return
}
if req.Code == "" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对码必填"})
return
}
if s.pair == nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对服务未启用"})
return
}
if err := s.pair(req.Code, req.Name); err != nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
return
+35 -2
View File
@@ -2,11 +2,14 @@ package pairing
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
@@ -69,11 +72,27 @@ func AutoPair(server, email, password, deviceName, pubKeyB64 string) (uint64, er
// PairWithCode 凭配对码注册设备,返回服务器分配的设备 ID
func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 string) (uint64, error) {
pairCode = strings.ToUpper(strings.TrimSpace(pairCode))
deviceName = strings.TrimSpace(deviceName)
if deviceName == "" {
deviceName = "macOS Agent"
}
pubKeyB64 = strings.TrimSpace(pubKeyB64)
if pubKeyB64 == "" {
return 0, fmt.Errorf("本机设备公钥为空,请重启客户端后重试")
}
pubKey, keyErr := base64.StdEncoding.DecodeString(pubKeyB64)
if keyErr != nil || len(pubKey) != ed25519.PublicKeySize {
return 0, fmt.Errorf("本机设备公钥格式错误,请重启客户端后重试")
}
code, raw, err := httpJSON("POST", server+"/api/v1/agent/pair", "", ginH{
"code": pairCode, "deviceName": deviceName, "os": osName, "version": version, "publicKey": pubKeyB64,
})
if err != nil || code != 200 {
return 0, fmt.Errorf("pair failed: %s", string(raw))
if err != nil {
return 0, fmt.Errorf("配对请求失败:%w", err)
}
if code != http.StatusOK {
return 0, fmt.Errorf("%s", responseMessage(raw, "配对失败"))
}
id, _ := strconv.ParseUint(field(raw, "deviceId"), 10, 64)
if id == 0 {
@@ -81,3 +100,17 @@ func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 strin
}
return id, nil
}
func responseMessage(raw []byte, fallback string) string {
var envelope struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(raw, &envelope); err == nil && strings.TrimSpace(envelope.Message) != "" {
return envelope.Message
}
if text := strings.TrimSpace(string(raw)); text != "" {
return text
}
return fallback
}
@@ -0,0 +1,67 @@
package pairing
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPairWithCodeSendsNormalizedPayload(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agent/pair" || r.Method != http.MethodPost {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body struct {
Code string `json:"code"`
DeviceName string `json:"deviceName"`
PublicKey string `json:"publicKey"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Code != "AB23CD" || body.DeviceName != "macOS Agent" {
t.Fatalf("payload was not normalized: %+v", body)
}
decoded, err := base64.StdEncoding.DecodeString(body.PublicKey)
if err != nil || len(decoded) != ed25519.PublicKeySize {
t.Fatalf("invalid public key: %q", body.PublicKey)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"ok","data":{"deviceId":42}}`))
}))
defer server.Close()
id, err := PairWithCode(server.URL, " ab23cd ", "", "core", "0.3.0", base64.StdEncoding.EncodeToString(pub))
if err != nil || id != 42 {
t.Fatalf("PairWithCode() = %d, %v", id, err)
}
}
func TestPairWithCodeRejectsMissingPublicKey(t *testing.T) {
_, err := PairWithCode("http://127.0.0.1:1", "AB23CD", "Mac", "core", "0.3.0", "")
if err == nil || !strings.Contains(err.Error(), "公钥") {
t.Fatalf("expected actionable public key error, got %v", err)
}
}
func TestPairWithCodePreservesServerMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":1001,"data":null,"message":"设备公钥格式不合法"}`))
}))
defer server.Close()
pub, _, _ := ed25519.GenerateKey(rand.Reader)
_, err := PairWithCode(server.URL, "AB23CD", "Mac", "core", "0.3.0", base64.StdEncoding.EncodeToString(pub))
if err == nil || !strings.Contains(err.Error(), "设备公钥格式不合法") {
t.Fatalf("expected server message, got %v", err)
}
}
+12 -1
View File
@@ -9,8 +9,9 @@ import (
"flag"
"log"
"os"
"path/filepath"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
@@ -102,6 +103,16 @@ func main() {
if id.DeviceID > 0 {
return errors.New("设备已配对")
}
name = strings.TrimSpace(name)
if name == "" {
name = cfg.Name
}
if name == "" {
name, _ = os.Hostname()
}
if name == "" {
name = "macOS Agent"
}
did, err := pairing.PairWithCode(cfg.Server, code, name, "core", "0.2.0", id.PublicKey)
if err != nil {
return err
+2 -2
View File
@@ -1,9 +1,9 @@
// 生成 icons/app.png(256x256 纯蓝方块图标)
// 生成 icons/app.png(512x512 纯蓝方块图标,兼容 macOS DMG)
const zlib = require("zlib");
const fs = require("fs");
const path = require("path");
const W = 256, H = 256;
const W = 512, H = 512;
const row = Buffer.alloc(1 + W * 4);
row[0] = 0;
for (let x = 0; x < W; x++) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 916 B

+30 -7
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require('electron');
const { app, BrowserWindow, Tray, Menu, Notification, ipcMain, shell, nativeImage } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
@@ -17,6 +17,7 @@ let localJsonTimer = null;
let quitting = false;
const dataDir = path.join(app.getPath('userData'), 'data');
const localJsonPath = path.join(dataDir, 'local.json');
const coreBin = isWin ? 'agent-core.exe' : 'agent-core';
const coreExe = app.isPackaged
? path.join(process.resourcesPath, coreBin)
@@ -30,7 +31,14 @@ function startCore() {
}
fs.mkdirSync(dataDir, { recursive: true });
try { coreProc?.kill(); } catch (e) { /* ignore */ }
corePort = 0;
coreToken = '';
try { fs.rmSync(localJsonPath, { force: true }); } catch (e) { /* ignore */ }
coreProc = spawn(coreExe, ['-dir', dataDir], { windowsHide: true });
coreProc.on('error', (err) => {
console.error('[core] process error: ' + err.message);
win?.webContents.send('core-error', '核心程序启动失败:' + err.message);
});
coreProc.on('exit', (code) => {
coreProc = null;
win?.webContents.send('core-exit', code);
@@ -42,7 +50,7 @@ function pollLocalJson() {
if (localJsonTimer) clearInterval(localJsonTimer);
localJsonTimer = setInterval(() => {
try {
const info = JSON.parse(fs.readFileSync(path.join(dataDir, 'local.json'), 'utf8'));
const info = JSON.parse(fs.readFileSync(localJsonPath, 'utf8'));
if (info.port && info.token) {
corePort = info.port;
coreToken = info.token;
@@ -91,20 +99,31 @@ ipcMain.handle('core:save-config', (e, cfg) => { writeConfig(cfg); startCore();
ipcMain.handle('core:restart', () => { startCore(); return true; });
ipcMain.handle('app:open-data-dir', () => shell.openPath(dataDir));
ipcMain.handle('app:open-web', (e, url) => shell.openExternal(url));
ipcMain.handle('app:notify', (e, title, body) => {
if (Notification.isSupported()) {
new Notification({ title: String(title || 'EveryPublish'), body: String(body || '') }).show();
}
return true;
});
ipcMain.handle('app:set-autostart', (e, on) => {
app.setLoginItemSettings({ openAtLogin: !!on });
app.setLoginItemSettings({ openAtLogin: !!on, openAsHidden: true, args: ['--minimized'] });
return app.getLoginItemSettings().openAtLogin;
});
ipcMain.handle('app:get-autostart', () => app.getLoginItemSettings().openAtLogin);
ipcMain.handle('app:quit', () => { quitting = true; app.quit(); });
function readConfig() {
try { return JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')); }
catch (e) { return { server: '', fakeAll: false }; }
const defaults = {
server: 'http://127.0.0.1:8090',
webUrl: 'http://127.0.0.1:3003',
fakeAll: false,
};
try { return { ...defaults, ...JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')) }; }
catch (e) { return defaults; }
}
function writeConfig(cfg) {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(cfg, null, 2));
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify({ ...readConfig(), ...cfg }, null, 2), { mode: 0o600 });
}
function createTray() {
@@ -172,6 +191,10 @@ async function runSmoke() {
}
}
app.on('before-quit', () => { quitting = true; try { coreProc?.kill(); } catch (e) { /* ignore */ } });
app.on('before-quit', () => {
quitting = true;
if (localJsonTimer) clearInterval(localJsonTimer);
try { coreProc?.kill(); } catch (e) { /* ignore */ }
});
app.on('window-all-closed', () => { /* 驻留托盘不退出 */ });
app.on('activate', showWin);
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "everypublish-client",
"version": "0.2.0",
"description": "EveryPublish Windows 客户端(Electron 壳 + Go agent-core)",
"description": "EveryPublish 历史 Electron 调试壳(非 macOS 正式交付)",
"main": "main.js",
"scripts": {
"start": "electron .",
@@ -17,12 +17,12 @@
"productName": "EveryPublish",
"directories": { "output": "release" },
"files": ["main.js", "preload.js", "renderer/**/*", "icons/**/*"],
"extraResources": [
{ "from": "../dist/agent-core.exe", "to": "agent-core.exe" }
],
"win": {
"target": [{ "target": "nsis", "arch": ["x64"] }],
"icon": "icons/app.ico",
"extraResources": [
{ "from": "../dist/agent-core.exe", "to": "agent-core.exe" }
],
"signAndEditExecutable": false
},
"nsis": {
+1
View File
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('api', {
restart: () => ipcRenderer.invoke('core:restart'),
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
openWeb: (url) => ipcRenderer.invoke('app:open-web', url),
notify: (title, body) => ipcRenderer.invoke('app:notify', title, body),
setAutoStart: (on) => ipcRenderer.invoke('app:set-autostart', on),
getAutoStart: () => ipcRenderer.invoke('app:get-autostart'),
quit: () => ipcRenderer.invoke('app:quit'),
+28 -15
View File
@@ -13,6 +13,7 @@ document.querySelectorAll('.tab').forEach((tab) => {
// ---- 状态页 ----
let currentServer = '';
let currentWeb = 'http://127.0.0.1:3003';
async function refreshStatus() {
try {
const s = await window.api.status();
@@ -33,7 +34,7 @@ setInterval(refreshStatus, 3000);
refreshStatus();
$('btn-open-web').addEventListener('click', () => {
const url = currentServer || 'http://127.0.0.1:8090';
const url = currentWeb || currentServer || 'http://127.0.0.1:3003';
window.api.openWeb(url);
});
$('btn-open-data').addEventListener('click', () => window.api.openDataDir());
@@ -66,35 +67,42 @@ $('btn-pair').addEventListener('click', async () => {
// ---- 挑战页 ----
let selectedChallenge = null;
let pollTimer = null;
let knownChallengeIds = new Set();
const KIND_LABEL = { qr: '扫码', sms: '短信验证码', confirm: 'APP 确认', captcha: '图形验证码', pending: '等待设备' };
async function refreshChallenges() {
try {
const r = await window.api.challenges();
const list = (r && r.list) || [];
const fresh = list.filter((ch) => ch.challengeId && !knownChallengeIds.has(ch.challengeId));
if (fresh.length && knownChallengeIds.size > 0 && window.api.notify) {
window.api.notify('EveryPublish 需要处理挑战', fresh.map((ch) => ch.platform || '平台').join('、') + ' 有新的扫码或验证码任务');
}
knownChallengeIds = new Set(list.map((ch) => ch.challengeId).filter(Boolean));
const badge = $('ch-badge');
if (list.length > 0) { badge.textContent = list.length; badge.classList.remove('hidden'); }
else badge.classList.add('hidden');
const box = $('ch-list');
box.innerHTML = '';
box.replaceChildren();
list.forEach((ch) => {
const div = document.createElement('div');
div.className = 'item' + (selectedChallenge && selectedChallenge.challengeId === ch.challengeId ? ' selected' : '');
const inner = [
'<span><b>',
ch.platform || '?',
'</b> · ',
KIND_LABEL[ch.kind] || ch.kind,
'</span><span class="p">',
ch.prompt || '',
'</span>',
].join('');
div.innerHTML = inner;
const label = document.createElement('span');
const platform = document.createElement('b');
platform.textContent = ch.platform || '?';
label.append(platform, document.createTextNode(' · ' + (KIND_LABEL[ch.kind] || ch.kind)));
const prompt = document.createElement('span');
prompt.className = 'p';
prompt.textContent = ch.prompt || '';
div.append(label, prompt);
div.addEventListener('click', () => selectChallenge(ch));
box.appendChild(div);
});
if (!list.length) {
box.innerHTML = '<p class="hint">暂无待处理挑战</p>';
const empty = document.createElement('p');
empty.className = 'hint';
empty.textContent = '暂无待处理挑战';
box.appendChild(empty);
}
} catch (e) { /* 忽略 */ }
}
@@ -149,7 +157,9 @@ startPolling();
async function loadSettings() {
try {
const cfg = await window.api.getConfig();
$('set-server').value = (cfg && cfg.server) || '';
$('set-server').value = (cfg && cfg.server) || 'http://127.0.0.1:8090';
$('set-web').value = (cfg && cfg.webUrl) || 'http://127.0.0.1:3003';
currentWeb = $('set-web').value;
$('set-fake').checked = !!(cfg && cfg.fakeAll);
$('set-autostart').checked = await window.api.getAutoStart();
} catch (e) { /* 忽略 */ }
@@ -158,12 +168,15 @@ loadSettings();
$('btn-save').addEventListener('click', async () => {
const server = $('set-server').value.trim();
const webUrl = $('set-web').value.trim();
const res = $('set-result');
if (!server) { res.textContent = '请填写服务器地址(内网部署填 http://服务器IP:8090)'; res.className = 'msg err'; return; }
const cfg = { server: server, fakeAll: $('set-fake').checked };
if (!webUrl) { res.textContent = '请填写网站地址'; res.className = 'msg err'; return; }
const cfg = { server: server, webUrl: webUrl, fakeAll: $('set-fake').checked };
await window.api.saveConfig(cfg);
await window.api.setAutoStart($('set-autostart').checked);
currentServer = server;
currentWeb = webUrl;
res.textContent = '已保存,核心进程已重启';
res.className = 'msg ok';
});
+2
View File
@@ -72,6 +72,8 @@
<h2>设置</h2>
<label>服务器地址(内网部署时填 http://服务器IP:8090)</label>
<input id="set-server" placeholder="http://192.168.x.x:8090" />
<label>网站地址(本地开发默认 http://127.0.0.1:3003)</label>
<input id="set-web" placeholder="http://127.0.0.1:3003" />
<label class="switch-row">
<input type="checkbox" id="set-fake" />
<span>模拟执行(无真实账号的安装联调)</span>
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
CORE_DIR="$ROOT_DIR/client/core"
DIST_DIR="$ROOT_DIR/client/dist"
RELEASE_DIR="$ROOT_DIR/client/macos/release"
APP_DIR="$RELEASE_DIR/EveryPublish.app"
ARCH=${MAC_ARCH:-arm64}
SIGN_IDENTITY=${MAC_SIGN_IDENTITY:--}
case "$ARCH" in
arm64) GOARCH=arm64; SWIFT_ARCH=arm64 ;;
x64) GOARCH=amd64; SWIFT_ARCH=x86_64 ;;
*) echo "Unsupported MAC_ARCH: $ARCH (use arm64 or x64)" >&2; exit 2 ;;
esac
mkdir -p "$DIST_DIR" "$RELEASE_DIR"
(cd "$CORE_DIR" && GOOS=darwin GOARCH="$GOARCH" CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o "$DIST_DIR/agent-core" .)
chmod 755 "$DIST_DIR/agent-core"
rm -rf "$APP_DIR"
mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
swiftc "$SCRIPT_DIR/EveryPublishMacOSApp.swift" \
-target "$SWIFT_ARCH-apple-macosx13.0" \
-O \
-parse-as-library \
-framework SwiftUI \
-framework AppKit \
-framework UserNotifications \
-o "$APP_DIR/Contents/MacOS/EveryPublish"
cp "$DIST_DIR/agent-core" "$APP_DIR/Contents/Resources/agent-core"
chmod 755 "$APP_DIR/Contents/MacOS/EveryPublish" "$APP_DIR/Contents/Resources/agent-core"
cat > "$APP_DIR/Contents/Info.plist" <<PLIST
<?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>CFBundleDisplayName</key><string>EveryPublish</string>
<key>CFBundleExecutable</key><string>EveryPublish</string>
<key>CFBundleIdentifier</key><string>com.everypublish.macos</string>
<key>CFBundleName</key><string>EveryPublish</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.3.0</string>
<key>CFBundleVersion</key><string>0.3.0</string>
<key>LSMinimumSystemVersion</key><string>13.0</string>
<key>LSUIElement</key><false/>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
PLIST
if [ "$SIGN_IDENTITY" = "-" ]; then
DMG_SUFFIX="-local"
echo "Signing app ad hoc for local testing (set MAC_SIGN_IDENTITY for distribution)"
codesign --force --deep --sign - "$APP_DIR"
else
DMG_SUFFIX=""
echo "Signing app with $SIGN_IDENTITY"
codesign --force --deep --options runtime --timestamp --sign "$SIGN_IDENTITY" "$APP_DIR"
fi
codesign --verify --deep --strict "$APP_DIR"
if command -v hdiutil >/dev/null 2>&1; then
STAGE_DIR=$(mktemp -d "$RELEASE_DIR/dmg-stage.XXXXXX")
trap 'rm -rf "$STAGE_DIR"' EXIT
cp -R "$APP_DIR" "$STAGE_DIR/EveryPublish.app"
ln -s /Applications "$STAGE_DIR/Applications"
rm -f "$RELEASE_DIR/EveryPublish-0.3.0-$ARCH.dmg" "$RELEASE_DIR/EveryPublish-0.3.0-$ARCH-local.dmg"
hdiutil create -volname EveryPublish -srcfolder "$STAGE_DIR" -ov -format UDZO "$RELEASE_DIR/EveryPublish-0.3.0-$ARCH$DMG_SUFFIX.dmg" >/dev/null
fi
file "$APP_DIR/Contents/MacOS/EveryPublish" "$APP_DIR/Contents/Resources/agent-core"
echo "Built $APP_DIR"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
APP_SOURCE="$SCRIPT_DIR/release/EveryPublish.app"
APP_DEST="${HOME}/Applications/EveryPublish.app"
if [ ! -d "$APP_SOURCE" ]; then
echo "Build the app first: ./build-macos-app.sh" >&2
exit 1
fi
mkdir -p "${HOME}/Applications"
rm -rf "$APP_DEST"
cp -R "$APP_SOURCE" "$APP_DEST"
xattr -dr com.apple.quarantine "$APP_DEST" 2>/dev/null || true
open "$APP_DEST"
echo "Installed local test app at $APP_DEST"