client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较
This commit is contained in:
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 |
@@ -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);
|
||||||
Generated
+5285
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
BIN
Binary file not shown.
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 细节,按报错微调即可(工程结构已就位)。
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user