client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent 33b7d57498
commit 9abf7f8213
60 changed files with 10025 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
module everypublish/agentcore
go 1.23
require (
everypublish/shared v0.0.0
github.com/coder/websocket v1.8.15
github.com/go-rod/rod v0.116.2
github.com/go-rod/stealth v0.4.9
)
require (
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
)
replace everypublish/shared => ../../shared
+24
View File
@@ -0,0 +1,24 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
)
// Config 客户端配置(data/config.json)
type Config struct {
Server string `json:"server"`
DeviceID uint64 `json:"deviceId"`
Name string `json:"name"`
Proxy string `json:"proxy"`
SimMS int `json:"simMs"`
FakeAll bool `json:"fakeAll"` // 全平台模拟执行(无真实账号联调用)
}
// Default 默认配置
func Default() *Config {
return &Config{Server: "http://127.0.0.1:8090", SimMS: 50}
}
// Load 读取配置(不存在则返回默认)
func Load(dir string) (*Config, error) {
cfg := Default()
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
_ = json.Unmarshal(raw, cfg)
return cfg, nil
}
// Save 持久化配置
func (c *Config) Save(dir string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(filepath.Join(dir, "config.json"), raw, 0o600)
}
+72
View File
@@ -0,0 +1,72 @@
package device
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
)
// Identity 设备身份:Ed25519 密钥对 + 服务器分配的设备 ID
type Identity struct {
DeviceID uint64 `json:"deviceId"`
Name string `json:"name"`
PublicKey string `json:"publicKey"`
priv ed25519.PrivateKey `json:"-"`
}
// LoadOrCreate 加载或生成本机设备身份(私钥 hex 文件 0600)
func LoadOrCreate(dir string, name string) (*Identity, error) {
keyPath := filepath.Join(dir, "device.key")
idPath := filepath.Join(dir, "identity.json")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
var priv ed25519.PrivateKey
if raw, err := os.ReadFile(keyPath); err == nil {
seed, err := hex.DecodeString(string(raw))
if err != nil || len(seed) != ed25519.SeedSize {
return nil, errors.New("device key corrupted")
}
priv = ed25519.NewKeyFromSeed(seed)
} else {
_, priv, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
if err = os.WriteFile(keyPath, []byte(hex.EncodeToString(priv.Seed())), 0o600); err != nil {
return nil, err
}
}
id := &Identity{
Name: name,
PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
priv: priv,
}
if raw, err := os.ReadFile(idPath); err == nil {
_ = json.Unmarshal(raw, id)
id.priv = priv
if id.Name == "" {
id.Name = name
}
}
return id, nil
}
// Save 持久化设备 ID(配对成功后调用)
func (i *Identity) Save(dir string) error {
raw, err := json.MarshalIndent(i, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "identity.json"), raw, 0o600)
}
// Sign 对载荷签名(hello 握手用)
func (i *Identity) Sign(payload []byte) string {
return base64.StdEncoding.EncodeToString(ed25519.Sign(i.priv, payload))
}
@@ -0,0 +1,38 @@
package device
import (
"crypto/ed25519"
"encoding/base64"
"testing"
)
func TestLoadOrCreatePersist(t *testing.T) {
dir := t.TempDir()
id1, err := LoadOrCreate(dir, "test-device")
if err != nil {
t.Fatalf("create: %v", err)
}
// 再次加载应得到同一公钥
id2, err := LoadOrCreate(dir, "test-device")
if err != nil {
t.Fatalf("reload: %v", err)
}
if id1.PublicKey != id2.PublicKey {
t.Fatal("public key should persist across reloads")
}
}
func TestSignVerify(t *testing.T) {
dir := t.TempDir()
id, _ := LoadOrCreate(dir, "d")
payload := []byte("nonce-1|123456")
sigB64 := id.Sign(payload)
sig, _ := base64.StdEncoding.DecodeString(sigB64)
pub, _ := base64.StdEncoding.DecodeString(id.PublicKey)
if !ed25519.Verify(ed25519.PublicKey(pub), payload, sig) {
t.Fatal("signature should verify")
}
if ed25519.Verify(ed25519.PublicKey(pub), []byte("tampered"), sig) {
t.Fatal("tampered payload should fail verify")
}
}
@@ -0,0 +1,458 @@
// bilibili B站执行器(无浏览器投稿 + API 扫码登录)。
// 协议依据:ForgQi/biliup(MIT)bili_webup.py 的 upos/bda2 上传链路。
package bilibili
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/vault"
"everypublish/shared/proto"
)
// Executor B站投稿执行器
type Executor struct {
Vault *vault.Vault
HTTP *http.Client
MemberBase string // 投稿 API 基址(测试可替换)
PassportBase string // 登录 API 基址(测试可替换)
UpOSScheme string // upos 上传协议(默认 https,测试 http)
PollTimeout time.Duration // 扫码轮询总时长(默认 3 分钟)
}
// New 构造执行器
func New(v *vault.Vault) *Executor {
return &Executor{
Vault: v,
HTTP: &http.Client{Timeout: 30 * time.Second},
MemberBase: "https://member.bilibili.com",
PassportBase: "https://passport.bilibili.com",
UpOSScheme: "https",
PollTimeout: 3 * time.Minute,
}
}
// Platform 平台标识
func (e *Executor) Platform() string { return "bilibili" }
// credentials 凭据(保险库 JSON)
type credentials struct {
Cookies string `json:"cookies"`
}
// Execute 发布:下载素材 → upos 上传 → 投稿 → 返回作品链接
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
fail := func(err string) exec.Result {
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err}
}
creds, err := e.creds(task.AccountID)
if err != nil {
return fail("B站账号未登录(本地无凭据),请先在客户端完成扫码绑定")
}
if len(task.MaterialURLs) == 0 {
return fail("任务缺少素材")
}
videoPath, cleanup, err := downloadFile(ctx, e.HTTP, task.MaterialURLs[0])
if err != nil {
return fail("素材下载失败: " + err.Error())
}
defer cleanup()
part, err := e.uploadVideo(ctx, creds.Cookies, videoPath)
if err != nil {
return fail("视频上传失败: " + err.Error())
}
bvid, err := e.submit(ctx, creds.Cookies, task, part)
if err != nil {
return fail("投稿提交失败: " + err.Error())
}
return exec.Result{
TaskID: task.TaskID,
Status: "success",
PublishedURL: "https://www.bilibili.com/video/" + bvid,
}
}
func (e *Executor) creds(accountID string) (*credentials, error) {
raw, err := e.Vault.Load(accountID)
if err != nil {
return nil, err
}
var c credentials
if err = json.Unmarshal(raw, &c); err != nil {
return nil, err
}
if c.Cookies == "" {
return nil, fmt.Errorf("empty cookies")
}
return &c, nil
}
// uploadVideo 上传视频(upos/bda2 分片),返回投稿所需的 part 信息
func (e *Executor) uploadVideo(ctx context.Context, cookies, filePath string) (map[string]interface{}, error) {
stat, err := os.Stat(filePath)
if err != nil {
return nil, err
}
name := filepath.Base(filePath)
total := stat.Size()
// 1) preupload 获取上传地址
q := url.Values{}
q.Set("r", "upos")
q.Set("profile", "ugcupos/bup")
q.Set("ssl", "0")
q.Set("version", "2.8.12")
q.Set("build", "2081200")
q.Set("name", name)
q.Set("size", strconv.FormatInt(total, 10))
preURL := e.MemberBase + "/preupload?upcdn=bda2&probe_version=20221109&" + q.Encode()
var pre struct {
OK int `json:"OK"`
Endpoint string `json:"endpoint"`
Auth string `json:"auth"`
BizID int `json:"biz_id"`
UposURI string `json:"upos_uri"`
ChunkSize int `json:"chunk_size"`
}
if err = e.getJSON(ctx, preURL, cookies, &pre); err != nil {
return nil, err
}
if pre.OK != 1 || pre.UposURI == "" {
return nil, fmt.Errorf("preupload 返回异常")
}
upURL := e.UpOSScheme + ":" + pre.Endpoint + "/" + strings.TrimPrefix(pre.UposURI, "upos://")
authH := map[string]string{"X-Upos-Auth": pre.Auth}
// 2) 申请 upload_id
var up struct {
UploadID string `json:"upload_id"`
}
if err = e.postJSON(ctx, upURL+"?uploads&output=json", cookies, authH, nil, &up); err != nil {
return nil, fmt.Errorf("申请 upload_id 失败: %w", err)
}
// 3) 分片上传
chunkSize := pre.ChunkSize
if chunkSize <= 0 {
chunkSize = 7 * 1024 * 1024
}
chunks := int((total + int64(chunkSize) - 1) / int64(chunkSize))
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
parts := make([]map[string]interface{}, 0, chunks)
buf := make([]byte, chunkSize)
for i := 0; i < chunks; i++ {
n, rerr := io.ReadFull(f, buf)
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
return nil, rerr
}
start := i * chunkSize
end := start + n
pq := url.Values{}
pq.Set("uploadId", up.UploadID)
pq.Set("partNumber", strconv.Itoa(i+1))
pq.Set("chunk", strconv.Itoa(i))
pq.Set("chunks", strconv.Itoa(chunks))
pq.Set("size", strconv.Itoa(n))
pq.Set("start", strconv.Itoa(start))
pq.Set("end", strconv.Itoa(end))
pq.Set("total", strconv.FormatInt(total, 10))
if err = e.putBody(ctx, upURL+"?"+pq.Encode(), cookies, authH, buf[:n]); err != nil {
return nil, fmt.Errorf("分片 %d 上传失败: %w", i+1, err)
}
parts = append(parts, map[string]interface{}{"partNumber": i + 1, "eTag": "etag"})
}
// 4) 合并分片
mq := url.Values{}
mq.Set("name", name)
mq.Set("uploadId", up.UploadID)
mq.Set("biz_id", strconv.Itoa(pre.BizID))
mq.Set("output", "json")
mq.Set("profile", "ugcupos/bup")
var merged struct {
OK int `json:"OK"`
}
if err = e.postJSON(ctx, upURL+"?"+mq.Encode(), cookies, authH, map[string]interface{}{"parts": parts}, &merged); err != nil {
return nil, err
}
if merged.OK != 1 {
return nil, fmt.Errorf("分片合并返回异常")
}
return map[string]interface{}{
"title": strings.TrimSuffix(name, filepath.Ext(name)),
"filename": strings.TrimSuffix(filepath.Base(pre.UposURI), filepath.Ext(filepath.Base(pre.UposURI))),
"desc": "",
}, nil
}
// submit 投稿(x/vu/web/add)
func (e *Executor) submit(ctx context.Context, cookies string, task exec.Task, part map[string]interface{}) (string, error) {
csrf := cookieValue(cookies, "bili_jct")
// 风控预热(失败不影响)
_, _ = e.get(ctx, e.MemberBase+"/x/geetest/pre/add", cookies)
tag := strings.Join(task.Tags, ",")
if tag == "" {
tag = "日常"
}
body := map[string]interface{}{
"title": truncateRunes(task.Title, 80),
"desc": task.Content,
"desc_v2": []map[string]interface{}{{"raw_text": task.Content, "type": 1, "biz_id": ""}},
"copyright": 1,
"source": "",
"tid": 174,
"tag": tag,
"dynamic": "",
"videos": []map[string]interface{}{part},
}
var ret struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Bvid string `json:"bvid"`
} `json:"data"`
}
if err := e.postJSON(ctx, e.MemberBase+"/x/vu/web/add?csrf="+csrf, cookies, nil, body, &ret); err != nil {
return "", err
}
if ret.Code != 0 {
return "", fmt.Errorf("code=%d %s", ret.Code, ret.Message)
}
return ret.Data.Bvid, nil
}
// SolveChallenge B站扫码登录:生成二维码 → onQR 展示 → 轮询 → 保存 cookie
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
key, qrURL, err := e.generateQR(ctx)
if err != nil {
return false, ""
}
if onQR != nil {
onQR(key, qrURL)
}
deadline := time.Now().Add(e.PollTimeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return false, ""
default:
}
code, cookieURL, perr := e.pollQR(ctx, key)
if perr != nil {
return false, ""
}
switch code {
case 0:
cookies := cookiesFromCrossDomainURL(cookieURL)
if cookies == "" {
return false, ""
}
_ = e.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+jsonEscape(cookies)+`"}`))
return true, "confirmed"
case 86090:
case 86101:
default:
return false, ""
}
time.Sleep(2 * time.Second)
}
return false, ""
}
// generateQR 生成登录二维码
func (e *Executor) generateQR(ctx context.Context) (key, qrURL string, err error) {
var ret struct {
Code int `json:"code"`
Data struct {
URL string `json:"url"`
QrcodeKey string `json:"qrcode_key"`
} `json:"data"`
}
if err = e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/generate", "", &ret); err != nil {
return "", "", err
}
if ret.Code != 0 || ret.Data.QrcodeKey == "" {
return "", "", fmt.Errorf("生成二维码失败 code=%d", ret.Code)
}
return ret.Data.QrcodeKey, ret.Data.URL, nil
}
// pollQR 轮询扫码状态:86101 未扫 / 86090 已扫待确认 / 0 成功(返回跨域 cookie URL)
func (e *Executor) pollQR(ctx context.Context, key string) (int, string, error) {
var ret struct {
Code int `json:"code"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/poll?qrcode_key="+key, "", &ret); err != nil {
return 0, "", err
}
return ret.Code, ret.Data.URL, nil
}
// ---- HTTP 辅助 ----
func (e *Executor) get(ctx context.Context, rawURL, cookies string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
if cookies != "" {
req.Header.Set("Cookie", cookies)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36")
req.Header.Set("Referer", "https://member.bilibili.com")
resp, err := e.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (e *Executor) getJSON(ctx context.Context, rawURL, cookies string, out interface{}) error {
raw, err := e.get(ctx, rawURL, cookies)
if err != nil {
return err
}
return json.Unmarshal(raw, out)
}
func (e *Executor) postJSON(ctx context.Context, rawURL, cookies string, extraH map[string]string, body interface{}, out interface{}) error {
var buf bytes.Buffer
if body != nil {
raw, _ := json.Marshal(body)
buf.Write(raw)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, &buf)
if err != nil {
return err
}
if cookies != "" {
req.Header.Set("Cookie", cookies)
}
for k, v := range extraH {
req.Header.Set(k, v)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36")
req.Header.Set("Referer", "https://member.bilibili.com")
resp, err := e.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("http %d", resp.StatusCode)
}
if out == nil {
return nil
}
return json.Unmarshal(raw, out)
}
func (e *Executor) putBody(ctx context.Context, rawURL, cookies string, extraH map[string]string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(data))
if err != nil {
return err
}
if cookies != "" {
req.Header.Set("Cookie", cookies)
}
for k, v := range extraH {
req.Header.Set(k, v)
}
resp, err := e.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("http %d", resp.StatusCode)
}
return nil
}
// downloadFile 下载素材到临时文件
func downloadFile(ctx context.Context, client *http.Client, rawURL string) (string, func(), error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", nil, err
}
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode)
}
tmp, err := os.CreateTemp("", "ep-material-*")
if err != nil {
return "", nil, err
}
_, err = io.Copy(tmp, resp.Body)
tmp.Close()
if err != nil {
os.Remove(tmp.Name())
return "", nil, err
}
return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil
}
// cookieValue 从 cookie 串取值
func cookieValue(cookies, key string) string {
for _, part := range strings.Split(cookies, ";") {
part = strings.TrimSpace(part)
if i := strings.Index(part, "="); i > 0 && part[:i] == key {
return part[i+1:]
}
}
return ""
}
// cookiesFromCrossDomainURL 从跨域跳转 URL 提取登录 cookie
func cookiesFromCrossDomainURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
q := u.Query()
sess := q.Get("SESSDATA")
jct := q.Get("bili_jct")
dede := q.Get("DedeUserID")
if sess == "" {
return ""
}
return "SESSDATA=" + sess + "; bili_jct=" + jct + "; DedeUserID=" + dede
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
func jsonEscape(s string) string {
raw, _ := json.Marshal(s)
return string(raw[1 : len(raw)-1])
}
@@ -0,0 +1,187 @@
package bilibili
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/vault"
"everypublish/shared/proto"
)
// stubBili 桩:preupload + upos(uploads/分片/合并)+ 投稿 add,记录关键请求
func stubBili(t *testing.T) (*httptest.Server, *struct {
PartQuery string
PartsLen int
AddBody map[string]interface{}
AddURL string
XUposAuth string
}) {
rec := &struct {
PartQuery string
PartsLen int
AddBody map[string]interface{}
AddURL string
XUposAuth string
}{}
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/preupload":
// endpoint 指向桩服务器自身
endpoint := "//" + strings.TrimPrefix(srv.URL, "http://")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"OK": 1, "endpoint": endpoint, "auth": "auth-x", "biz_id": 1000,
"upos_uri": "upos://video/av123", "chunk_size": 5,
})
case strings.Contains(r.URL.RawQuery, "uploads"):
_ = json.NewEncoder(w).Encode(map[string]string{"upload_id": "upload-1"})
case r.Method == http.MethodPut:
rec.PartQuery = r.URL.RawQuery
rec.XUposAuth = r.Header.Get("X-Upos-Auth")
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && strings.Contains(r.URL.RawQuery, "output=json"):
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
if parts, ok := body["parts"].([]interface{}); ok {
rec.PartsLen = len(parts)
}
_ = json.NewEncoder(w).Encode(map[string]int{"OK": 1})
case r.URL.Path == "/x/vu/web/add":
rec.AddURL = r.URL.RawQuery
_ = json.NewDecoder(r.Body).Decode(&rec.AddBody)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0, "data": map[string]string{"bvid": "BV1TEST123"},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
return srv, rec
}
func TestExecuteUploadFlow(t *testing.T) {
// 素材服务器
matSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("0123456789abcdef"))
}))
defer matSrv.Close()
srv, rec := stubBili(t)
defer srv.Close()
v, _ := vault.Open(t.TempDir())
_ = v.Save("77", []byte(`{"cookies":"SESSDATA=sess; bili_jct=csrf-x; DedeUserID=1"}`))
e := New(v)
e.MemberBase = srv.URL
e.PassportBase = srv.URL
e.UpOSScheme = "http"
res := e.Execute(context.Background(), exec.Task{
TaskID: "9", AccountID: "77", Title: "测试标题", Content: "简介",
Tags: []string{"测试", "日常"}, MaterialURLs: []string{matSrv.URL + "/v.mp4"},
})
if res.Status != "success" {
t.Fatalf("expect success, got %+v", res)
}
if res.PublishedURL != "https://www.bilibili.com/video/BV1TEST123" {
t.Fatalf("published url wrong: %s", res.PublishedURL)
}
// rec.PartQuery 记录最后一片(partNumber=4 size=1 start=15 end=16)
if !strings.Contains(rec.PartQuery, "partNumber=4") || !strings.Contains(rec.PartQuery, "chunks=4") ||
!strings.Contains(rec.PartQuery, "total=16") || !strings.Contains(rec.PartQuery, "size=1") {
t.Fatalf("chunk query wrong: %s", rec.PartQuery)
}
if rec.PartsLen != 4 {
t.Fatalf("expect 4 merged parts, got %d", rec.PartsLen)
}
if rec.AddURL != "csrf=csrf-x" {
t.Fatalf("add csrf wrong: %s", rec.AddURL)
}
if rec.AddBody["title"] != "测试标题" || rec.AddBody["copyright"] != float64(1) {
t.Fatalf("add body wrong: %+v", rec.AddBody)
}
if videos, ok := rec.AddBody["videos"].([]interface{}); !ok || len(videos) != 1 {
t.Fatal("videos missing")
}
}
func TestSolveChallengeQRLogin(t *testing.T) {
pollAttempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "generate"):
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0, "data": map[string]string{"url": "https://qr.example/x", "qrcode_key": "key-1"},
})
case strings.Contains(r.URL.Path, "poll"):
pollAttempts++
if pollAttempts == 1 {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
} else {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"data": map[string]string{
"url": "https://passport.biligame.com/x/passport-login/web/crossDomain?SESSDATA=sess123&bili_jct=jct456&DedeUserID=789",
},
})
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
v, _ := vault.Open(t.TempDir())
e := New(v)
e.MemberBase = srv.URL
e.PassportBase = srv.URL
e.PollTimeout = 10 * time.Second
var gotQR string
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1", AccountID: "77"}, func(token, qrURL string) {
gotQR = qrURL
})
if !solved || value != "confirmed" {
t.Fatalf("expect solved, got %v %s", solved, value)
}
if gotQR == "" {
t.Fatal("onQR should be called")
}
raw, err := v.Load("77")
if err != nil {
t.Fatalf("vault should have cookies: %v", err)
}
if !strings.Contains(string(raw), "SESSDATA=sess123") {
t.Fatalf("cookies not saved: %s", raw)
}
}
func TestPollTimeoutUnscanned(t *testing.T) {
// 一直未扫码:应超时返回未解决
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "generate") {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]string{"url": "u", "qrcode_key": "k"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
}))
defer srv.Close()
v, _ := vault.Open(t.TempDir())
e := New(v)
e.MemberBase = srv.URL
e.PassportBase = srv.URL
e.PollTimeout = 3 * time.Second
if solved, _ := e.SolveChallenge(context.Background(), proto.Challenge{AccountID: "1"}, nil); solved {
t.Fatal("unscanned should timeout unsolved")
}
}
+116
View File
@@ -0,0 +1,116 @@
// cdnbase 三平台执行器公共底座:保险库 + 浏览器档案目录 + 代理 + 执行入口。
package cdnbase
import (
"context"
"encoding/json"
"path/filepath"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/exec/cdp"
"everypublish/agentcore/internal/vault"
"everypublish/shared/proto"
)
// Platform 三平台执行器公共底座
type Platform struct {
Vault *vault.Vault
RootDir string // 浏览器档案根目录(每账号子目录)
Proxy string // 账号固定 IP 代理
ChromePath string
Headless bool
ReceiptDir string // 截图回执目录
Name string // 平台名(日志)
PublishCfg cdp.PublishConfig
LoginCfg cdp.QRLoginConfig
LoginDomain string // 登录 cookie 域名
}
// credentials 保险库凭据
type credentials struct {
Cookies string `json:"cookies"`
}
// loadCookies 读账号 cookie(无则空,触发扫码)
func (p *Platform) loadCookies(accountID string) string {
raw, err := p.Vault.Load(accountID)
if err != nil {
return ""
}
var c credentials
if json.Unmarshal(raw, &c) != nil {
return ""
}
return c.Cookies
}
// profileDir 账号独立档案目录
func (p *Platform) profileDir(accountID string) string {
return filepath.Join(p.RootDir, p.Name, accountID)
}
// Publish 通用发布入口(下载素材 → 启动浏览器 → 发布 → 回传)
func (p *Platform) Publish(ctx context.Context, task exec.Task) exec.Result {
fail := func(err string) exec.Result {
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err}
}
cookies := p.loadCookies(task.AccountID)
if len(task.MaterialURLs) == 0 {
return fail("任务缺少素材")
}
videoPath, cleanup, err := cdp.DownloadFile(ctx, task.MaterialURLs[0])
if err != nil {
return fail("素材下载失败: " + err.Error())
}
defer cleanup()
br, err := cdp.Launch(ctx, cdp.Options{
UserDataDir: p.profileDir(task.AccountID),
Proxy: p.Proxy,
Headless: p.Headless,
ChromePath: p.ChromePath,
})
if err != nil {
return fail("浏览器启动失败: " + err.Error())
}
defer br.Close()
cfg := p.PublishCfg
cfg.Platform = p.Name
cfg.ReceiptDir = p.ReceiptDir
res, err := br.Publish(ctx, cfg, videoPath, task.Title, task.Content, task.Tags, cookies, p.LoginDomain)
if err != nil {
return fail("发布失败: " + err.Error())
}
return exec.Result{
TaskID: task.TaskID,
Status: "success",
PublishedURL: res.URL,
Receipts: res.Receipts,
}
}
// SolveChallenge CDP 扫码登录:二维码截图经 onQR 展示 → 轮询登录 → 保存 cookie
func (p *Platform) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
br, err := cdp.Launch(ctx, cdp.Options{
UserDataDir: p.profileDir(ch.AccountID),
Proxy: p.Proxy,
Headless: p.Headless,
ChromePath: p.ChromePath,
})
if err != nil {
cdp.Log("%s: 扫码浏览器启动失败: %v", p.Name, err)
return false, ""
}
defer br.Close()
cookies, err := br.QRLogin(ctx, p.LoginCfg, onQR)
if err != nil {
cdp.Log("%s: 扫码登录失败: %v", p.Name, err)
return false, ""
}
if cookies == "" {
return false, ""
}
_ = p.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+cdp.EscapeJSON(cookies)+`"}`))
return true, "confirmed"
}
+231
View File
@@ -0,0 +1,231 @@
// cdp 共享浏览器基础设施:独立档案启动(每账号一档案)、代理、指纹、扫码登录。
// 一期驱动 rod-launcher 管理的 Chromium;ChromePath 给定则驱动系统 Chrome/Edge。
package cdp
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
rodstealth "github.com/go-rod/stealth"
)
// Options 浏览器启动选项
type Options struct {
UserDataDir string // 每账号独立档案目录
Proxy string // 账号固定 IP 代理(http://ip:port)
Headless bool
ChromePath string // 系统 Chrome/Edge 可执行文件(空=自动探测/下载)
}
// Browser 浏览器封装
type Browser struct {
rod *rod.Browser
Page *rod.Page
}
// defaultChromePath 常见系统浏览器路径(Windows 优先,macOS 兜底)
func defaultChromePath() string {
candidates := []string{
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
}
if runtime.GOOS == "darwin" {
candidates = append(candidates, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// Launch 启动浏览器(独立档案 + 指纹 + 反自动化)
func Launch(ctx context.Context, opts Options) (*Browser, error) {
l := launcher.New()
if opts.UserDataDir != "" {
if err := os.MkdirAll(opts.UserDataDir, 0o700); err != nil {
return nil, err
}
l = l.UserDataDir(opts.UserDataDir)
}
if opts.Proxy != "" {
l = l.Set("proxy-server", opts.Proxy)
}
bin := opts.ChromePath
if bin == "" {
bin = defaultChromePath()
}
if bin != "" {
l = l.Bin(bin)
}
l = l.Headless(opts.Headless).
Set("no-first-run").
Set("no-default-browser-check").
Set("disable-blink-features", "AutomationControlled").
Set("window-size", "1440,900")
url, err := l.Launch()
if err != nil {
return nil, err
}
browser := rod.New().ControlURL(url)
if err = browser.Connect(); err != nil {
return nil, err
}
// 指纹/反自动化(stealth 注入)
page, err := rodstealth.Page(browser)
if err != nil {
browser.Close()
return nil, err
}
return &Browser{rod: browser, Page: page}, nil
}
// Close 关闭浏览器
func (b *Browser) Close() {
_ = b.rod.Close()
}
// Screenshot 截图回执(PNG 本地保存,返回路径;二期上传服务器)
func (b *Browser) Screenshot(receiptDir, name string) string {
if err := os.MkdirAll(receiptDir, 0o755); err != nil {
return ""
}
path := filepath.Join(receiptDir, name)
_ = b.Page.MustWaitLoad().MustScreenshot(path)
return path
}
// InjectCookies 注入登录 cookie(免重复扫码)
func (b *Browser) InjectCookies(cookieStr, domain string) error {
for _, part := range strings.Split(cookieStr, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
i := strings.Index(part, "=")
if i <= 0 {
continue
}
name, value := part[:i], part[i+1:]
cookie := &proto.NetworkCookieParam{
Name: name,
Value: value,
Domain: domain,
Path: "/",
}
_ = b.rod.SetCookies([]*proto.NetworkCookieParam{cookie})
}
return nil
}
// Cookies 导出当前浏览器 cookie(域名过滤)
func (b *Browser) Cookies(domain string) string {
cookies, err := b.rod.GetCookies()
if err != nil {
return ""
}
parts := make([]string, 0, len(cookies))
for _, c := range cookies {
if domain != "" && !strings.Contains(c.Domain, domain) {
continue
}
parts = append(parts, c.Name+"="+c.Value)
}
return strings.Join(parts, "; ")
}
// QRLoginConfig 扫码登录配置
type QRLoginConfig struct {
LoginURL string
LoginPathMarker string // 登录页 URL 特征(登录成功=URL 离开该特征)
QRSelector string // 二维码元素选择器
SuccessWait time.Duration
}
// QRLogin 通用扫码登录:打开登录页 → 截图二维码经 onQR 展示 → 轮询 URL 直到登录成功
func (b *Browser) QRLogin(ctx context.Context, cfg QRLoginConfig, onQR func(qrToken, qrURL string)) (string, error) {
if err := b.Page.Navigate(cfg.LoginURL); err != nil {
return "", err
}
_ = b.Page.WaitLoad()
// 截图二维码 → 临时 PNG → file:// URI → onQR(壳端 BitmapImage 展示)
qrEl, err := b.Page.Element(cfg.QRSelector)
if err == nil {
raw, serr := qrEl.Screenshot(proto.PageCaptureScreenshotFormatPng, 100)
if serr == nil && len(raw) > 0 {
qrPath := filepath.Join(os.TempDir(), "ep-qr-"+time.Now().Format("150405")+".png")
if werr := os.WriteFile(qrPath, raw, 0o600); werr == nil && onQR != nil {
onQR("", "file://"+qrPath)
}
}
}
// 轮询登录状态
deadline := time.Now().Add(cfg.SuccessWait)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return "", fmt.Errorf("cancelled")
default:
}
info, ierr := b.Page.Info()
if ierr == nil && !strings.Contains(info.URL, cfg.LoginPathMarker) {
return b.Cookies(""), nil
}
time.Sleep(2 * time.Second)
}
return "", fmt.Errorf("扫码登录超时")
}
// DownloadFile 下载素材到临时文件(返回路径与清理函数)
func DownloadFile(ctx context.Context, rawURL string) (string, func(), error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", nil, err
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode)
}
tmp, err := os.CreateTemp("", "ep-material-*")
if err != nil {
return "", nil, err
}
_, err = io.Copy(tmp, resp.Body)
tmp.Close()
if err != nil {
os.Remove(tmp.Name())
return "", nil, err
}
return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil
}
// EscapeJSON 转义字符串供嵌入 JSON
func EscapeJSON(s string) string {
raw, _ := json.Marshal(s)
return string(raw[1 : len(raw)-1])
}
// Log 调试输出
func Log(format string, args ...interface{}) {
log.Printf("[cdp] "+format, args...)
}
+64
View File
@@ -0,0 +1,64 @@
package cdp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestEscapeJSON(t *testing.T) {
s := EscapeJSON("SESSDATA=ab\"c; bili_jct=d")
// 转义后应可嵌入 JSON 且还原一致
var decoded string
if err := json.Unmarshal([]byte(`"`+s+`"`), &decoded); err != nil {
t.Fatalf("escaped string not valid json: %v", err)
}
if decoded != "SESSDATA=ab\"c; bili_jct=d" {
t.Fatalf("roundtrip mismatch: %q", decoded)
}
}
func TestDownloadFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("video-bytes-123"))
}))
defer srv.Close()
path, cleanup, err := DownloadFile(context.Background(), srv.URL+"/v.mp4")
if err != nil {
t.Fatalf("download: %v", err)
}
defer cleanup()
raw, err := os.ReadFile(path)
if err != nil || string(raw) != "video-bytes-123" {
t.Fatalf("content mismatch: %s %v", raw, err)
}
cleanup()
if _, err = os.Stat(path); !os.IsNotExist(err) {
t.Fatal("cleanup should remove temp file")
}
}
func TestDownloadFileHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusGone)
_, _ = io.WriteString(w, "gone")
}))
defer srv.Close()
if _, _, err := DownloadFile(context.Background(), srv.URL); err == nil {
t.Fatal("410 should fail")
}
}
func TestDefaultChromePath(t *testing.T) {
// 至少不 panic、返回字符串
p := defaultChromePath()
if strings.TrimSpace(p) == "" && p != "" {
t.Fatal("unexpected")
}
}
+144
View File
@@ -0,0 +1,144 @@
// Publish 通用发布流程:进上传页 → 注入 cookie → 传视频 → 填表单 → 点发布 → 等成功跳转 → 截图回执。
// 各平台选择器差异经 PublishConfig 配置;平台改版时只需调整配置。
package cdp
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
)
// PublishConfig 发布流程配置(选择器按平台填写)
type PublishConfig struct {
Platform string
UploadURL string
SuccessMarker string // 发布成功后的 URL 特征
FileInputSel string
TitleSel string
DescSel string
PublishBtnSel string
FormWait time.Duration // 等视频处理出表单的最长时间
SuccessWait time.Duration
ReceiptDir string
}
// PublishResult 发布结果
type PublishResult struct {
URL string
Receipts []string
}
// Publish 执行通用发布流程
func (b *Browser) Publish(ctx context.Context, cfg PublishConfig, videoPath, title, content string, tags []string, cookieStr, cookieDomain string) (*PublishResult, error) {
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
return nil, err
}
_ = b.Page.WaitLoad()
// 注入登录 cookie(有则免扫码)
if cookieStr != "" {
_ = b.InjectCookies(cookieStr, cookieDomain)
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
return nil, err
}
_ = b.Page.WaitLoad()
}
// 1) 传视频
fileInput, err := b.Page.Element(cfg.FileInputSel)
if err != nil {
return nil, fmt.Errorf("找不到上传入口(%s): %w", cfg.FileInputSel, err)
}
if err = fileInput.SetFiles([]string{videoPath}); err != nil {
return nil, fmt.Errorf("选择文件失败: %w", err)
}
Log("%s: 视频已选择,等待平台处理...", cfg.Platform)
// 2) 等表单渲染(视频转码后出现标题输入框)
titleEl, err := b.Page.ElementR(cfg.TitleSel, "")
if err != nil {
titleEl, err = b.waitElement(ctx, cfg.TitleSel, cfg.FormWait)
}
if err != nil {
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-form-fail.png")
return nil, fmt.Errorf("等表单渲染超时(%s): %w", cfg.TitleSel, err)
}
// 3) 填标题/描述(话题并入描述)
if err = titleEl.Input(title); err != nil {
return nil, fmt.Errorf("填标题失败: %w", err)
}
desc := content
if len(tags) > 0 {
for _, t := range tags {
t = strings.TrimSpace(t)
if t != "" && !strings.HasPrefix(t, "#") {
t = "#" + t
}
desc += " " + t
}
}
if descEl, derr := b.Page.Element(cfg.DescSel); derr == nil {
_ = descEl.Input(desc)
}
// 4) 点发布
btn, err := b.waitElement(ctx, cfg.PublishBtnSel, 15*time.Second)
if err != nil {
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-btn-fail.png")
return nil, fmt.Errorf("找不到发布按钮(%s): %w", cfg.PublishBtnSel, err)
}
if err = btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
return nil, fmt.Errorf("点发布失败: %w", err)
}
Log("%s: 已点发布,等待结果...", cfg.Platform)
// 5) 等成功跳转
if err = b.waitURL(ctx, cfg.SuccessMarker, cfg.SuccessWait); err != nil {
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-publish-fail.png")
return nil, err
}
info, _ := b.Page.Info()
receipt := b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-success.png")
Log("%s: 发布成功 %s", cfg.Platform, info.URL)
return &PublishResult{URL: info.URL, Receipts: []string{receipt}}, nil
}
// waitElement 轮询等待元素出现
func (b *Browser) waitElement(ctx context.Context, selector string, timeout time.Duration) (*rod.Element, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, fmt.Errorf("cancelled")
default:
}
el, err := b.Page.Element(selector)
if err == nil {
return el, nil
}
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("timeout waiting %s", selector)
}
// waitURL 轮询等待 URL 命中特征
func (b *Browser) waitURL(ctx context.Context, marker string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return fmt.Errorf("cancelled")
default:
}
info, err := b.Page.Info()
if err == nil && strings.Contains(info.URL, marker) {
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("timeout waiting url %s", marker)
}
@@ -0,0 +1,62 @@
// douyin 抖音执行器(CDP:creator.douyin.com)。选择器参考 dreammis/social-auto-upload(2026-06 版)。
package douyin
import (
"context"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/exec/cdnbase"
"everypublish/agentcore/internal/exec/cdp"
"everypublish/agentcore/internal/vault"
"everypublish/shared/proto"
)
// Executor 抖音发布执行器
type Executor struct {
base *cdnbase.Platform
}
// New 构造执行器(rootDir=浏览器档案根目录,proxy=账号固定IP代理)
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
p := &cdnbase.Platform{
Vault: v,
RootDir: rootDir,
Proxy: proxy,
ChromePath: chromePath,
Headless: headless,
ReceiptDir: rootDir + "/receipts",
Name: "douyin",
LoginDomain: ".douyin.com",
PublishCfg: cdp.PublishConfig{
UploadURL: "https://creator.douyin.com/creator-micro/content/upload",
SuccessMarker: "creator-micro/content/manage",
FileInputSel: "input[type=\"file\"]",
TitleSel: "input[placeholder*=\"填写作品标题\"]",
DescSel: "div.zone-container[contenteditable=\"true\"]",
PublishBtnSel: "button:has-text(\"发布\")",
FormWait: 180 * time.Second, // 视频转码渲染表单较慢
SuccessWait: 120 * time.Second,
},
LoginCfg: cdp.QRLoginConfig{
LoginURL: "https://creator.douyin.com/",
LoginPathMarker: "creator.douyin.com/?",
QRSelector: ".qrcode-v2 img, .qrcode img, img[src*=qrcode]",
SuccessWait: 3 * time.Minute,
},
}
return &Executor{base: p}
}
// Platform 平台标识
func (e *Executor) Platform() string { return "douyin" }
// Execute 发布
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
return e.base.Publish(ctx, task)
}
// SolveChallenge 扫码登录(QR 截图经 onQR 展示)
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
return e.base.SolveChallenge(ctx, ch, onQR)
}
+39
View File
@@ -0,0 +1,39 @@
package exec_test
import (
"context"
"testing"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/exec/fake"
"everypublish/shared/proto"
)
func TestFakeExecutor(t *testing.T) {
e := fake.Executor{SimMS: 5}
start := time.Now()
res := e.Execute(context.Background(), exec.Task{TaskID: "1", Platform: "douyin", Title: "t"})
if time.Since(start) < 5*time.Millisecond {
t.Fatal("should simulate delay")
}
if res.Status != "success" || res.PublishedURL == "" {
t.Fatalf("expect success, got %+v", res)
}
}
func TestFakeSolver(t *testing.T) {
e := fake.Executor{SimMS: 1}
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1"}, nil)
if !solved || value == "" {
t.Fatalf("expect solved, got %v %s", solved, value)
}
}
func TestRegistryFallback(t *testing.T) {
r := exec.NewRegistry()
r.SetFallback(fake.Executor{SimMS: 1})
if r.Get("douyin") == nil {
t.Fatal("fallback should serve any platform")
}
}
+87
View File
@@ -0,0 +1,87 @@
package exec
import (
"context"
"sync"
"everypublish/shared/proto"
)
// Task 执行任务(由 task.push 转换而来)
type Task struct {
TaskID string
Platform string
AccountID string
AccountName string
Title string
Content string
Tags []string
MaterialURLs []string
ScheduleAt int64
Priority int
}
// Result 执行结果(回传 task.result)
type Result struct {
TaskID string
Status string
PublishedURL string
Receipts []string
Error string
}
// Executor 平台执行器(每个平台一个实现;D10 起接入真实平台)
type Executor interface {
Platform() string
Execute(ctx context.Context, task Task) Result
}
// ChallengeSolver 能自动完成挑战的平台执行器(扫码/验证码)。
// onQR:平台生成二维码时回调(把 qrToken/qrUrl 推给壳展示,可为 nil)。
type ChallengeSolver interface {
SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (solved bool, value string)
}
// Registry 执行器注册表(并发安全)
type Registry struct {
mu sync.RWMutex
m map[string]Executor
fallback Executor
}
// NewRegistry 创建注册表
func NewRegistry() *Registry {
return &Registry{m: make(map[string]Executor)}
}
// Register 注册平台执行器
func (r *Registry) Register(e Executor) {
r.mu.Lock()
defer r.mu.Unlock()
r.m[e.Platform()] = e
}
// SetFallback 注册兜底执行器(未接入平台用)
func (r *Registry) SetFallback(e Executor) {
r.mu.Lock()
defer r.mu.Unlock()
r.fallback = e
}
// Get 取执行器(无匹配返回 nil)
func (r *Registry) Get(platform string) Executor {
r.mu.RLock()
defer r.mu.RUnlock()
if e, ok := r.m[platform]; ok {
return e
}
return r.fallback
}
// Solve 取挑战解决器(nil 表示不支持自动解决)
func (r *Registry) Solve(platform string) ChallengeSolver {
if s, ok := r.Get(platform).(ChallengeSolver); ok {
return s
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
// fake 假执行器:D8 闭环验收用,模拟平台执行(延迟后返回成功)。
package fake
import (
"context"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/shared/proto"
)
// Executor 假执行器(可作兜底:任何平台都返回成功)
type Executor struct {
SimMS int
}
// Platform 空串表示通用(注册为兜底)
func (e Executor) Platform() string { return "" }
// Execute 模拟执行:延迟 SimMS 后返回成功
func (e Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
select {
case <-time.After(time.Duration(e.SimMS) * time.Millisecond):
case <-ctx.Done():
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: "cancelled"}
}
return exec.Result{
TaskID: task.TaskID,
Status: "success",
PublishedURL: "https://example.com/fake/" + task.TaskID,
Receipts: []string{},
}
}
// SolveChallenge 模拟扫码:延迟后返回已解决
func (e Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
if onQR != nil {
onQR("fake-qr-token", "https://example.com/fake-qr")
}
select {
case <-time.After(time.Duration(e.SimMS) * time.Millisecond):
case <-ctx.Done():
return false, ""
}
return true, "fake-qr-token"
}
@@ -0,0 +1,62 @@
// kuaishou 快手执行器(CDP:cp.kuaishou.com)。
package kuaishou
import (
"context"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/exec/cdnbase"
"everypublish/agentcore/internal/exec/cdp"
"everypublish/agentcore/internal/vault"
"everypublish/shared/proto"
)
// Executor 快手发布执行器
type Executor struct {
base *cdnbase.Platform
}
// New 构造执行器
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
p := &cdnbase.Platform{
Vault: v,
RootDir: rootDir,
Proxy: proxy,
ChromePath: chromePath,
Headless: headless,
ReceiptDir: rootDir + "/receipts",
Name: "kuaishou",
LoginDomain: ".kuaishou.com",
PublishCfg: cdp.PublishConfig{
UploadURL: "https://cp.kuaishou.com/article/publish/video",
SuccessMarker: "article/manage/video",
FileInputSel: "input[type=\"file\"]",
TitleSel: "input[placeholder*=\"标题\"]",
DescSel: "[contenteditable=\"true\"]",
PublishBtnSel: "button:has-text(\"发布\")",
FormWait: 180 * time.Second,
SuccessWait: 120 * time.Second,
},
LoginCfg: cdp.QRLoginConfig{
LoginURL: "https://cp.kuaishou.com/",
LoginPathMarker: "passport.kuaishou.com",
QRSelector: ".qrcode-img, img[src*=qrcode], .qrcode img",
SuccessWait: 3 * time.Minute,
},
}
return &Executor{base: p}
}
// Platform 平台标识
func (e *Executor) Platform() string { return "kuaishou" }
// Execute 发布
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
return e.base.Publish(ctx, task)
}
// SolveChallenge 扫码登录
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
return e.base.SolveChallenge(ctx, ch, onQR)
}
@@ -0,0 +1,62 @@
// xiaohongshu 小红书执行器(CDP:creator.xiaohongshu.com)。
package xiaohongshu
import (
"context"
"time"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/exec/cdnbase"
"everypublish/agentcore/internal/exec/cdp"
"everypublish/agentcore/internal/vault"
"everypublish/shared/proto"
)
// Executor 小红书发布执行器
type Executor struct {
base *cdnbase.Platform
}
// New 构造执行器
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
p := &cdnbase.Platform{
Vault: v,
RootDir: rootDir,
Proxy: proxy,
ChromePath: chromePath,
Headless: headless,
ReceiptDir: rootDir + "/receipts",
Name: "xiaohongshu",
LoginDomain: ".xiaohongshu.com",
PublishCfg: cdp.PublishConfig{
UploadURL: "https://creator.xiaohongshu.com/publish/publish",
SuccessMarker: "publish/success",
FileInputSel: "input[type=\"file\"]",
TitleSel: "input[placeholder*=\"填写标题\"]",
DescSel: "#post-textarea",
PublishBtnSel: "button:has-text(\"发布\")",
FormWait: 180 * time.Second,
SuccessWait: 120 * time.Second,
},
LoginCfg: cdp.QRLoginConfig{
LoginURL: "https://creator.xiaohongshu.com/login",
LoginPathMarker: "creator.xiaohongshu.com/login",
QRSelector: ".qrcode-img, img[src*=qrcode], .qrcode img",
SuccessWait: 3 * time.Minute,
},
}
return &Executor{base: p}
}
// Platform 平台标识
func (e *Executor) Platform() string { return "xiaohongshu" }
// Execute 发布
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
return e.base.Publish(ctx, task)
}
// SolveChallenge 扫码登录
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
return e.base.SolveChallenge(ctx, ch, onQR)
}
@@ -0,0 +1,163 @@
// localserver 本机控制服务:仅 127.0.0.1,token 鉴权,供 WinUI 壳调用。
// 端点:/status /challenges /challenges/{id} /pair;端口与 token 写入 data/local.json。
package localserver
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"everypublish/shared/proto"
)
// CoreState 核心状态接口(由 wsclient.Client 实现)
type CoreState interface {
Online() bool
SessionID() string
Challenges() []proto.Challenge
SolveChallenge(challengeID, value string) error
}
// PairFunc 配对回调(main 提供:配对成功保存身份并启动连接)
type PairFunc func(code, name string) error
// Info 本地服务信息(写 local.json 供壳读取)
type Info struct {
Port int `json:"port"`
Token string `json:"token"`
}
// ExtraFunc 额外状态字段(server/deviceId/name 等,闭包实时取值)
type ExtraFunc func() map[string]interface{}
// Server 本机控制服务
type Server struct {
state CoreState
pair PairFunc
token string
dir string
extra ExtraFunc
mu sync.Mutex
addr string
}
// New 构造控制服务(extra 可空)
func New(state CoreState, pair PairFunc, token, dir string, extra ExtraFunc) *Server {
return &Server{state: state, pair: pair, token: token, dir: dir, extra: extra}
}
// Listen 绑定随机端口并服务,同时写 data/local.json
func (s *Server) Listen() error {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
port := ln.Addr().(*net.TCPAddr).Port
s.mu.Lock()
s.addr = ln.Addr().String()
s.mu.Unlock()
raw, _ := json.Marshal(Info{Port: port, Token: s.token})
_ = os.WriteFile(filepath.Join(s.dir, "local.json"), raw, 0o600)
log.Printf("[local] control server %s (local.json written)", s.addr)
mux := http.NewServeMux()
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/challenges", s.handleChallenges)
mux.HandleFunc("/challenges/", s.handleSolve)
mux.HandleFunc("/pair", s.handlePair)
return http.Serve(ln, mux)
}
// Addr 实际监听地址(Listen 后可用)
func (s *Server) Addr() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.addr
}
func (s *Server) auth(r *http.Request) bool {
// 仅接受请求头;token 不进 URL(避免泄露进日志/历史)。常数时间比较防时序侧信道。
got := r.Header.Get("X-Agent-Token")
if len(got) != len(s.token) {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
m := map[string]interface{}{
"online": s.state.Online(),
"session": s.state.SessionID(),
}
if s.extra != nil {
for k, v := range s.extra() {
m[k] = v
}
}
_ = json.NewEncoder(w).Encode(m)
}
func (s *Server) handleChallenges(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"list": s.state.Challenges()})
}
func (s *Server) handleSolve(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := strings.TrimPrefix(r.URL.Path, "/challenges/")
var req struct {
Value string `json:"value"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := s.state.SolveChallenge(id, req.Value); err != nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
}
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
if !s.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Code string `json:"code"`
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Code == "" {
_ = 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
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
}
// RandToken 生成随机 token
func RandToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,136 @@
package localserver
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
"everypublish/shared/proto"
)
type fakeState struct {
online bool
chs []proto.Challenge
err error
}
func (f *fakeState) Online() bool { return f.online }
func (f *fakeState) SessionID() string { return "s1" }
func (f *fakeState) Challenges() []proto.Challenge { return f.chs }
func (f *fakeState) SolveChallenge(id, value string) error { return f.err }
func startTestServer(t *testing.T, state CoreState, pair PairFunc) (*Server, string) {
t.Helper()
token := RandToken()
s := New(state, pair, token, t.TempDir(), nil)
go func() { _ = s.Listen() }()
for i := 0; i < 50; i++ {
if s.Addr() != "" {
break
}
time.Sleep(10 * time.Millisecond)
}
if s.Addr() == "" {
t.Fatal("server did not start")
}
return s, token
}
func get(t *testing.T, base, path, token string) (int, map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest("GET", "http://"+base+path, nil)
if token != "" {
req.Header.Set("X-Agent-Token", token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
var m map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&m)
return resp.StatusCode, m
}
func TestStatusAuth(t *testing.T) {
s, token := startTestServer(t, &fakeState{online: true}, nil)
code, _ := get(t, s.Addr(), "/status", "")
if code != 401 {
t.Fatalf("no token should be 401, got %d", code)
}
code, m := get(t, s.Addr(), "/status", token)
if code != 200 || m["online"] != true {
t.Fatalf("status wrong: %d %v", code, m)
}
}
func TestPairCallback(t *testing.T) {
var gotCode, gotName string
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
gotCode, gotName = code, name
return nil
})
// 未带 token → 401
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("pair: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("pair without token should 401, got %d", resp.StatusCode)
}
resp.Body.Close()
// 带 token → 回调被调用
req2, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
req2.Header.Set("X-Agent-Token", token)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("pair: %v", err)
}
resp2.Body.Close()
if gotCode != "AB12CD" || gotName != "PC" {
t.Fatalf("callback not invoked: %q %q", gotCode, gotName)
}
}
func TestPairErrorPropagates(t *testing.T) {
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
return errors.New("配对码无效")
})
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"X"}`))
req.Header.Set("X-Agent-Token", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("pair: %v", err)
}
defer resp.Body.Close()
var m map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&m)
if m["code"] != float64(1) {
t.Fatalf("expect error code 1, got %v", m)
}
}
func TestSolve(t *testing.T) {
s, token := startTestServer(t, &fakeState{err: nil}, nil)
code, m := get(t, s.Addr(), "/challenges", token)
if code != 200 {
t.Fatalf("challenges: %d", code)
}
_ = m
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/challenges/9", bytes.NewBufferString(`{"value":"123456"}`))
req.Header.Set("X-Agent-Token", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("solve: %v", err)
}
defer resp.Body.Close()
var m2 map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&m2)
if m2["code"] != float64(0) {
t.Fatalf("solve should succeed, got %v", m2)
}
}
+83
View File
@@ -0,0 +1,83 @@
package pairing
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
func httpJSON(method, url, token string, body interface{}) (int, []byte, error) {
var buf bytes.Buffer
if body != nil {
raw, _ := json.Marshal(body)
buf.Write(raw)
}
req, err := http.NewRequest(method, url, &buf)
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw, nil
}
func field(raw []byte, key string) string {
var m map[string]interface{}
_ = json.Unmarshal(raw, &m)
if d, ok := m["data"].(map[string]interface{}); ok {
if v, ok := d[key]; ok {
switch t := v.(type) {
case string:
return t
case float64:
return strconv.FormatInt(int64(t), 10)
}
}
}
return ""
}
type ginH map[string]interface{}
// AutoPair 开发便利:登录 → 取配对码 → 配对(无 UI 场景闭环用)
func AutoPair(server, email, password, deviceName, pubKeyB64 string) (uint64, error) {
code, raw, err := httpJSON("POST", server+"/api/v1/auth/login", "", ginH{"email": email, "password": password})
if err != nil || code != 200 {
return 0, fmt.Errorf("login failed: %s", string(raw))
}
token := field(raw, "accessToken")
code, raw, err = httpJSON("POST", server+"/api/v1/agent/pair-code", token, nil)
if err != nil || code != 200 {
return 0, fmt.Errorf("pair-code failed: %s", string(raw))
}
pairCode := field(raw, "code")
return PairWithCode(server, pairCode, deviceName, "core", "0.2.0", pubKeyB64)
}
// PairWithCode 凭配对码注册设备,返回服务器分配的设备 ID
func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 string) (uint64, error) {
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))
}
id, _ := strconv.ParseUint(field(raw, "deviceId"), 10, 64)
if id == 0 {
return 0, fmt.Errorf("pair response missing deviceId")
}
return id, nil
}
+139
View File
@@ -0,0 +1,139 @@
package vault
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
)
// Vault 凭据保险库:AES-256-GCM 加密的 JSON 文件(主密钥单独 0600 文件)。
// 二期:Windows 上主密钥改用 DPAPI 保护,文件格式不变。
type Vault struct {
dir string
key []byte
}
type vaultFile struct {
Version int `json:"version"`
Entries map[string]string `json:"entries"`
}
// Open 打开/创建保险库(主密钥 data/master.key,32 字节)
func Open(dir string) (*Vault, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
keyPath := filepath.Join(dir, "master.key")
var key []byte
if raw, err := os.ReadFile(keyPath); err == nil {
key = make([]byte, base64.RawStdEncoding.DecodedLen(len(raw)))
if _, err = base64.RawStdEncoding.Decode(key, raw); err != nil || len(key) != 32 {
return nil, errors.New("master key corrupted")
}
} else {
key = make([]byte, 32)
if _, err = rand.Read(key); err != nil {
return nil, err
}
if err = os.WriteFile(keyPath, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
return nil, err
}
}
return &Vault{dir: dir, key: key}, nil
}
func (v *Vault) path() string { return filepath.Join(v.dir, "vault.enc") }
// load 读取并解密全部条目
func (v *Vault) load() (map[string]string, error) {
raw, err := os.ReadFile(v.path())
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
var f vaultFile
if err = json.Unmarshal(raw, &f); err != nil {
return nil, err
}
if f.Entries == nil {
f.Entries = map[string]string{}
}
return f.Entries, nil
}
// Save 加密保存单条凭据(cookie/token 等,值为 JSON 字符串)
func (v *Vault) Save(accountID string, plaintext []byte) error {
entries, err := v.load()
if err != nil {
return err
}
block, err := aes.NewCipher(v.key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return err
}
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
entries[accountID] = base64.StdEncoding.EncodeToString(sealed)
return v.persist(entries)
}
// Load 解密读取单条凭据
func (v *Vault) Load(accountID string) ([]byte, error) {
entries, err := v.load()
if err != nil {
return nil, err
}
sealedB64, ok := entries[accountID]
if !ok {
return nil, errors.New("entry not found")
}
sealed, err := base64.StdEncoding.DecodeString(sealedB64)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(v.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(sealed) < gcm.NonceSize() {
return nil, errors.New("entry corrupted")
}
nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
// Delete 删除单条凭据
func (v *Vault) Delete(accountID string) error {
entries, err := v.load()
if err != nil {
return err
}
delete(entries, accountID)
return v.persist(entries)
}
func (v *Vault) persist(entries map[string]string) error {
raw, err := json.MarshalIndent(vaultFile{Version: 1, Entries: entries}, "", " ")
if err != nil {
return err
}
return os.WriteFile(v.path(), raw, 0o600)
}
+83
View File
@@ -0,0 +1,83 @@
package vault
import (
"os"
"path/filepath"
"testing"
)
func TestSaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
v, err := Open(dir)
if err != nil {
t.Fatalf("open: %v", err)
}
secret := []byte(`{"cookies":"session=abc","token":"xyz"}`)
if err = v.Save("acc-1", secret); err != nil {
t.Fatalf("save: %v", err)
}
// 文件不应包含明文
raw, _ := os.ReadFile(filepath.Join(dir, "vault.enc"))
if stringContains(string(raw), "session=abc") {
t.Fatal("vault file must not contain plaintext")
}
got, err := v.Load("acc-1")
if err != nil {
t.Fatalf("load: %v", err)
}
if string(got) != string(secret) {
t.Fatalf("roundtrip mismatch: %s vs %s", got, secret)
}
}
func TestReopenPersists(t *testing.T) {
dir := t.TempDir()
v1, _ := Open(dir)
_ = v1.Save("acc-2", []byte("data-2"))
v2, err := Open(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
got, err := v2.Load("acc-2")
if err != nil || string(got) != "data-2" {
t.Fatalf("reopen mismatch: %s %v", got, err)
}
}
func TestTamperFails(t *testing.T) {
dir := t.TempDir()
v, _ := Open(dir)
_ = v.Save("acc-3", []byte("data-3"))
path := filepath.Join(dir, "vault.enc")
raw, _ := os.ReadFile(path)
raw[len(raw)/2] ^= 0xff
_ = os.WriteFile(path, raw, 0o600)
if _, err := v.Load("acc-3"); err == nil {
t.Fatal("tampered ciphertext should fail auth")
}
}
func TestDelete(t *testing.T) {
dir := t.TempDir()
v, _ := Open(dir)
_ = v.Save("acc-4", []byte("d4"))
if err := v.Delete("acc-4"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := v.Load("acc-4"); err == nil {
t.Fatal("deleted entry should not load")
}
}
func stringContains(s, sub string) bool {
return len(s) >= len(sub) && indexOf(s, sub) >= 0
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+334
View File
@@ -0,0 +1,334 @@
package wsclient
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"everypublish/agentcore/internal/device"
"everypublish/agentcore/internal/exec"
"everypublish/shared/proto"
)
// Client WSS 客户端:指数退避重连 + 任务幂等重放 + 挑战自动处理
type Client struct {
ServerURL string
Identity *device.Identity
Registry *exec.Registry
ExecTimeout time.Duration
mu sync.Mutex
writeMu sync.Mutex
conn *websocket.Conn
backoff time.Duration
executed map[string]exec.Result
chMu sync.Mutex
challenges map[string]proto.Challenge // 活跃挑战(WinUI 壳展示)
online bool
sessionID string
}
// New 构造客户端
func New(serverURL string, id *device.Identity, registry *exec.Registry) *Client {
return &Client{
ServerURL: serverURL,
Identity: id,
Registry: registry,
ExecTimeout: 5 * time.Minute,
executed: make(map[string]exec.Result),
challenges: make(map[string]proto.Challenge),
}
}
// Run 主循环:断线按 1s/5s/15s/60s 指数退避重连,握手成功重置
func (c *Client) Run(ctx context.Context) error {
for {
err := c.session(ctx)
if ctx.Err() != nil {
return nil
}
c.stepBackoff()
log.Printf("[ws] connection lost (%v), reconnect in %s", err, c.backoff)
select {
case <-ctx.Done():
return nil
case <-time.After(c.backoff):
}
}
}
func (c *Client) stepBackoff() {
switch c.backoff {
case 0:
c.backoff = time.Second
case time.Second:
c.backoff = 5 * time.Second
case 5 * time.Second:
c.backoff = 15 * time.Second
default:
c.backoff = 60 * time.Second
}
}
// session 单次连接会话:握手 → 心跳 → 消息循环
func (c *Client) session(ctx context.Context) error {
wsURL := "ws" + strings.TrimPrefix(c.ServerURL, "http") + "/ws/agent"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
return err
}
defer conn.Close(websocket.StatusNormalClosure, "bye")
defer func() {
c.mu.Lock()
c.online = false
c.mu.Unlock()
}()
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
// hello 握手(Ed25519 签名)
nonce := randHex(16)
ts := time.Now().UnixMilli()
sig := c.Identity.Sign([]byte(nonce + "|" + strconv.FormatInt(ts, 10)))
hello := proto.NewEnvelope(randHex(8), proto.TypeHello, proto.DeviceHello{
DeviceID: strconv.FormatUint(c.Identity.DeviceID, 10),
Nonce: nonce,
TS: ts,
Sig: sig,
Version: "0.2.0",
})
if err = c.send(conn, hello); err != nil {
return err
}
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
_, raw, err := conn.Read(rctx)
cancel()
if err != nil {
return err
}
var ackEnv proto.Envelope
_ = json.Unmarshal(raw, &ackEnv)
if ackEnv.Type != proto.TypeHelloAck {
return fmt.Errorf("expect hello.ack, got %s", ackEnv.Type)
}
var ack proto.HelloAck
_ = json.Unmarshal(ackEnv.Payload, &ack)
c.mu.Lock()
c.backoff = 0 // 握手成功重置退避
c.online = true
c.sessionID = ack.SessionID
c.mu.Unlock()
log.Printf("[ws] online, session %s", ack.SessionID)
// 心跳(15s,服务器 90s 超时检测)
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
go c.heartbeatLoop(hbCtx)
// 消息循环
for {
typ, raw, err := conn.Read(ctx)
if err != nil {
return err
}
if typ != websocket.MessageText {
continue
}
var env proto.Envelope
if err = json.Unmarshal(raw, &env); err != nil {
continue
}
c.handle(conn, env)
}
}
// handle 分发服务端消息
func (c *Client) handle(conn *websocket.Conn, env proto.Envelope) {
switch env.Type {
case proto.TypeTaskPush:
var push proto.TaskPush
if err := json.Unmarshal(env.Payload, &push); err != nil {
return
}
c.handleTaskPush(conn, env, push)
case proto.TypeChallenge:
var ch proto.Challenge
if err := json.Unmarshal(env.Payload, &ch); err != nil {
return
}
c.handleChallenge(conn, env, ch)
case proto.TypeHeartbeatAck:
// 忽略
default:
log.Printf("[ws] ignore msg %s", env.Type)
}
}
// handleTaskPush 任务下发:幂等(已执行过直接重放结果)+ 执行 + 回传
func (c *Client) handleTaskPush(conn *websocket.Conn, env proto.Envelope, push proto.TaskPush) {
c.mu.Lock()
if res, ok := c.executed[push.TaskID]; ok {
c.mu.Unlock()
log.Printf("[ws] task %s already executed, replay result", push.TaskID)
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, proto.TaskResult{
TaskID: res.TaskID, Status: res.Status, PublishedURL: res.PublishedURL,
Receipts: res.Receipts, Error: res.Error, FinishedAt: time.Now().UnixMilli(),
}))
return
}
c.mu.Unlock()
log.Printf("[ws] TASK push %s platform=%s title=%s", push.TaskID, push.Platform, push.Title)
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
task := exec.Task{
TaskID: push.TaskID, Platform: push.Platform, AccountID: push.AccountID, AccountName: push.AccountName,
Title: push.Title, Content: push.Content, Tags: push.Tags, MaterialURLs: push.MaterialURLs,
ScheduleAt: push.ScheduleAt, Priority: push.Priority,
}
var res exec.Result
e := c.Registry.Get(push.Platform)
if e == nil {
res = exec.Result{TaskID: push.TaskID, Status: "failed", Error: "平台执行器未接入: " + push.Platform}
} else {
eCtx, cancel := context.WithTimeout(context.Background(), c.ExecTimeout)
res = e.Execute(eCtx, task)
cancel()
}
c.mu.Lock()
c.executed[push.TaskID] = res
if len(c.executed) > 1024 {
c.executed = make(map[string]exec.Result)
}
c.mu.Unlock()
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, proto.TaskResult{
TaskID: res.TaskID, Status: res.Status, PublishedURL: res.PublishedURL,
Receipts: res.Receipts, Error: res.Error, FinishedAt: time.Now().UnixMilli(),
}))
log.Printf("[ws] TASK %s done status=%s", push.TaskID, res.Status)
}
// handleChallenge 挑战:ack + 有自动解决器则异步解决(60s 超时),否则留人工
func (c *Client) handleChallenge(conn *websocket.Conn, env proto.Envelope, ch proto.Challenge) {
log.Printf("[ws] CHALLENGE %s kind=%s prompt=%s", ch.ChallengeID, ch.Kind, ch.Prompt)
c.chMu.Lock()
c.challenges[ch.ChallengeID] = ch
c.chMu.Unlock()
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeChallengeAck, proto.ChallengeAck{ChallengeID: ch.ChallengeID, Action: "accept"}))
solver := c.Registry.Solve(ch.Platform)
if solver == nil {
log.Printf("[ws] challenge %s 无自动解决器,等待人工处理", ch.ChallengeID)
return
}
// 平台生成二维码时更新挑战记录(壳经 /challenges 展示)
onQR := func(qrToken, qrURL string) {
c.chMu.Lock()
if cur, ok := c.challenges[ch.ChallengeID]; ok {
cur.QRToken = qrToken
cur.QRURL = qrURL
c.challenges[ch.ChallengeID] = cur
}
c.chMu.Unlock()
}
go func() {
sCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if solved, value := solver.SolveChallenge(sCtx, ch, onQR); solved {
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: ch.ChallengeID, Value: value}))
c.chMu.Lock()
delete(c.challenges, ch.ChallengeID)
c.chMu.Unlock()
log.Printf("[ws] CHALLENGE %s solved", ch.ChallengeID)
} else {
log.Printf("[ws] CHALLENGE %s 未解决,等待人工", ch.ChallengeID)
}
}()
}
// heartbeatLoop 心跳(15s)
func (c *Client) heartbeatLoop(ctx context.Context) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
continue
}
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeHeartbeat, proto.Heartbeat{DeviceID: strconv.FormatUint(c.Identity.DeviceID, 10), TS: time.Now().UnixMilli()}))
}
}
}
// send 统一写出口(写锁串行化,避免并发写)
func (c *Client) send(conn *websocket.Conn, msg *proto.Envelope) error {
raw, err := json.Marshal(msg)
if err != nil {
return err
}
wctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
c.writeMu.Lock()
defer c.writeMu.Unlock()
return conn.Write(wctx, websocket.MessageText, raw)
}
// Challenges 当前活跃挑战快照(WinUI 壳轮询展示)
func (c *Client) Challenges() []proto.Challenge {
c.chMu.Lock()
defer c.chMu.Unlock()
list := make([]proto.Challenge, 0, len(c.challenges))
for _, ch := range c.challenges {
list = append(list, ch)
}
return list
}
// SolveChallenge 人工完成挑战(验证码/确认),经 WSS 回传服务器
func (c *Client) SolveChallenge(challengeID, value string) error {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return errors.New("offline")
}
return c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: challengeID, Value: value}))
}
// Online 当前是否在线
func (c *Client) Online() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.online
}
// SessionID 当前会话 ID
func (c *Client) SessionID() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.sessionID
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,207 @@
package wsclient
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
"everypublish/agentcore/internal/device"
"everypublish/agentcore/internal/exec"
"everypublish/shared/proto"
)
// countingExec 计数执行器(验证幂等:只执行一次)
type countingExec struct{ n int32 }
func (c *countingExec) Platform() string { return "" }
func (c *countingExec) Execute(ctx context.Context, task exec.Task) exec.Result {
atomic.AddInt32(&c.n, 1)
return exec.Result{TaskID: task.TaskID, Status: "success", PublishedURL: "https://ex.com/" + task.TaskID}
}
func (c *countingExec) SolveChallenge(ctx context.Context, ch proto.Challenge) (bool, string) {
return true, "tok"
}
// verifyHello 校验客户端 hello 签名
func verifyHello(t *testing.T, id *device.Identity, raw []byte) {
t.Helper()
var env proto.Envelope
_ = json.Unmarshal(raw, &env)
var hello proto.DeviceHello
_ = json.Unmarshal(env.Payload, &hello)
pub, _ := base64.StdEncoding.DecodeString(id.PublicKey)
sig, _ := base64.StdEncoding.DecodeString(hello.Sig)
if !ed25519.Verify(ed25519.PublicKey(pub), []byte(hello.Nonce+"|"+strconv.FormatInt(hello.TS, 10)), sig) {
t.Errorf("hello signature invalid")
}
}
// sendAck 回 hello.ack
func sendAck(r *http.Request, conn *websocket.Conn) {
ack := proto.NewEnvelope("ack1", proto.TypeHelloAck, proto.HelloAck{ServerNonce: "n", SessionID: "s1", ServerTS: 1})
rawAck, _ := json.Marshal(ack)
_ = conn.Write(r.Context(), websocket.MessageText, rawAck)
}
// startStub 启动 stub:验签 → ack → 推任务1 → 收结果 → 再推任务1(重放)→ 收重放结果
func startStub(t *testing.T, id *device.Identity) (*httptest.Server, chan proto.TaskResult, chan struct{}) {
results := make(chan proto.TaskResult, 8)
connected := make(chan struct{}, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
_, raw, err := conn.Read(ctx)
cancel()
if err != nil {
return
}
verifyHello(t, id, raw)
sendAck(r, conn)
connected <- struct{}{}
push := proto.NewEnvelope("p1", proto.TypeTaskPush, proto.TaskPush{TaskID: "1", Platform: "douyin", Title: "t1"})
rawPush, _ := json.Marshal(push)
_ = conn.Write(r.Context(), websocket.MessageText, rawPush)
gotFirst := false
for {
_, raw, err = conn.Read(r.Context())
if err != nil {
return
}
var env2 proto.Envelope
if json.Unmarshal(raw, &env2) != nil {
continue
}
if env2.Type == proto.TypeTaskResult {
var res proto.TaskResult
_ = json.Unmarshal(env2.Payload, &res)
results <- res
if !gotFirst {
gotFirst = true
push2 := proto.NewEnvelope("p2", proto.TypeTaskPush, proto.TaskPush{TaskID: "1", Platform: "douyin", Title: "t1"})
rawPush2, _ := json.Marshal(push2)
_ = conn.Write(r.Context(), websocket.MessageText, rawPush2)
}
}
}
}))
return srv, results, connected
}
func TestClientTaskRoundTripAndIdempotent(t *testing.T) {
id, _ := device.LoadOrCreate(t.TempDir(), "dev")
id.DeviceID = 1
registry := exec.NewRegistry()
ce := &countingExec{}
registry.SetFallback(ce)
srv, results, connected := startStub(t, id)
defer srv.Close()
c := New(srv.URL, id, registry)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = c.Run(ctx) }()
select {
case <-connected:
case <-time.After(5 * time.Second):
t.Fatal("handshake timeout")
}
var r1 proto.TaskResult
select {
case r1 = <-results:
case <-time.After(5 * time.Second):
t.Fatal("first result timeout")
}
if r1.Status != "success" {
t.Fatalf("first result should be success, got %s", r1.Status)
}
var r2 proto.TaskResult
select {
case r2 = <-results:
case <-time.After(5 * time.Second):
t.Fatal("replay result timeout")
}
if r2.Status != "success" || r2.PublishedURL != r1.PublishedURL {
t.Fatalf("replay should return cached result, got %+v", r2)
}
if n := atomic.LoadInt32(&ce.n); n != 1 {
t.Fatalf("executor should run exactly once, got %d", n)
}
}
// TestClientReconnect 服务器断开后客户端应按退避自动重连
func TestClientReconnect(t *testing.T) {
id, _ := device.LoadOrCreate(t.TempDir(), "dev")
id.DeviceID = 1
registry := exec.NewRegistry()
registry.SetFallback(&countingExec{})
var connCount int32
connected := make(chan struct{}, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
_, raw, err := conn.Read(ctx)
cancel()
if err != nil {
return
}
verifyHello(t, id, raw)
sendAck(r, conn)
connected <- struct{}{}
n := atomic.AddInt32(&connCount, 1)
if n == 1 {
// 第一次连接:ack 后立刻断开,模拟服务器故障
_ = conn.Close(websocket.StatusGoingAway, "restart")
return
}
// 后续连接:正常读循环
for {
if _, _, err = conn.Read(r.Context()); err != nil {
return
}
}
}))
defer srv.Close()
c := New(srv.URL, id, registry)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = c.Run(ctx) }()
select {
case <-connected:
case <-time.After(5 * time.Second):
t.Fatal("first connect timeout")
}
select {
case <-connected:
case <-time.After(8 * time.Second):
t.Fatal("reconnect timeout")
}
if atomic.LoadInt32(&connCount) < 2 {
t.Fatal("expected second connection")
}
}
+162
View File
@@ -0,0 +1,162 @@
// agent-core 客户端核心:设备身份 + 凭据保险库 + WSS 长连接 + 平台执行器。
// 常驻模型:WinUI 壳 spawn 本进程 → 壳读 data/local.json(随机端口+token)→
// 经 localhost HTTP 控制(状态/配对/挑战)。CLI 亦可独立运行(-user/-pass 自动配对)。
package main
import (
"context"
"errors"
"flag"
"log"
"os"
"path/filepath"
"os/signal"
"sync"
"syscall"
"everypublish/agentcore/internal/config"
"everypublish/agentcore/internal/device"
"everypublish/agentcore/internal/exec"
"everypublish/agentcore/internal/exec/bilibili"
"everypublish/agentcore/internal/exec/douyin"
"everypublish/agentcore/internal/exec/fake"
"everypublish/agentcore/internal/exec/kuaishou"
"everypublish/agentcore/internal/exec/xiaohongshu"
"everypublish/agentcore/internal/localserver"
"everypublish/agentcore/internal/pairing"
"everypublish/agentcore/internal/vault"
"everypublish/agentcore/internal/wsclient"
)
func main() {
var (
server = flag.String("server", "", "服务器地址(默认取配置)")
dir = flag.String("dir", "./data", "数据目录(设备密钥/保险库/配置)")
name = flag.String("name", "", "设备名")
user = flag.String("user", "", "开发便利:自动配对登录邮箱")
password = flag.String("pass", "", "开发便利:自动配对登录密码")
simMS = flag.Int("sim", 0, "假执行器模拟耗时 ms(0 用配置值)")
token = flag.String("local-token", "", "本机控制服务 token(默认随机)")
)
flag.Parse()
cfg, err := config.Load(*dir)
if err != nil {
log.Fatalf("config: %v", err)
}
if *server != "" {
cfg.Server = *server
}
if *name != "" {
cfg.Name = *name
}
if *simMS > 0 {
cfg.SimMS = *simMS
}
_ = cfg.Save(*dir)
// 设备身份(私钥本机持久化 0600)
id, err := device.LoadOrCreate(*dir, cfg.Name)
if err != nil {
log.Fatalf("device: %v", err)
}
cfg.Name = id.Name
// 凭据保险库(平台 cookie/token 加密落盘)
v, err := vault.Open(*dir)
if err != nil {
log.Fatalf("vault: %v", err)
}
// 执行器注册:B站无浏览器投稿 + 抖音/快手/小红书 CDP(D10/D11);
// fakeAll=true 时全部走假执行器(无真实账号的安装联调)。
registry := exec.NewRegistry()
if !cfg.FakeAll {
browserRoot := filepath.Join(*dir, "browser") // 每账号独立档案
registry.Register(bilibili.New(v))
registry.Register(douyin.New(v, browserRoot, cfg.Proxy, "", false))
registry.Register(kuaishou.New(v, browserRoot, cfg.Proxy, "", false))
registry.Register(xiaohongshu.New(v, browserRoot, cfg.Proxy, "", false))
}
registry.SetFallback(fake.Executor{SimMS: cfg.SimMS})
// WSS 客户端(重连/幂等/心跳)
client := wsclient.New(cfg.Server, id, registry)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// 连接启动(配对后;未配对时等壳调 /pair)
var runOnce sync.Once
startConn := func() {
if id.DeviceID == 0 {
return
}
runOnce.Do(func() {
go func() { _ = client.Run(ctx) }()
})
}
// 配对回调(本机控制服务 /pair 触发)
pairFn := func(code, name string) error {
if id.DeviceID > 0 {
return errors.New("设备已配对")
}
did, err := pairing.PairWithCode(cfg.Server, code, name, "core", "0.2.0", id.PublicKey)
if err != nil {
return err
}
id.DeviceID = did
if name != "" {
id.Name = name
cfg.Name = name
}
if err = id.Save(*dir); err != nil {
return err
}
log.Printf("paired device %d (name %s)", id.DeviceID, id.Name)
startConn()
return nil
}
// 开发便利:CLI 自动配对
if id.DeviceID == 0 && *user != "" && *password != "" {
did, err := pairing.AutoPair(cfg.Server, *user, *password, cfg.Name, id.PublicKey)
if err != nil {
log.Fatalf("auto pair: %v", err)
}
id.DeviceID = did
if err = id.Save(*dir); err != nil {
log.Fatalf("save identity: %v", err)
}
log.Printf("auto paired device %d", id.DeviceID)
}
startConn()
// 本机控制服务(WinUI 壳入口)
localToken := *token
if localToken == "" {
localToken = localserver.RandToken()
}
ls := localserver.New(client, pairFn, localToken, *dir, func() map[string]interface{} {
return map[string]interface{}{
"server": cfg.Server,
"deviceId": id.DeviceID,
"name": id.Name,
}
})
go func() {
if err = ls.Listen(); err != nil {
log.Fatalf("local server: %v", err)
}
}()
if id.DeviceID == 0 {
log.Printf("agent-core started UNPAIRED: server=%s(请在客户端输入网页端配对码)", cfg.Server)
} else {
log.Printf("agent-core started: server=%s device=%d", cfg.Server, id.DeviceID)
}
<-ctx.Done()
log.Println("agent-core stopped")
}
+13
View File
@@ -0,0 +1,13 @@
const fs = require('fs');
const p = require('path');
const png = fs.readFileSync(p.join(__dirname, 'icons', 'app.png'));
const hdr = Buffer.alloc(6);
hdr.writeUInt16LE(1, 2);
hdr.writeUInt16LE(1, 4);
const ent = Buffer.alloc(16);
ent.writeUInt16LE(1, 4);
ent.writeUInt16LE(32, 6);
ent.writeUInt32LE(png.length, 8);
ent.writeUInt32LE(22, 12);
fs.writeFileSync(p.join(__dirname, 'icons', 'app.ico'), Buffer.concat([hdr, ent, png]));
console.log('app.ico bytes:', 22 + png.length);
+50
View File
@@ -0,0 +1,50 @@
// 生成 icons/app.png(256x256 纯蓝方块图标)
const zlib = require("zlib");
const fs = require("fs");
const path = require("path");
const W = 256, H = 256;
const row = Buffer.alloc(1 + W * 4);
row[0] = 0;
for (let x = 0; x < W; x++) {
row[1 + x * 4] = 0;
row[2 + x * 4] = 110;
row[3 + x * 4] = 255;
row[4 + x * 4] = 255;
}
const raw = Buffer.concat(Array(H).fill(row));
const crcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[n] = c >>> 0;
}
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const t = Buffer.from(type, "ascii");
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
return Buffer.concat([len, t, data, crc]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(W, 0);
ihdr.writeUInt32BE(H, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", zlib.deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
fs.mkdirSync(path.join(__dirname, "icons"), { recursive: true });
fs.writeFileSync(path.join(__dirname, "icons", "app.png"), png);
console.log("icon generated:", png.length, "bytes");
Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

+177
View File
@@ -0,0 +1,177 @@
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const http = require('http');
const isWin = process.platform === 'win32';
const isSmoke = process.argv.includes('--smoke');
const minimized = process.argv.includes('--minimized');
let win = null;
let tray = null;
let coreProc = null;
let corePort = 0;
let coreToken = '';
let localJsonTimer = null;
let quitting = false;
const dataDir = path.join(app.getPath('userData'), 'data');
const coreBin = isWin ? 'agent-core.exe' : 'agent-core';
const coreExe = app.isPackaged
? path.join(process.resourcesPath, coreBin)
: path.join(__dirname, '..', 'dist', coreBin);
function startCore() {
if (!fs.existsSync(coreExe)) {
console.error('[core] binary not found: ' + coreExe);
win?.webContents.send('core-error', '核心程序缺失:' + coreExe);
return;
}
fs.mkdirSync(dataDir, { recursive: true });
try { coreProc?.kill(); } catch (e) { /* ignore */ }
coreProc = spawn(coreExe, ['-dir', dataDir], { windowsHide: true });
coreProc.on('exit', (code) => {
coreProc = null;
win?.webContents.send('core-exit', code);
});
pollLocalJson();
}
function pollLocalJson() {
if (localJsonTimer) clearInterval(localJsonTimer);
localJsonTimer = setInterval(() => {
try {
const info = JSON.parse(fs.readFileSync(path.join(dataDir, 'local.json'), 'utf8'));
if (info.port && info.token) {
corePort = info.port;
coreToken = info.token;
clearInterval(localJsonTimer);
win?.webContents.send('core-ready');
}
} catch (e) { /* 核心尚未就绪 */ }
}, 800);
}
function coreRequest(method, p, body) {
return new Promise((resolve, reject) => {
if (!corePort || !coreToken) { reject(new Error('核心未就绪')); return; }
const data = body ? JSON.stringify(body) : null;
const req = http.request({
host: '127.0.0.1',
port: corePort,
path: p,
method: method,
headers: {
'X-Agent-Token': coreToken,
'Content-Type': 'application/json',
'Content-Length': data ? Buffer.byteLength(data) : 0,
},
}, (res) => {
let raw = '';
res.on('data', (c) => { raw += c; });
res.on('end', () => {
try { resolve(JSON.parse(raw)); } catch (e) { resolve({ raw }); }
});
});
req.on('error', reject);
req.setTimeout(8000, () => { req.destroy(new Error('核心请求超时')); });
if (data) req.write(data);
req.end();
});
}
// ---- IPC(渲染进程 → 核心/系统) ----
ipcMain.handle('core:status', () => coreRequest('GET', '/status'));
ipcMain.handle('core:pair', (e, code, name) => coreRequest('POST', '/pair', { code, name }));
ipcMain.handle('core:challenges', () => coreRequest('GET', '/challenges'));
ipcMain.handle('core:solve', (e, id, value) => coreRequest('POST', '/challenges/' + encodeURIComponent(id), { value }));
ipcMain.handle('core:get-config', () => readConfig());
ipcMain.handle('core:save-config', (e, cfg) => { writeConfig(cfg); startCore(); return true; });
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:set-autostart', (e, on) => {
app.setLoginItemSettings({ openAtLogin: !!on });
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 }; }
}
function writeConfig(cfg) {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(cfg, null, 2));
}
function createTray() {
let img = nativeImage.createFromPath(path.join(__dirname, 'icons', 'app.png'));
if (img.isEmpty()) img = nativeImage.createEmpty();
tray = new Tray(img);
tray.setToolTip('EveryPublish 客户端');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '打开主窗口', click: showWin },
{ type: 'separator' },
{ label: '退出', click: () => { quitting = true; app.quit(); } },
]));
tray.on('click', showWin);
}
function showWin() {
if (win) { win.show(); win.focus(); }
}
function createWindow() {
win = new BrowserWindow({
width: 980, height: 660,
minWidth: 780, minHeight: 540,
title: 'EveryPublish 客户端',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
win.on('close', (e) => {
if (!quitting) { e.preventDefault(); win.hide(); }
});
}
app.whenReady().then(() => {
app.setAppUserModelId('com.everypublish.client');
if (isSmoke) {
// 冒烟模式:启动 core → 等就绪 → 查状态 → 退出(无窗口)
runSmoke();
return;
}
createTray();
createWindow();
if (minimized) win?.hide();
startCore();
});
async function runSmoke() {
startCore();
const deadline = Date.now() + 30000;
while (!coreToken && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 500));
}
if (!coreToken) { console.log('SMOKE_RESULT ' + JSON.stringify({ error: 'core 未就绪' })); app.exit(1); return; }
try {
const status = await coreRequest('GET', '/status');
console.log('SMOKE_RESULT ' + JSON.stringify(status));
app.exit(0);
} catch (e) {
console.log('SMOKE_RESULT ' + JSON.stringify({ error: String(e) }));
app.exit(1);
}
}
app.on('before-quit', () => { quitting = true; try { coreProc?.kill(); } catch (e) { /* ignore */ } });
app.on('window-all-closed', () => { /* 驻留托盘不退出 */ });
app.on('activate', showWin);
+5285
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "everypublish-client",
"version": "0.2.0",
"description": "EveryPublish Windows 客户端(Electron 壳 + Go agent-core)",
"main": "main.js",
"scripts": {
"start": "electron .",
"gen-icon": "node gen-icon.js",
"dist:win": "electron-builder --win nsis --x64"
},
"devDependencies": {
"electron": "^31.7.7",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "com.everypublish.client",
"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",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}
+19
View File
@@ -0,0 +1,19 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
status: () => ipcRenderer.invoke('core:status'),
pair: (code, name) => ipcRenderer.invoke('core:pair', code, name),
challenges: () => ipcRenderer.invoke('core:challenges'),
solve: (id, value) => ipcRenderer.invoke('core:solve', id, value),
getConfig: () => ipcRenderer.invoke('core:get-config'),
saveConfig: (cfg) => ipcRenderer.invoke('core:save-config', cfg),
restart: () => ipcRenderer.invoke('core:restart'),
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
openWeb: (url) => ipcRenderer.invoke('app:open-web', url),
setAutoStart: (on) => ipcRenderer.invoke('app:set-autostart', on),
getAutoStart: () => ipcRenderer.invoke('app:get-autostart'),
quit: () => ipcRenderer.invoke('app:quit'),
onCoreReady: (cb) => ipcRenderer.on('core-ready', cb),
onCoreError: (cb) => ipcRenderer.on('core-error', cb),
onCoreExit: (cb) => ipcRenderer.on('core-exit', cb),
});
+179
View File
@@ -0,0 +1,179 @@
// EveryPublish 客户端渲染进程逻辑
const $ = (id) => document.getElementById(id);
// ---- 页签切换 ----
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
tab.classList.add('active');
$('page-' + tab.dataset.tab).classList.add('active');
});
});
// ---- 状态页 ----
let currentServer = '';
async function refreshStatus() {
try {
const s = await window.api.status();
if (s && s.server) currentServer = s.server;
$('st-server').textContent = (s && s.server) || '-';
$('st-device').textContent = s && s.deviceId > 0 ? '#' + s.deviceId + '(' + (s.name || '') + ')' : '未配对';
$('st-session').textContent = (s && s.session) || '-';
const dot = $('st-dot');
if (!s) { $('st-online').textContent = '核心未就绪'; dot.className = 'dot gray'; }
else if (s.online) { $('st-online').textContent = '在线'; dot.className = 'dot green'; }
else { $('st-online').textContent = s.deviceId > 0 ? '离线(自动重连中)' : '未配对'; dot.className = 'dot orange'; }
} catch (e) {
$('st-online').textContent = '核心未就绪';
$('st-dot').className = 'dot gray';
}
}
setInterval(refreshStatus, 3000);
refreshStatus();
$('btn-open-web').addEventListener('click', () => {
const url = currentServer || 'http://127.0.0.1:8090';
window.api.openWeb(url);
});
$('btn-open-data').addEventListener('click', () => window.api.openDataDir());
// ---- 配对页 ----
$('btn-pair').addEventListener('click', async () => {
const code = $('pair-code').value.trim();
const name = $('pair-name').value.trim();
const res = $('pair-result');
if (!code) { res.textContent = '请输入配对码'; res.className = 'msg err'; return; }
$('btn-pair').disabled = true;
res.textContent = '配对中...';
res.className = 'msg';
try {
const r = await window.api.pair(code, name);
if (r && r.code === 0) {
res.textContent = '配对成功,正在连接服务器(可到「状态」页查看)';
res.className = 'msg ok';
} else {
res.textContent = (r && r.message) || '配对失败';
res.className = 'msg err';
}
} catch (e) {
res.textContent = '配对失败:' + e;
res.className = 'msg err';
}
$('btn-pair').disabled = false;
});
// ---- 挑战页 ----
let selectedChallenge = null;
let pollTimer = null;
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 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 = '';
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;
div.addEventListener('click', () => selectChallenge(ch));
box.appendChild(div);
});
if (!list.length) {
box.innerHTML = '<p class="hint">暂无待处理挑战</p>';
}
} catch (e) { /* 忽略 */ }
}
function selectChallenge(ch) {
selectedChallenge = ch;
refreshChallenges();
const detail = $('ch-detail');
detail.classList.remove('hidden');
const qr = ch.qrUrl || ch.qrToken || '';
const img = $('ch-qr');
if (qr && (qr.startsWith('http') || qr.startsWith('file://'))) {
img.src = qr;
img.classList.remove('hidden');
} else {
img.classList.add('hidden');
}
$('ch-value').value = '';
$('ch-result').textContent = '';
}
$('btn-solve').addEventListener('click', async () => {
if (!selectedChallenge) return;
const value = $('ch-value').value.trim() || 'confirmed';
const res = $('ch-result');
try {
const r = await window.api.solve(selectedChallenge.challengeId, value);
if (r && r.code === 0) {
res.textContent = '已提交,等待服务器确认';
res.className = 'msg ok';
selectedChallenge = null;
$('ch-detail').classList.add('hidden');
refreshChallenges();
} else {
res.textContent = (r && r.message) || '提交失败';
res.className = 'msg err';
}
} catch (e) {
res.textContent = '提交失败:' + e;
res.className = 'msg err';
}
});
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(refreshChallenges, 2000);
refreshChallenges();
}
startPolling();
// ---- 设置页 ----
async function loadSettings() {
try {
const cfg = await window.api.getConfig();
$('set-server').value = (cfg && cfg.server) || '';
$('set-fake').checked = !!(cfg && cfg.fakeAll);
$('set-autostart').checked = await window.api.getAutoStart();
} catch (e) { /* 忽略 */ }
}
loadSettings();
$('btn-save').addEventListener('click', async () => {
const server = $('set-server').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 };
await window.api.saveConfig(cfg);
await window.api.setAutoStart($('set-autostart').checked);
currentServer = server;
res.textContent = '已保存,核心进程已重启';
res.className = 'msg ok';
});
$('btn-quit').addEventListener('click', () => window.api.quit());
// 核心就绪提示
if (window.api.onCoreReady) {
window.api.onCoreReady(() => {
$('st-online').textContent = '核心已就绪';
refreshStatus();
});
}
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>EveryPublish 客户端</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">EveryPublish <span class="sub">发布客户端</span></div>
<nav class="tabs">
<button class="tab active" data-tab="status">状态</button>
<button class="tab" data-tab="pair">配对</button>
<button class="tab" data-tab="challenges">挑战<span id="ch-badge" class="badge hidden">0</span></button>
<button class="tab" data-tab="settings">设置</button>
</nav>
</header>
<main id="main">
<!-- 状态页 -->
<section id="page-status" class="page active">
<div class="card">
<h2>运行状态</h2>
<div class="kv"><span>连接状态</span><b id="st-online">检测中...</b><span id="st-dot" class="dot gray"></span></div>
<div class="kv"><span>服务器</span><b id="st-server">-</b></div>
<div class="kv"><span>设备</span><b id="st-device">-</b></div>
<div class="kv"><span>会话</span><b id="st-session">-</b></div>
</div>
<div class="card actions">
<button id="btn-open-web" class="btn primary">打开网页控制台</button>
<button id="btn-open-data" class="btn">打开数据目录</button>
</div>
</section>
<!-- 配对页 -->
<section id="page-pair" class="page">
<div class="card">
<h2>设备配对</h2>
<ol class="steps">
<li>打开网页控制台(需与服务器内网互通)</li>
<li>登录后进入「设置 → Agent 设备」生成配对码</li>
<li>在下方输入配对码并点击「配对」</li>
</ol>
<label>配对码(6 位,5 分钟有效)</label>
<input id="pair-code" placeholder="例如:AB12CD" maxlength="6" />
<label>设备名(选填)</label>
<input id="pair-name" placeholder="例如:办公室电脑-01" />
<button id="btn-pair" class="btn primary">配对</button>
<p id="pair-result" class="msg"></p>
</div>
</section>
<!-- 挑战页 -->
<section id="page-challenges" class="page">
<div class="card">
<h2>二次验证挑战</h2>
<p class="hint">平台要求扫码/验证码时在此处理;超时可在网页端一键重发。</p>
<div id="ch-list" class="list"></div>
<div id="ch-detail" class="detail hidden">
<img id="ch-qr" alt="二维码" />
<label>验证码 / 确认值</label>
<input id="ch-value" placeholder="扫码后输入验证码;扫码挑战留空直接提交" />
<button id="btn-solve" class="btn primary">提交</button>
<p id="ch-result" class="msg"></p>
</div>
</div>
</section>
<!-- 设置页 -->
<section id="page-settings" class="page">
<div class="card">
<h2>设置</h2>
<label>服务器地址(内网部署时填 http://服务器IP:8090)</label>
<input id="set-server" placeholder="http://192.168.x.x:8090" />
<label class="switch-row">
<input type="checkbox" id="set-fake" />
<span>模拟执行(无真实账号的安装联调)</span>
</label>
<label class="switch-row">
<input type="checkbox" id="set-autostart" />
<span>开机自启</span>
</label>
<div class="actions">
<button id="btn-save" class="btn primary">保存并重启核心</button>
<button id="btn-quit" class="btn danger">退出客户端</button>
</div>
<p id="set-result" class="msg"></p>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #1f2329; height: 100vh; display: flex; flex-direction: column; }
.topbar { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 0 24px; display: flex; align-items: center; height: 56px; gap: 32px; }
.brand { font-size: 18px; font-weight: 700; color: #0a63f0; }
.brand .sub { font-size: 13px; font-weight: 400; color: #86909c; margin-left: 6px; }
.tabs { display: flex; gap: 8px; }
.tab { border: none; background: none; padding: 8px 16px; font-size: 14px; cursor: pointer; border-radius: 6px; color: #4e5969; }
.tab:hover { background: #f2f4f7; }
.tab.active { background: #e8f1ff; color: #0a63f0; font-weight: 600; }
.badge { display: inline-block; margin-left: 4px; background: #f53f3f; color: #fff; border-radius: 8px; font-size: 11px; padding: 0 6px; }
.hidden { display: none !important; }
#main { flex: 1; overflow-y: auto; padding: 24px; }
.page { display: none; max-width: 680px; margin: 0 auto; }
.page.active { display: block; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 16px; margin-bottom: 14px; }
.kv { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px dashed #f0f1f3; font-size: 14px; }
.kv span { color: #86909c; width: 80px; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-left: 6px; }
.dot.gray { background: #c9cdd4; } .dot.green { background: #00b42a; } .dot.orange { background: #ff7d00; } .dot.red { background: #f53f3f; }
label { display: block; font-size: 13px; color: #4e5969; margin: 12px 0 6px; }
input[type="text"], input:not([type]) { width: 100%; padding: 9px 12px; border: 1px solid #d9dde3; border-radius: 6px; font-size: 14px; outline: none; }
input:focus { border-color: #0a63f0; }
.btn { padding: 9px 20px; border: 1px solid #d9dde3; border-radius: 6px; background: #fff; cursor: pointer; font-size: 14px; }
.btn:hover { background: #f7f8fa; }
.btn.primary { background: #0a63f0; border-color: #0a63f0; color: #fff; }
.btn.primary:hover { background: #0b57d0; }
.btn.danger { color: #f53f3f; border-color: #f53f3f; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.msg { margin-top: 10px; font-size: 13px; color: #4e5969; min-height: 18px; }
.msg.ok { color: #00b42a; } .msg.err { color: #f53f3f; }
.hint { font-size: 13px; color: #86909c; margin-bottom: 10px; }
.steps { margin: 0 0 14px 18px; font-size: 13px; color: #4e5969; line-height: 1.9; }
.list .item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 8px; cursor: pointer; font-size: 13px; }
.list .item:hover { border-color: #0a63f0; }
.list .item.selected { border-color: #0a63f0; background: #f0f7ff; }
.list .item .p { color: #86909c; }
.detail { margin-top: 14px; }
.detail img { max-width: 220px; max-height: 220px; border: 1px solid #e5e7eb; border-radius: 6px; display: block; margin-bottom: 10px; }
.switch-row { display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 10px 0; cursor: pointer; }
Vendored Executable
BIN
View File
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<Application
x:Class="EveryPublish.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.UI.Xaml;
namespace EveryPublish.Client;
public partial class App : Application
{
private Window? _window;
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var startMinimized = args.Arguments.Contains("--minimized");
_window = new MainWindow();
if (!startMinimized)
{
_window.Activate();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<RootNamespace>EveryPublish.Client</RootNamespace>
<AssemblyName>EveryPublish.Client</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Platforms>x64</Platforms>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.2.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.5.240802000" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
<PackageReference Include="H.NotifyIcon.WinUI" Version="2.0.131" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
<Window
x:Class="EveryPublish.Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="EveryPublish 客户端">
<NavigationView x:Name="NavView"
PaneDisplayMode="Top"
IsSettingsVisible="False"
SelectionChanged="NavView_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem Content="状态" Tag="status">
<NavigationViewItem.Icon><SymbolIcon Symbol="Home"/></NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="配对" Tag="pair">
<NavigationViewItem.Icon><SymbolIcon Symbol="Link"/></NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="挑战" Tag="challenges">
<NavigationViewItem.Icon><SymbolIcon Symbol="Permissions"/></NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="设置" Tag="settings">
<NavigationViewItem.Icon><SymbolIcon Symbol="Setting"/></NavigationViewItem.Icon>
</NavigationViewItem>
</NavigationView.MenuItems>
<Frame x:Name="ContentFrame"/>
</NavigationView>
</Window>
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.IO;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using EveryPublish.Client.Pages;
using EveryPublish.Client.Services;
using H.NotifyIcon;
namespace EveryPublish.Client;
public sealed partial class MainWindow : Window
{
private readonly string _dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EveryPublish");
private readonly CoreClient _core;
private readonly Watchdog _watchdog;
private readonly ClientContext _context;
private TaskbarIcon? _tray;
private bool _exiting;
/// <summary>供设置页「退出客户端」调用</summary>
public static Action? ExitAction;
public MainWindow()
{
InitializeComponent();
Directory.CreateDirectory(_dataDir);
_core = new CoreClient(_dataDir);
_watchdog = new Watchdog(_core, _dataDir, OnCoreFatal);
_context = new ClientContext(_core, _dataDir, _watchdog);
ExitAction = ExitApp;
AppWindow.Closing += OnClosing;
InitTray();
NavView.SelectedItem = NavView.MenuItems[0];
Navigate("status");
_watchdog.Start();
}
private void OnCoreFatal()
{
DispatcherQueue.TryEnqueue(async () =>
{
var dialog = new ContentDialog
{
Title = "核心进程异常",
Content = "agent-core 连续重启失败,请检查安装目录或重新安装。",
CloseButtonText = "知道了",
XamlRoot = Content.XamlRoot,
};
await dialog.ShowAsync();
});
}
private void InitTray()
{
try
{
_tray = new TaskbarIcon { ToolTipText = "EveryPublish 客户端" };
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app.ico");
if (File.Exists(iconPath))
{
_tray.IconSource = new BitmapImage(new Uri(iconPath));
}
// H.NotifyIcon 2.x:左/右键命令打开主窗口(退出入口在设置页)
_tray.LeftClickCommand = new SimpleCommand(ShowWindow);
_tray.RightClickCommand = new SimpleCommand(ShowWindow);
}
catch
{
// 托盘不可用不影响主功能
}
}
private void ShowWindow()
{
AppWindow.Show();
Activate();
}
private void OnClosing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (!_exiting)
{
args.Cancel = true;
AppWindow.Hide(); // 隐藏到托盘(WinUI 3 无 Window.Hide,用 AppWindow.Hide)
}
}
private void ExitApp()
{
_exiting = true;
_tray?.Dispose();
_watchdog.Dispose();
_core.Dispose();
Close();
}
private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
if (args.SelectedItem is NavigationViewItem item && item.Tag is string tag)
{
Navigate(tag);
}
}
private void Navigate(string tag)
{
Type page = tag switch
{
"pair" => typeof(PairPage),
"challenges" => typeof(ChallengesPage),
"settings" => typeof(SettingsPage),
_ => typeof(StatusPage),
};
ContentFrame.Navigate(page, _context);
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace EveryPublish.Client.Models;
/// <summary>agent-core 写入的 local.json(随机端口 + token)</summary>
public class LocalInfo
{
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("token")]
public string Token { get; set; } = "";
}
/// <summary>核心状态(/status 返回)</summary>
public class CoreStatus
{
[JsonPropertyName("online")]
public bool Online { get; set; }
[JsonPropertyName("session")]
public string Session { get; set; } = "";
[JsonPropertyName("server")]
public string Server { get; set; } = "";
[JsonPropertyName("deviceId")]
public long DeviceId { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = "";
}
/// <summary>挑战(/challenges 返回,与 shared/proto.Challenge 一致)</summary>
public class ChallengeItem
{
[JsonPropertyName("challengeId")]
public string ChallengeId { get; set; } = "";
[JsonPropertyName("accountId")]
public string AccountId { get; set; } = "";
[JsonPropertyName("platform")]
public string Platform { get; set; } = "";
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("qrToken")]
public string QrToken { get; set; } = "";
[JsonPropertyName("prompt")]
public string Prompt { get; set; } = "";
[JsonPropertyName("expiresAt")]
public long ExpiresAt { get; set; }
}
+47
View File
@@ -0,0 +1,47 @@
# EveryPublish 客户端打包脚本(Windows 上执行)
# 产出 dist/EveryPublish-Client-<版本>-win-x64.zip:WinUI 壳 + agent-core.exe 同目录。
param(
[string]$Version = "0.2.0"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot # client/ui
$dist = Join-Path $root "..\dist"
New-Item -ItemType Directory -Force -Path $dist | Out-Null
Write-Host "[1/5] 生成图标 app.ico ..."
Add-Type -AssemblyName System.Drawing
$assets = Join-Path $root "Assets"
New-Item -ItemType Directory -Force -Path $assets | Out-Null
$bmp = New-Object System.Drawing.Bitmap 32, 32
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.Clear([System.Drawing.Color]::FromArgb(0, 110, 255))
$font = New-Object System.Drawing.Font("Arial", 16, [System.Drawing.FontStyle]::Bold)
$g.DrawString("E", $font, [System.Drawing.Brushes]::White, 7, 5)
$g.Dispose()
$icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
$fs = [System.IO.File]::Create((Join-Path $assets "app.ico"))
$icon.Save($fs)
$fs.Close()
Write-Host "[2/5] 交叉编译 agent-core.exe (Go) ..."
Push-Location (Join-Path $root "..\core")
$env:GOOS = "windows"
$env:GOARCH = "amd64"
go build -o (Join-Path $dist "agent-core.exe") .
Pop-Location
Write-Host "[3/5] dotnet publish WinUI 壳 ..."
$publishDir = Join-Path $dist "publish"
Push-Location $root
dotnet publish EveryPublish.Client.csproj -c Release -r win-x64 -p:Platform=x64 -o $publishDir
Pop-Location
Write-Host "[4/5] 组装产物 ..."
Copy-Item (Join-Path $dist "agent-core.exe") $publishDir -Force
Copy-Item -Recurse -Force $assets (Join-Path $publishDir "Assets")
Write-Host "[5/5] 压缩 ..."
$zip = Join-Path $dist "EveryPublish-Client-$Version-win-x64.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $zip -Force
Write-Host "OK: $zip" -ForegroundColor Green
+31
View File
@@ -0,0 +1,31 @@
# EveryPublish Windows 构建前环境自检(PowerShell)
Write-Host "=== EveryPublish Windows 构建自检 ===" -ForegroundColor Cyan
function Check($name, $cmd) {
try {
$out = Invoke-Expression $cmd 2>&1 | Out-String
$first = ($out.Trim() -split "`n")[0]
Write-Host "[OK] $name : $first" -ForegroundColor Green
return $true
} catch {
Write-Host "[FAIL] $name 未安装或不可用" -ForegroundColor Red
return $false
}
}
$ok = $true
$ok = (Check "dotnet SDK" "dotnet --version") -and $ok
$ok = (Check "Go 工具链" "go version") -and $ok
# 版本校验:.NET 需 8.x,Go 需 1.23+
$dotnetVer = (dotnet --version 2>$null)
if ($dotnetVer -and -not $dotnetVer.StartsWith("8.")) {
Write-Host "[WARN] dotnet 版本 $dotnetVer,建议 .NET 8 SDK" -ForegroundColor Yellow
}
if (-not $ok) {
Write-Host "请先安装依赖(见 client/ui/README.md)后重试" -ForegroundColor Red
exit 1
}
Write-Host "环境就绪:可运行 Packaging\build-installer.ps1 打包" -ForegroundColor Green
+38
View File
@@ -0,0 +1,38 @@
<Page
x:Class="EveryPublish.Client.Pages.ChallengesPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Margin="32" RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="二次验证挑战" Style="{StaticResource TitleTextBlockStyle}"/>
<TextBlock Text="平台要求扫码/验证码时在此处理;超时可在网页端一键重发。" Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="12">
<ListView x:Name="ChallengeList" SelectionChanged="ChallengeList_SelectionChanged">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Padding="8" Spacing="4">
<TextBlock FontWeight="SemiBold">
<Run Text="平台:"/><Run Text="{Binding Platform}"/>
<Run Text=" 类型:"/><Run Text="{Binding Kind}"/>
</TextBlock>
<TextBlock Text="{Binding Prompt}" TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<TextBlock x:Name="EmptyText" Text="暂无待处理挑战" Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<StackPanel x:Name="SolvePanel" Spacing="8" Visibility="Collapsed" MaxWidth="420">
<Image x:Name="QrImage" MaxHeight="220" MaxWidth="220" Visibility="Collapsed" HorizontalAlignment="Left"/>
<TextBlock x:Name="SolveHint" Text="验证码 / 确认值"/>
<TextBox x:Name="ValueBox" PlaceholderText="输入收到的验证码"/>
<Button x:Name="SolveButton" Content="提交" Style="{StaticResource AccentButtonStyle}" Click="Solve_Click" HorizontalAlignment="Left"/>
<TextBlock x:Name="SolveResult" Text="" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Grid>
</Page>
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using EveryPublish.Client.Models;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class ChallengesPage : Page
{
private ClientContext _ctx = null!;
private DispatcherTimer? _timer;
private ChallengeItem? _selected;
public ChallengesPage()
{
InitializeComponent();
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_timer.Tick += async (s, e) => await RefreshAsync();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
_ctx.Core.ReloadLocalJson();
_timer?.Start();
_ = RefreshAsync();
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
_timer?.Stop();
}
private async Task RefreshAsync()
{
var list = await _ctx.Core.GetChallengesAsync();
ChallengeList.ItemsSource = list;
EmptyText.Visibility = list.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
if (_selected == null)
{
SolvePanel.Visibility = Visibility.Collapsed;
}
}
private void ChallengeList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_selected = ChallengeList.SelectedItem as ChallengeItem;
if (_selected == null)
{
SolvePanel.Visibility = Visibility.Collapsed;
return;
}
SolvePanel.Visibility = Visibility.Visible;
SolveHint.Text = _selected.Kind switch
{
"qr" => "扫码挑战(请在对应平台 App 扫码;完成后点击「我已确认」提交)",
_ => "输入收到的验证码 / 确认值",
};
// 二维码展示:qrUrl 为 http URL 或 file:// 本地路径
var qr = !string.IsNullOrEmpty(_selected.QrUrl) ? _selected.QrUrl : _selected.QrToken;
if (!string.IsNullOrEmpty(qr) && (qr.StartsWith("http") || qr.StartsWith("file://")))
{
QrImage.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri(qr));
QrImage.Visibility = Visibility.Visible;
}
else
{
QrImage.Visibility = Visibility.Collapsed;
}
ValueBox.Text = "";
SolveResult.Text = "";
}
private async void Solve_Click(object sender, RoutedEventArgs e)
{
if (_selected == null) return;
var value = ValueBox.Text.Trim();
if (_selected.Kind == "qr" && string.IsNullOrEmpty(value))
{
value = "confirmed";
}
if (string.IsNullOrEmpty(value))
{
SolveResult.Text = "请输入验证码";
return;
}
SolveButton.IsEnabled = false;
var (ok, message) = await _ctx.Core.SolveAsync(_selected.ChallengeId, value);
SolveButton.IsEnabled = true;
SolveResult.Text = ok ? "已提交,等待服务器确认" : message;
if (ok)
{
_selected = null;
await RefreshAsync();
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Page
x:Class="EveryPublish.Client.Pages.PairPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Margin="32" Spacing="16" MaxWidth="480">
<TextBlock Text="设备配对" Style="{StaticResource TitleTextBlockStyle}"/>
<InfoBar IsOpen="True" Severity="Informational" IsClosable="False">
<InfoBar.Message>
<TextBlock Text="1. 登录网页控制台 →「设置 → Agent 设备」生成配对码;2. 在下方输入配对码完成绑定。"/>
</InfoBar.Message>
</InfoBar>
<TextBox x:Name="CodeBox" Header="配对码" PlaceholderText="6 位配对码(5 分钟内有效)"/>
<TextBox x:Name="NameBox" Header="设备名(选填)" PlaceholderText="例如:办公室电脑-01"/>
<Button x:Name="PairButton" Content="配对" Style="{StaticResource AccentButtonStyle}" Click="Pair_Click" HorizontalAlignment="Left"/>
<TextBlock x:Name="PairResult" Text="" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</Page>
+37
View File
@@ -0,0 +1,37 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class PairPage : Page
{
private ClientContext _ctx = null!;
public PairPage()
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
_ctx.Core.ReloadLocalJson();
}
private async void Pair_Click(object sender, RoutedEventArgs e)
{
var code = CodeBox.Text.Trim();
var name = NameBox.Text.Trim();
if (string.IsNullOrEmpty(code))
{
PairResult.Text = "请输入配对码";
return;
}
PairButton.IsEnabled = false;
PairResult.Text = "配对中...";
var (ok, message) = await _ctx.Core.PairAsync(code, name);
PairButton.IsEnabled = true;
PairResult.Text = ok ? "配对成功,正在连接服务器(可到「状态」页查看)" : message;
}
}
+20
View File
@@ -0,0 +1,20 @@
<Page
x:Class="EveryPublish.Client.Pages.SettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Margin="32" Spacing="16" MaxWidth="520">
<TextBlock Text="设置" Style="{StaticResource TitleTextBlockStyle}"/>
<TextBox x:Name="ServerBox" Header="服务器地址" PlaceholderText="http://127.0.0.1:8090"/>
<ToggleSwitch x:Name="AutoStartToggle" Header="开机自启" OnContent="开" OffContent="关"/>
<ToggleSwitch x:Name="FakeAllToggle" Header="模拟执行(无真实账号的安装联调)" OnContent="开" OffContent="关"/>
<TextBlock><Run Text="数据目录:" FontWeight="SemiBold"/><Run x:Name="DataDirText" Text="..."/></TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="保存并重启核心" Style="{StaticResource AccentButtonStyle}" Click="Save_Click"/>
<Button Content="打开数据目录" Click="OpenDir_Click"/>
<Button Content="退出客户端" Click="Exit_Click"/>
</StackPanel>
<TextBlock x:Name="ResultText" Text="" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</Page>
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.IO;
using System.Text.Json;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.System;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class SettingsPage : Page
{
private ClientContext _ctx = null!;
public SettingsPage()
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
DataDirText.Text = _ctx.DataDir;
ServerBox.Text = ReadServer();
AutoStartToggle.IsOn = AutoStart.IsEnabled();
FakeAllToggle.IsOn = ReadBool("fakeAll");
}
private bool ReadBool(string key)
{
try
{
var path = Path.Combine(_ctx.DataDir, "config.json");
if (!File.Exists(path)) return false;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty(key, out var v)) return v.GetBoolean();
}
catch { /* 忽略 */ }
return false;
}
private string ReadServer()
{
try
{
var path = Path.Combine(_ctx.DataDir, "config.json");
if (!File.Exists(path)) return "http://127.0.0.1:8090";
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty("server", out var s)) return s.GetString() ?? "";
}
catch { /* 忽略 */ }
return "http://127.0.0.1:8090";
}
private void WriteServer(string server)
{
var path = Path.Combine(_ctx.DataDir, "config.json");
var doc = File.Exists(path)
? JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(path))
: JsonDocument.Parse("{}").RootElement;
using var json = JsonDocument.Parse(doc.GetRawText());
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json.RootElement.GetRawText()) ?? new();
dict["server"] = JsonSerializer.SerializeToElement(server);
File.WriteAllText(path, JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true }));
}
private void Save_Click(object sender, RoutedEventArgs e)
{
var server = ServerBox.Text.Trim();
if (string.IsNullOrEmpty(server))
{
ResultText.Text = "服务器地址不能为空";
return;
}
WriteServer(server);
WriteBool("fakeAll", FakeAllToggle.IsOn);
AutoStart.SetEnabled(AutoStartToggle.IsOn);
_ctx.Watchdog.Restart();
ResultText.Text = "已保存,核心进程已重启";
}
private void WriteBool(string key, bool value)
{
var path = Path.Combine(_ctx.DataDir, "config.json");
var dict = File.Exists(path)
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(File.ReadAllText(path)) ?? new()
: new();
dict[key] = JsonSerializer.SerializeToElement(value);
File.WriteAllText(path, JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true }));
}
private async void OpenDir_Click(object sender, RoutedEventArgs e)
{
Directory.CreateDirectory(_ctx.DataDir);
await Launcher.LaunchFolderPathAsync(_ctx.DataDir);
}
private void Exit_Click(object sender, RoutedEventArgs e)
{
MainWindow.ExitAction?.Invoke();
}
}
+26
View File
@@ -0,0 +1,26 @@
<Page
x:Class="EveryPublish.Client.Pages.StatusPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Margin="32" Spacing="16">
<TextBlock Text="运行状态" Style="{StaticResource TitleTextBlockStyle}"/>
<InfoBar IsOpen="True" Severity="Informational" IsClosable="False">
<InfoBar.Message>
<TextBlock Text="客户端后台常驻运行;关闭窗口将最小化到托盘,不会退出。"/>
</InfoBar.Message>
</InfoBar>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="连接状态:" VerticalAlignment="Center"/>
<Ellipse x:Name="OnlineDot" Width="10" Height="10" Fill="Gray" VerticalAlignment="Center"/>
<TextBlock x:Name="OnlineText" Text="..." VerticalAlignment="Center" FontWeight="SemiBold"/>
</StackPanel>
<TextBlock><Run Text="服务器:" FontWeight="SemiBold"/><Run x:Name="ServerText" Text="..."/></TextBlock>
<TextBlock><Run Text="设备:" FontWeight="SemiBold"/><Run x:Name="DeviceText" Text="..."/></TextBlock>
<TextBlock><Run Text="会话:" FontWeight="SemiBold"/><Run x:Name="SessionText" Text="..."/></TextBlock>
</StackPanel>
<Button x:Name="OpenWebButton" Content="打开网页控制台" Click="OpenWeb_Click" HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</Page>
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.System;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class StatusPage : Page
{
private ClientContext _ctx = null!;
private DispatcherTimer? _timer;
public StatusPage()
{
InitializeComponent();
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
_timer.Tick += async (s, e) => await RefreshAsync();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
_ctx.Core.ReloadLocalJson();
_timer?.Start();
_ = RefreshAsync();
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
_timer?.Stop();
}
private async Task RefreshAsync()
{
_ctx.Core.ReloadLocalJson();
var status = await _ctx.Core.GetStatusAsync();
if (status == null)
{
OnlineText.Text = "核心进程未就绪";
OnlineDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray);
return;
}
ServerText.Text = status.Server;
DeviceText.Text = status.DeviceId > 0 ? "#" + status.DeviceId + "(" + status.Name + ")" : "未配对";
SessionText.Text = status.Session;
if (status.Online)
{
OnlineText.Text = "在线";
OnlineDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.LimeGreen);
}
else
{
OnlineText.Text = status.DeviceId > 0 ? "离线(自动重连中)" : "未配对";
OnlineDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Orange);
}
}
private async void OpenWeb_Click(object sender, RoutedEventArgs e)
{
var status = await _ctx.Core.GetStatusAsync();
var url = status?.Server ?? "http://127.0.0.1:8090";
await Launcher.LaunchUriAsync(new Uri(url));
}
}
+30
View File
@@ -0,0 +1,30 @@
# EveryPublish Windows 客户端(WinUI 3 壳)
## 结构
- 本目录:WinUI 3(C#/.NET 8)外壳——托盘、配对引导、挑战窗口、状态页、设置,
spawn 并看门狗守护 `agent-core.exe`(../core,Go)。
- 壳与 core 的桥:`data/local.json`(core 启动写随机端口 + token),壳经 127.0.0.1 HTTP 控制。
## Windows 构建(需 Windows 10/11 + .NET 8 SDK)
1. 安装 .NET 8 SDK(https://dotnet.microsoft.com/download/dotnet/8.0)与 Go 1.23+。
2. 首次构建前还原依赖:`dotnet restore EveryPublish.Client.csproj`(需网络)。
3. 一键打包:`powershell -ExecutionPolicy Bypass -File Packaging\build-installer.ps1`。
产出 `../dist/EveryPublish-Client-0.2.0-win-x64.zip`。
4. 解压运行 `EveryPublish.Client.exe`(无需安装 .NET 运行时与 WindowsAppSDK,已自包含)。
## 手动验证清单(D9 验收)
1. 首次启动:窗口显示「未配对」;托盘图标出现。
2. 网页端生成配对码 → 客户端「配对」页输入 → 配对成功 → 状态页「在线」。
3. 网页端新建账号并绑定 → 客户端「挑战」页出现挑战 → 提交验证码 → 网页端账号变「已绑定」。
4. 网页端新建任务 → 审核通过 → 客户端状态页保持在线 → 网页端任务变「发布成功」。
5. 关闭窗口 → 最小化到托盘(进程仍运行);托盘「退出」才真正退出。
6. 设置页开启「开机自启」→ 注销/重启后客户端自动运行。
## 已知注意
- H.NotifyIcon.WinUI 锁定 2.0.131(NuGet 已验证存在):托盘左/右键打开主窗口(2.x 无 WPF 式 ContextMenu),退出入口在「设置 → 退出客户端」。
- 构建前可先跑 `Packaging\precheck.ps1` 自检 .NET 8 SDK 与 Go 工具链。
- 若出现 WinUI 编译错误,多为 XAML 控件 API 细节,按报错微调即可(工程结构已就位)。
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.Win32;
namespace EveryPublish.Client.Services;
/// <summary>开机自启(HKCU Run)</summary>
public static class AutoStart
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "EveryPublishClient";
public static bool IsEnabled()
{
using var key = Registry.CurrentUser.OpenSubKey(RunKey);
return key?.GetValue(ValueName) != null;
}
public static void SetEnabled(bool enabled)
{
using var key = Registry.CurrentUser.CreateSubKey(RunKey);
if (key == null) return;
if (enabled)
{
var exe = Environment.ProcessPath ?? "";
key.SetValue(ValueName, "\"" + exe + "\" --minimized");
}
else
{
key.DeleteValue(ValueName, false);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace EveryPublish.Client.Services;
/// <summary>页面共享上下文(导航参数)</summary>
public class ClientContext
{
public CoreClient Core { get; }
public string DataDir { get; }
public Watchdog Watchdog { get; }
public ClientContext(CoreClient core, string dataDir, Watchdog watchdog)
{
Core = core;
DataDir = dataDir;
Watchdog = watchdog;
}
}
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using EveryPublish.Client.Models;
namespace EveryPublish.Client.Services;
/// <summary>agent-core 本机控制客户端(读 local.json → 127.0.0.1 + token 鉴权)</summary>
public class CoreClient : IDisposable
{
private readonly HttpClient _http;
private readonly string _dataDir;
private string _base = "";
private string _token = "";
public CoreClient(string dataDir)
{
_dataDir = dataDir;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
ReloadLocalJson();
}
/// <summary>重新读取 data/local.json(端口/token,core 每次启动随机)</summary>
public void ReloadLocalJson()
{
try
{
var path = Path.Combine(_dataDir, "local.json");
if (!File.Exists(path)) return;
var info = JsonSerializer.Deserialize<LocalInfo>(File.ReadAllText(path));
if (info == null || info.Port <= 0 || string.IsNullOrEmpty(info.Token)) return;
_base = "http://127.0.0.1:" + info.Port;
_token = info.Token;
}
catch
{
// core 尚未写入 local.json,稍后重试
}
}
private HttpRequestMessage NewRequest(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, _base + path);
req.Headers.Add("X-Agent-Token", _token);
return req;
}
public async Task<CoreStatus?> GetStatusAsync()
{
try
{
using var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, "/status"));
if (!resp.IsSuccessStatusCode) return null;
return await resp.Content.ReadFromJsonAsync<CoreStatus>();
}
catch
{
return null;
}
}
public async Task<List<ChallengeItem>> GetChallengesAsync()
{
try
{
using var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, "/challenges"));
if (!resp.IsSuccessStatusCode) return new List<ChallengeItem>();
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
if (doc.TryGetProperty("list", out var list))
{
return list.Deserialize<List<ChallengeItem>>() ?? new List<ChallengeItem>();
}
return new List<ChallengeItem>();
}
catch
{
return new List<ChallengeItem>();
}
}
public Task<(bool Ok, string Message)> PairAsync(string code, string name)
{
return PostAsync("/pair", new { code, name });
}
public Task<(bool Ok, string Message)> SolveAsync(string challengeId, string value)
{
return PostAsync("/challenges/" + challengeId, new { value });
}
private async Task<(bool Ok, string Message)> PostAsync(string path, object body)
{
try
{
using var req = NewRequest(HttpMethod.Post, path);
req.Content = JsonContent.Create(body);
using var resp = await _http.SendAsync(req);
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var ok = doc.TryGetProperty("code", out var c) && c.GetInt32() == 0;
var msg = doc.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
return (ok, msg);
}
catch (Exception ex)
{
return (false, "无法连接客户端核心:" + ex.Message);
}
}
public void Dispose()
{
_http.Dispose();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System;
using System.Windows.Input;
namespace EveryPublish.Client.Services;
/// <summary>极简 ICommand 实现(托盘命令用,免 CommunityToolkit 依赖)</summary>
public class SimpleCommand : ICommand
{
private readonly Action _action;
public SimpleCommand(Action action)
{
_action = action;
}
public event EventHandler? CanExecuteChanged { add { } remove { } }
public bool CanExecute(object? parameter) => true;
public void Execute(object? parameter) => _action();
}
+91
View File
@@ -0,0 +1,91 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
namespace EveryPublish.Client.Services;
/// <summary>看门狗:spawn agent-core 子进程,5s 心跳检查,失败自动拉起,3 次失败告警</summary>
public class Watchdog : IDisposable
{
private readonly CoreClient _core;
private readonly string _dataDir;
private readonly Action _onFatal;
private Process? _proc;
private Timer? _timer;
private int _failCount;
public Watchdog(CoreClient core, string dataDir, Action onFatal)
{
_core = core;
_dataDir = dataDir;
_onFatal = onFatal;
}
public void Start()
{
Spawn();
_timer = new Timer(_ => Check(), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
public void Restart()
{
try { _proc?.Kill(); } catch { /* 忽略 */ }
Spawn();
}
private void Spawn()
{
var exe = Path.Combine(AppContext.BaseDirectory, "agent-core.exe");
if (!File.Exists(exe))
{
_onFatal();
return;
}
var psi = new ProcessStartInfo
{
FileName = exe,
Arguments = "-dir \"" + _dataDir + "\"",
WorkingDirectory = Path.GetDirectoryName(exe) ?? AppContext.BaseDirectory,
CreateNoWindow = true,
UseShellExecute = false,
};
_proc = Process.Start(psi);
}
private void Check()
{
_core.ReloadLocalJson();
var healthy = false;
if (_proc != null && !_proc.HasExited)
{
try
{
var status = _core.GetStatusAsync().GetAwaiter().GetResult();
healthy = status != null;
}
catch
{
healthy = false;
}
}
if (healthy)
{
_failCount = 0;
return;
}
_failCount++;
if (_failCount >= 3)
{
_onFatal();
return;
}
Spawn();
}
public void Dispose()
{
_timer?.Dispose();
try { _proc?.Kill(); } catch { /* 忽略 */ }
}
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="EveryPublish.Client.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>