Files
huya-go/internal/gui/app.go
T
2026-07-06 18:08:23 +08:00

519 lines
13 KiB
Go

package gui
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"huya-go/internal/huya"
"huya-go/internal/sniper"
)
const (
eventLog = "gui:log"
eventMonitorResult = "gui:monitor-result"
eventMonitorState = "gui:monitor-state"
maxLogEntries = 500
)
type App struct {
ctx context.Context
logMu sync.Mutex
logs []LogEntry
runMu sync.Mutex
monitorCancel context.CancelFunc
monitorRunning bool
}
type Config struct {
Transport string `json:"transport"`
WSSHost string `json:"wssHost"`
SID int32 `json:"sid"`
PID int32 `json:"pid"`
Name string `json:"name"`
CookieText string `json:"cookieText"`
Loops int `json:"loops"`
DelayMS int `json:"delayMs"`
Concurrency int `json:"concurrency"`
StopAfterSuccess bool `json:"stopAfterSuccess"`
TimeoutSec int `json:"timeoutSec"`
StartAt string `json:"startAt"`
}
type AccountPreview struct {
Line int `json:"line"`
UID int64 `json:"uid"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}
type LogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
}
type PrizeListResult struct {
UID int64 `json:"uid"`
Status int32 `json:"status"`
Msg string `json:"msg"`
Prizes []huya.PrizeInfo `json:"prizes"`
Raw *huya.GetActPrizeListResp `json:"raw,omitempty"`
}
type ScoreResult struct {
UID int64 `json:"uid"`
Score int64 `json:"score"`
Resp *huya.GetUserScoreResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
}
type RecordResult struct {
UID int64 `json:"uid"`
Records []huya.UserPrizeInfo `json:"records"`
Resp *huya.GetUserPrizeRecordsResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
}
type ExchangeResult struct {
UID int64 `json:"uid"`
Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
}
type MonitorState struct {
Running bool `json:"running"`
Message string `json:"message"`
}
type cookieLine struct {
lineNo int
text string
}
func NewApp() *App {
return &App{}
}
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.addLog("info", "GUI 已启动")
}
func (a *App) Shutdown(ctx context.Context) {
a.StopMonitor()
}
func (a *App) DefaultConfig() Config {
return Config{
Transport: "wss",
WSSHost: huya.DefaultWSSHost,
SID: 2203,
PID: 12889,
Name: "小黄鸭",
Loops: 20,
DelayMS: 120,
Concurrency: 5,
StopAfterSuccess: true,
TimeoutSec: 15,
}
}
func (a *App) PreviewAccounts(cookieText string) []AccountPreview {
previews := make([]AccountPreview, 0)
for _, line := range splitCookieLines(cookieText) {
uid := huya.ResolveUID(line.text)
item := AccountPreview{Line: line.lineNo, UID: uid, Valid: uid > 0}
if uid <= 0 {
item.Error = "无法解析 yyuid/udb_uid"
}
previews = append(previews, item)
}
return previews
}
func (a *App) GetLogs() []LogEntry {
a.logMu.Lock()
defer a.logMu.Unlock()
return append([]LogEntry(nil), a.logs...)
}
func (a *App) IsRunning() bool {
a.runMu.Lock()
defer a.runMu.Unlock()
return a.monitorRunning
}
func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
if err != nil {
return PrizeListResult{}, err
}
if cfg.SID <= 0 {
return PrizeListResult{}, errors.New("SID 不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return PrizeListResult{}, err
}
defer closeClient()
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
if err != nil {
a.addLog("error", "查询奖品失败: %v", err)
return PrizeListResult{}, err
}
a.addLog("info", "查询奖品完成: uid=%d sid=%d count=%d", accounts[0].UID, cfg.SID, len(resp.Prizes))
return PrizeListResult{
UID: accounts[0].UID,
Status: resp.Status,
Msg: resp.Msg,
Prizes: resp.Prizes,
Raw: resp,
}, nil
}
func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
if err != nil {
return nil, err
}
if cfg.SID <= 0 {
return nil, errors.New("SID 不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
results := make([]ScoreResult, len(accounts))
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
item := ScoreResult{UID: account.UID, Resp: resp, Error: errorString(err)}
if resp != nil {
item.Score = resp.AvailableScore()
}
results[i] = item
})
a.addLog("info", "查询积分完成: accounts=%d", len(accounts))
return results, nil
}
func (a *App) GetRecords(cfg Config) ([]RecordResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
if err != nil {
return nil, err
}
if cfg.SID <= 0 {
return nil, errors.New("SID 不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
results := make([]RecordResult, len(accounts))
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
item := RecordResult{UID: account.UID, Resp: resp, Error: errorString(err)}
if resp != nil {
item.Records = resp.Records
}
results[i] = item
})
a.addLog("info", "查询兑换记录完成: accounts=%d", len(accounts))
return results, nil
}
func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
if err != nil {
return nil, err
}
if cfg.SID <= 0 || cfg.PID <= 0 {
return nil, errors.New("SID 和 PID 都不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
results := make([]ExchangeResult, len(accounts))
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, cfg.SID, cfg.PID)
results[i] = ExchangeResult{UID: account.UID, Resp: resp, Error: errorString(err)}
})
a.addLog("info", "直接兑换完成: sid=%d pid=%d accounts=%d", cfg.SID, cfg.PID, len(accounts))
return results, nil
}
func (a *App) StartMonitor(cfg Config) error {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
if err != nil {
return err
}
if cfg.SID <= 0 {
return errors.New("SID 不能为空")
}
if cfg.PID <= 0 && strings.TrimSpace(cfg.Name) == "" {
return errors.New("PID 和名称至少填写一个")
}
startAt, err := parseStartTime(cfg.StartAt)
if err != nil {
return err
}
a.runMu.Lock()
if a.monitorRunning {
a.runMu.Unlock()
return errors.New("监控已在运行")
}
ctx, cancel := context.WithCancel(context.Background())
a.monitorCancel = cancel
a.monitorRunning = true
a.runMu.Unlock()
client, closeClient, err := a.newClient(cfg)
if err != nil {
cancel()
a.finishMonitor("启动失败")
return err
}
a.emitState(true, "运行中")
a.addLog("info", "启动监控: sid=%d pid=%d name=%s accounts=%d", cfg.SID, cfg.PID, cfg.Name, len(accounts))
go func() {
defer closeClient()
defer a.finishMonitor("已停止")
if !startAt.IsZero() {
a.addLog("info", "等待定时开始: %s", startAt.Format("2006-01-02 15:04:05"))
if err := waitUntil(ctx, startAt); err != nil {
a.addLog("info", "定时等待已取消")
return
}
}
runner := sniper.Runner{Client: client}
err := runner.Run(ctx, accounts, sniper.Target{
PID: cfg.PID,
Name: strings.TrimSpace(cfg.Name),
}, sniper.Config{
SID: cfg.SID,
Loops: cfg.Loops,
Delay: time.Duration(cfg.DelayMS) * time.Millisecond,
Concurrency: cfg.Concurrency,
StopAfterSuccess: cfg.StopAfterSuccess,
}, func(result sniper.Result) {
a.emit(eventMonitorResult, result)
a.addLog("info", "监控结果: loop=%d uid=%d action=%s error=%s", result.Loop, result.UID, result.Action, result.Error)
})
if err != nil && !errors.Is(err, context.Canceled) {
a.addLog("error", "监控结束异常: %v", err)
}
}()
return nil
}
func (a *App) StopMonitor() bool {
a.runMu.Lock()
cancel := a.monitorCancel
a.runMu.Unlock()
if cancel == nil {
return false
}
cancel()
return true
}
func (a *App) newClient(cfg Config) (huya.ActivityClient, func(), error) {
return huya.NewActivityClient(cfg.Transport, cfg.WSSHost, time.Duration(cfg.TimeoutSec)*time.Second, func(msg string) {
a.addLog("debug", "%s", msg)
})
}
func (a *App) runConcurrent(accounts []sniper.Account, concurrency int, fn func(i int, account sniper.Account)) {
if concurrency <= 0 {
concurrency = 1
}
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i, account := range accounts {
i, account := i, account
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fn(i, account)
}()
}
wg.Wait()
}
func (a *App) finishMonitor(message string) {
a.runMu.Lock()
a.monitorCancel = nil
a.monitorRunning = false
a.runMu.Unlock()
a.emitState(false, message)
}
func (a *App) addLog(level, format string, args ...any) {
entry := LogEntry{
Time: time.Now().Format("15:04:05.000"),
Level: level,
Message: fmt.Sprintf(format, args...),
}
a.logMu.Lock()
a.logs = append(a.logs, entry)
if len(a.logs) > maxLogEntries {
a.logs = append([]LogEntry(nil), a.logs[len(a.logs)-maxLogEntries:]...)
}
a.logMu.Unlock()
a.emit(eventLog, entry)
}
func (a *App) emitState(running bool, message string) {
a.emit(eventMonitorState, MonitorState{Running: running, Message: message})
}
func (a *App) emit(event string, data any) {
if a.ctx == nil {
return
}
runtime.EventsEmit(a.ctx, event, data)
}
func normalizeConfig(cfg Config) Config {
if strings.TrimSpace(cfg.Transport) == "" {
cfg.Transport = "wss"
}
if strings.TrimSpace(cfg.WSSHost) == "" {
cfg.WSSHost = huya.DefaultWSSHost
}
if cfg.Loops <= 0 {
cfg.Loops = 1
}
if cfg.DelayMS <= 0 {
cfg.DelayMS = 150
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
if cfg.TimeoutSec <= 0 {
cfg.TimeoutSec = 15
}
cfg.Name = strings.TrimSpace(cfg.Name)
cfg.CookieText = strings.TrimSpace(cfg.CookieText)
cfg.StartAt = strings.TrimSpace(cfg.StartAt)
return cfg
}
func parseAccounts(cookieText string) ([]sniper.Account, error) {
lines := splitCookieLines(cookieText)
if len(lines) == 0 {
return nil, errors.New("Cookie 不能为空")
}
accounts := make([]sniper.Account, 0, len(lines))
for _, cookie := range lines {
uid := huya.ResolveUID(cookie.text)
if uid <= 0 {
return nil, fmt.Errorf("第 %d 行 Cookie 无法解析 yyuid/udb_uid", cookie.lineNo)
}
accounts = append(accounts, sniper.Account{UID: uid, Cookie: cookie.text})
}
return accounts, nil
}
func splitCookieLines(cookieText string) []cookieLine {
lines := strings.Split(cookieText, "\n")
out := make([]cookieLine, 0, len(lines))
for i, line := range lines {
item := strings.TrimSpace(line)
if item == "" || strings.HasPrefix(item, "#") {
continue
}
out = append(out, cookieLine{lineNo: i + 1, text: item})
}
return out
}
func requestContext(timeoutSec int) (context.Context, context.CancelFunc) {
if timeoutSec <= 0 {
timeoutSec = 15
}
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
}
func parseStartTime(text string) (time.Time, error) {
text = strings.TrimSpace(text)
if text == "" {
return time.Time{}, nil
}
if ts, err := strconv.ParseInt(text, 10, 64); err == nil {
return time.Unix(ts, 0), nil
}
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} {
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析开始时间: %s", text)
}
func waitUntil(ctx context.Context, when time.Time) error {
for {
remaining := time.Until(when)
if remaining <= 0 {
return nil
}
timer := time.NewTimer(minDuration(remaining, 200*time.Millisecond))
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return ctx.Err()
}
}
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}