Files
hockey_new/agent/main_windows.go
2026-08-19 15:08:39 +03:00

1026 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build windows
package main
import (
_ "embed"
"encoding/binary"
"fmt"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"syscall"
"unsafe"
)
const (
WS_OVERLAPPED = 0x00000000
WS_CAPTION = 0x00C00000
WS_SYSMENU = 0x00080000
WS_MINIMIZEBOX = 0x00020000
WS_VISIBLE = 0x10000000
WS_CHILD = 0x40000000
WS_TABSTOP = 0x00010000
ES_AUTOHSCROLL = 0x0080
ES_MULTILINE = 0x0004
ES_AUTOVSCROLL = 0x0040
ES_READONLY = 0x0800
WS_VSCROLL = 0x00200000
BS_PUSHBUTTON = 0x00000000
BS_AUTOCHECKBOX = 0x00000003
SS_LEFT = 0
SS_CENTER = 1
SS_RIGHT = 2
SW_SHOW = 5
SW_HIDE = 0
SW_RESTORE = 9
WM_DESTROY = 0x0002
WM_CLOSE = 0x0010
WM_SETICON = 0x0080
WM_COMMAND = 0x0111
WM_TIMER = 0x0113
WM_PAINT = 0x000F
WM_CTLCOLORSTATIC = 0x0138
WM_CTLCOLOREDIT = 0x0133
WM_SETFONT = 0x0030
WM_APP = 0x8000
BN_CLICKED = 0
BM_GETCHECK = 0x00F0
BM_SETCHECK = 0x00F1
BST_CHECKED = 1
COLOR_WINDOW = 5
TRANSPARENT = 1
NIM_ADD = 0
NIM_MODIFY = 1
NIM_DELETE = 2
NIF_MESSAGE = 1
NIF_ICON = 2
NIF_TIP = 4
NIF_INFO = 0x10
NIIF_INFO = 1
NIIF_WARNING = 2
NIIF_ERROR = 3
WM_LBUTTONDBLCLK = 0x0203
WM_RBUTTONUP = 0x0205
MF_STRING = 0
TPM_RIGHTBUTTON = 0x0002
IDI_APPLICATION = 32512
IDC_ARROW = 32512
ICON_SMALL = 0
ICON_BIG = 1
LR_DEFAULTCOLOR = 0
FW_NORMAL = 400
FW_SEMIBOLD = 600
FW_BOLD = 700
DEFAULT_CHARSET = 1
OUT_DEFAULT_PRECIS = 0
CLIP_DEFAULT_PRECIS = 0
CLEARTYPE_QUALITY = 5
DEFAULT_PITCH = 0
NULL_BRUSH = 5
MB_OK = 0
MB_ICONINFORMATION = 0x40
MB_ICONERROR = 0x10
CF_UNICODETEXT = 13
GMEM_MOVEABLE = 0x0002
REG_SZ = 1
)
const (
idDiag = 1001
idSettings = 1002
idExit = 1003
idSaveSettings = 1101
idCancelSettings = 1102
idCopyLog = 1201
idServerValue = 2001
idVmixValue = 2002
idDeviceValue = 2003
idAccountValue = 2004
idMatchValue = 2005
idMappingValue = 2006
idMainStatus = 2007
idLastCommand = 2008
idDeviceID = 2009
trayMessage = WM_APP + 21
settingsDoneMsg = WM_APP + 22
)
type POINT struct{ X, Y int32 }
type RECT struct{ Left, Top, Right, Bottom int32 }
type MSG struct {
HWnd uintptr
Message uint32
WParam, LParam uintptr
Time uint32
Pt POINT
LPrivate uint32
}
type PAINTSTRUCT struct {
Hdc uintptr
FErase int32
RcPaint RECT
FRestore, FIncUpdate int32
RgbReserved [32]byte
}
type WNDCLASSEX struct {
CbSize uint32
Style uint32
LpfnWndProc uintptr
CbClsExtra, CbWndExtra int32
HInstance, HIcon, HCursor, HbrBackground uintptr
LpszMenuName, LpszClassName *uint16
HIconSm uintptr
}
type NOTIFYICONDATA struct {
CbSize uint32
HWnd uintptr
UID, UFlags, UCallbackMessage uint32
HIcon uintptr
SzTip [128]uint16
DwState, DwStateMask uint32
SzInfo [256]uint16
UTimeoutOrVersion uint32
SzInfoTitle [64]uint16
DwInfoFlags uint32
GuidItem [16]byte
HBalloonIcon uintptr
}
type WinApp struct {
agent *Agent
hwnd uintptr
settingsHwnd uintptr
diagHwnd uintptr
controls map[int]uintptr
settingsControls map[int]uintptr
diagEdit uintptr
font, fontSmall, fontBold, fontTitle, fontDiag uintptr
bgBrush, cardBrush, editBrush uintptr
tray NOTIFYICONDATA
iconBig, iconSmall uintptr
lastTrayStatus string
lastDiagLog string
lastTexts map[int]string
configCache Config
stateCache AppState
uiMu sync.Mutex
settingsResult string
settingsResultError bool
reallyExit bool
}
var app *WinApp
// smith.ico is embedded directly into agent.exe.
//
//go:embed smith.ico
var smithIcon []byte
func iconFromICO(data []byte, desired int) uintptr {
if len(data) < 6 || binary.LittleEndian.Uint16(data[0:2]) != 0 || binary.LittleEndian.Uint16(data[2:4]) != 1 {
return 0
}
count := int(binary.LittleEndian.Uint16(data[4:6]))
if count <= 0 || len(data) < 6+count*16 {
return 0
}
bestOff, bestSize, bestScore := 0, 0, int(^uint(0)>>1)
for i := 0; i < count; i++ {
e := 6 + i*16
w := int(data[e])
h := int(data[e+1])
if w == 0 {
w = 256
}
if h == 0 {
h = 256
}
sz := int(binary.LittleEndian.Uint32(data[e+8 : e+12]))
off := int(binary.LittleEndian.Uint32(data[e+12 : e+16]))
if sz <= 0 || off < 0 || off+sz > len(data) {
continue
}
score := absInt(w-desired) + absInt(h-desired)
if score < bestScore {
bestScore = score
bestOff = off
bestSize = sz
}
}
if bestSize == 0 {
return 0
}
h, _, _ := pCreateIconFromResourceEx.Call(uintptr(unsafe.Pointer(&data[bestOff])), uintptr(bestSize), 1, 0x00030000, uintptr(desired), uintptr(desired), LR_DEFAULTCOLOR)
return h
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
shell32 = syscall.NewLazyDLL("shell32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
pRegisterClassEx = user32.NewProc("RegisterClassExW")
pCreateWindowEx = user32.NewProc("CreateWindowExW")
pDefWindowProc = user32.NewProc("DefWindowProcW")
pShowWindow = user32.NewProc("ShowWindow")
pUpdateWindow = user32.NewProc("UpdateWindow")
pGetMessage = user32.NewProc("GetMessageW")
pTranslateMessage = user32.NewProc("TranslateMessage")
pDispatchMessage = user32.NewProc("DispatchMessageW")
pPostQuitMessage = user32.NewProc("PostQuitMessage")
pDestroyWindow = user32.NewProc("DestroyWindow")
pSetTimer = user32.NewProc("SetTimer")
pSetWindowText = user32.NewProc("SetWindowTextW")
pSendMessage = user32.NewProc("SendMessageW")
pLoadIcon = user32.NewProc("LoadIconW")
pLoadCursor = user32.NewProc("LoadCursorW")
pCreateIconFromResourceEx = user32.NewProc("CreateIconFromResourceEx")
pDestroyIcon = user32.NewProc("DestroyIcon")
pAdjustWindowRectEx = user32.NewProc("AdjustWindowRectEx")
pBeginPaint = user32.NewProc("BeginPaint")
pEndPaint = user32.NewProc("EndPaint")
pInvalidateRect = user32.NewProc("InvalidateRect")
pMessageBox = user32.NewProc("MessageBoxW")
pGetWindowTextLength = user32.NewProc("GetWindowTextLengthW")
pGetWindowText = user32.NewProc("GetWindowTextW")
pSetForegroundWindow = user32.NewProc("SetForegroundWindow")
pGetCursorPos = user32.NewProc("GetCursorPos")
pIsWindowVisible = user32.NewProc("IsWindowVisible")
pPostMessage = user32.NewProc("PostMessageW")
pCreatePopupMenu = user32.NewProc("CreatePopupMenu")
pAppendMenu = user32.NewProc("AppendMenuW")
pTrackPopupMenu = user32.NewProc("TrackPopupMenu")
pDestroyMenu = user32.NewProc("DestroyMenu")
pOpenClipboard = user32.NewProc("OpenClipboard")
pEmptyClipboard = user32.NewProc("EmptyClipboard")
pSetClipboardData = user32.NewProc("SetClipboardData")
pCloseClipboard = user32.NewProc("CloseClipboard")
pSetBkMode = gdi32.NewProc("SetBkMode")
pSetBkColor = gdi32.NewProc("SetBkColor")
pSetTextColor = gdi32.NewProc("SetTextColor")
pCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
pCreateFont = gdi32.NewProc("CreateFontW")
pGetStockObject = gdi32.NewProc("GetStockObject")
pSelectObject = gdi32.NewProc("SelectObject")
pCreatePen = gdi32.NewProc("CreatePen")
pRoundRect = gdi32.NewProc("RoundRect")
pDeleteObject = gdi32.NewProc("DeleteObject")
pShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW")
pGetModuleHandle = kernel32.NewProc("GetModuleHandleW")
pGlobalAlloc = kernel32.NewProc("GlobalAlloc")
pGlobalLock = kernel32.NewProc("GlobalLock")
pGlobalUnlock = kernel32.NewProc("GlobalUnlock")
pRtlMoveMemory = kernel32.NewProc("RtlMoveMemory")
pSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
)
func utf16(s string) *uint16 { p, _ := syscall.UTF16PtrFromString(s); return p }
func rgb(r, g, b byte) uintptr { return uintptr(uint32(r) | uint32(g)<<8 | uint32(b)<<16) }
func loword(v uintptr) int { return int(v & 0xffff) }
func hiword(v uintptr) int { return int((v >> 16) & 0xffff) }
func main() {
// Win32 windows and their message queue belong to the OS thread that created
// them. A Go goroutine may otherwise migrate between OS threads, which can
// manifest as an intermittent "Not responding" GUI. Keep the complete GUI
// lifetime pinned to one Windows thread.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
pSetProcessDPIAware.Call()
path, err := configPath()
if err != nil {
return
}
cfg, err := loadConfig(path)
if err != nil {
return
}
agent := NewAgent(path, cfg)
app = &WinApp{
agent: agent,
controls: map[int]uintptr{},
settingsControls: map[int]uintptr{},
lastTexts: map[int]string{},
configCache: cfg,
}
if err := app.init(); err != nil {
return
}
// Make the GUI visible before starting any network work. This keeps first
// launch deterministic even when DNS, the server, or vMix are unavailable.
if !cfg.StartMinimized {
pShowWindow.Call(app.hwnd, SW_SHOW)
pUpdateWindow.Call(app.hwnd)
}
app.addTray()
agent.Start()
app.refresh()
if cfg.RunWithWindows {
// Registry maintenance is not part of startup-critical GUI work.
go func() { _ = setRunAtStartup(true) }()
}
var msg MSG
for {
r, _, _ := pGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
if int32(r) <= 0 {
break
}
pTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
pDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
}
agent.Stop()
app.removeTray()
}
func (w *WinApp) init() error {
inst, _, _ := pGetModuleHandle.Call(0)
fallback, _, _ := pLoadIcon.Call(0, IDI_APPLICATION)
cursor, _, _ := pLoadCursor.Call(0, IDC_ARROW)
w.iconBig = iconFromICO(smithIcon, 32)
w.iconSmall = iconFromICO(smithIcon, 16)
if w.iconBig == 0 {
w.iconBig = fallback
}
if w.iconSmall == 0 {
w.iconSmall = w.iconBig
}
w.bgBrush = createBrush(15, 20, 28)
w.cardBrush = createBrush(23, 30, 41)
w.editBrush = createBrush(30, 38, 51)
w.font = createFont(21, FW_NORMAL)
w.fontSmall = createFont(18, FW_NORMAL)
w.fontBold = createFont(22, FW_SEMIBOLD)
w.fontTitle = createFont(32, FW_BOLD)
w.fontDiag = createFontFace(18, FW_NORMAL, "Consolas")
class := utf16("HockeyVmixAgent123")
wc := WNDCLASSEX{CbSize: uint32(unsafe.Sizeof(WNDCLASSEX{})), LpfnWndProc: syscall.NewCallback(mainWndProc), HInstance: inst, HIcon: w.iconBig, HCursor: cursor, HbrBackground: w.bgBrush, LpszClassName: class, HIconSm: w.iconSmall}
if r, _, e := pRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))); r == 0 {
return fmt.Errorf("RegisterClassEx: %v", e)
}
// Keep the main window deliberately roomy. Dimensions below are CLIENT area
// dimensions; AdjustWindowRectEx adds the actual Windows frame/title bar.
style := uintptr(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX)
rect := RECT{Right: 850, Bottom: 760}
pAdjustWindowRectEx.Call(uintptr(unsafe.Pointer(&rect)), style, 0, 0)
outerW := rect.Right - rect.Left
outerH := rect.Bottom - rect.Top
hwnd, _, e := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(class)), uintptr(unsafe.Pointer(utf16("HOCKEY vMix AGENT"))), style, 0x80000000, 0x80000000, uintptr(outerW), uintptr(outerH), 0, 0, inst, 0)
if hwnd == 0 {
return fmt.Errorf("CreateWindow: %v", e)
}
w.hwnd = hwnd
pSendMessage.Call(hwnd, WM_SETICON, ICON_BIG, w.iconBig)
pSendMessage.Call(hwnd, WM_SETICON, ICON_SMALL, w.iconSmall)
w.buildMain()
// Auxiliary windows are created lazily on the locked GUI thread. Keeping
// startup to one main window avoids unnecessary Win32 work on first launch.
pSetTimer.Call(hwnd, 1, 1000, 0)
return nil
}
func createBrush(r, g, b byte) uintptr { h, _, _ := pCreateSolidBrush.Call(rgb(r, g, b)); return h }
func createFont(size int, weight int) uintptr {
return createFontFace(size, weight, "Segoe UI")
}
func createFontFace(size int, weight int, face string) uintptr {
h, _, _ := pCreateFont.Call(uintptr(int32(-size)), 0, 0, 0, uintptr(weight), 0, 0, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, uintptr(unsafe.Pointer(utf16(face))))
return h
}
func (w *WinApp) label(id int, text string, x, y, width, height int, font uintptr) uintptr {
return w.control(id, "STATIC", text, WS_CHILD|WS_VISIBLE|SS_LEFT, x, y, width, height, font)
}
func (w *WinApp) labelRight(id int, text string, x, y, width, height int, font uintptr) uintptr {
return w.control(id, "STATIC", text, WS_CHILD|WS_VISIBLE|SS_RIGHT, x, y, width, height, font)
}
func (w *WinApp) button(id int, text string, x, y, width, height int) uintptr {
return w.control(id, "BUTTON", text, WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON, x, y, width, height, w.fontSmall)
}
func (w *WinApp) control(id int, class, text string, style uintptr, x, y, width, height int, font uintptr) uintptr {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16(class))), uintptr(unsafe.Pointer(utf16(text))), style, uintptr(x), uintptr(y), uintptr(width), uintptr(height), w.hwnd, uintptr(id), 0, 0)
if h != 0 {
pSendMessage.Call(h, WM_SETFONT, font, 1)
w.controls[id] = h
}
return h
}
func (w *WinApp) buildMain() {
w.label(0, "HOCKEY vMix AGENT", 36, 24, 470, 46, w.fontTitle)
w.label(0, "универсальный мост Web ↔ vMix", 38, 68, 470, 26, w.fontSmall)
w.labelRight(idMainStatus, "● STARTING", 540, 30, 280, 38, w.fontBold)
w.label(0, "СЕРВЕР", 48, 120, 130, 24, w.fontSmall)
w.label(idServerValue, "Подключение…", 48, 149, 330, 36, w.fontBold)
w.label(0, "vMix", 468, 120, 130, 24, w.fontSmall)
w.label(idVmixValue, "Проверка…", 468, 149, 340, 36, w.fontBold)
w.label(0, "УСТРОЙСТВО", 48, 232, 160, 24, w.fontSmall)
w.label(idDeviceValue, "—", 48, 263, 760, 38, w.fontBold)
w.label(idDeviceID, "Device ID: —", 48, 304, 760, 30, w.fontSmall)
w.label(0, "АККАУНТ", 48, 376, 130, 24, w.fontSmall)
w.label(idAccountValue, "Не прикреплён", 48, 405, 330, 36, w.fontBold)
w.label(0, "ТЕКУЩИЙ МАТЧ", 468, 376, 190, 24, w.fontSmall)
w.label(idMatchValue, "—", 468, 405, 340, 36, w.fontBold)
w.label(0, "MAPPING", 48, 488, 130, 24, w.fontSmall)
w.label(idMappingValue, "Будет назначаться сервером", 48, 517, 760, 36, w.fontBold)
w.label(0, "ПОСЛЕДНЯЯ КОМАНДА", 48, 590, 220, 24, w.fontSmall)
w.label(idLastCommand, "Пока команд не было", 48, 619, 760, 32, w.font)
w.label(0, "v"+agentVersion+" • Protocol "+fmt.Sprint(protocolVersion), 38, 716, 320, 26, w.fontSmall)
w.button(idDiag, "Диагностика", 548, 700, 132, 44)
w.button(idSettings, "Настройки", 694, 700, 126, 44)
}
func mainWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr {
if app == nil {
r, _, _ := pDefWindowProc.Call(hwnd, uintptr(msg), wparam, lparam)
return r
}
switch msg {
case WM_TIMER:
app.refresh()
// Do not rewrite a hidden diagnostics edit control. The previous build
// replaced the entire log every 500 ms forever after Diagnostics had
// been opened once, which could make the Win32 UI appear frozen.
if app.diagEdit != 0 && app.diagHwnd != 0 {
visible, _, _ := pIsWindowVisible.Call(app.diagHwnd)
if visible != 0 {
app.refreshDiagnostics()
}
}
return 0
case settingsDoneMsg:
app.uiMu.Lock()
text := app.settingsResult
isErr := app.settingsResultError
app.settingsResult = ""
app.settingsResultError = false
app.uiMu.Unlock()
// Successful saves are intentionally silent: the Settings window closes
// immediately and the Agent reconnects in the background. Only errors use
// a modal dialog, avoiding another source of perceived UI "freezes".
if text != "" && isErr {
message(app.hwnd, "Ошибка настроек", text, MB_ICONERROR)
}
return 0
case WM_COMMAND:
if hiword(wparam) == BN_CLICKED {
switch loword(wparam) {
case idDiag:
app.openDiagnostics()
case idSettings:
app.openSettings()
}
}
return 0
case WM_PAINT:
app.paint(hwnd)
return 0
case WM_CTLCOLORSTATIC:
pSetBkMode.Call(wparam, TRANSPARENT)
id := app.controlID(lparam)
color := rgb(203, 213, 225)
if id == idMainStatus {
status, _ := app.statusTextFor(app.stateCache)
color = status
pSetBkMode.Call(wparam, 0)
pSetBkColor.Call(wparam, rgb(15, 20, 28))
pSetTextColor.Call(wparam, color)
return app.bgBrush
}
if id == idServerValue {
if app.stateCache.ServerConnected {
color = rgb(74, 222, 128)
} else {
color = rgb(248, 113, 113)
}
}
if id == idVmixValue {
if app.stateCache.Vmix.Connected {
color = rgb(74, 222, 128)
} else {
color = rgb(248, 113, 113)
}
}
pSetTextColor.Call(wparam, color)
b, _, _ := pGetStockObject.Call(NULL_BRUSH)
return b
case WM_CLOSE:
if app.reallyExit {
pDestroyWindow.Call(hwnd)
} else {
pShowWindow.Call(hwnd, SW_HIDE)
}
return 0
case WM_DESTROY:
app.removeTray()
pPostQuitMessage.Call(0)
return 0
case trayMessage:
switch uint32(lparam) {
case WM_LBUTTONDBLCLK:
app.showMain()
case WM_RBUTTONUP:
app.trayMenu()
}
return 0
}
r, _, _ := pDefWindowProc.Call(hwnd, uintptr(msg), wparam, lparam)
return r
}
func (w *WinApp) controlID(hwnd uintptr) int {
for id, h := range w.controls {
if h == hwnd {
return id
}
}
return 0
}
func (w *WinApp) paint(hwnd uintptr) {
var ps PAINTSTRUCT
hdc, _, _ := pBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
pen, _, _ := pCreatePen.Call(0, 1, rgb(48, 59, 75))
oldPen, _, _ := pSelectObject.Call(hdc, pen)
oldBrush, _, _ := pSelectObject.Call(hdc, w.cardBrush)
for _, r := range []RECT{{30, 104, 415, 202}, {445, 104, 830, 202}, {30, 216, 830, 350}, {30, 364, 415, 460}, {445, 364, 830, 460}, {30, 474, 830, 566}, {30, 578, 830, 676}} {
pRoundRect.Call(hdc, uintptr(r.Left), uintptr(r.Top), uintptr(r.Right), uintptr(r.Bottom), 14, 14)
}
pSelectObject.Call(hdc, oldPen)
pSelectObject.Call(hdc, oldBrush)
pDeleteObject.Call(pen)
pEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
}
func (w *WinApp) statusTextFor(s AppState) (uintptr, string) {
if !s.ServerConnected {
if s.ServerConnecting {
return rgb(251, 191, 36), "● CONNECTING"
}
return rgb(248, 113, 113), "● SERVER OFFLINE"
}
if !s.Vmix.Connected {
return rgb(248, 113, 113), "● VMIX NOT FOUND"
}
if !s.Paired {
return rgb(251, 191, 36), "● NOT PAIRED"
}
if !s.Active {
return rgb(148, 163, 184), "● STANDBY"
}
if s.MatchID == "" {
return rgb(251, 191, 36), "● WAITING FOR MATCH"
}
return rgb(74, 222, 128), "● READY"
}
func (w *WinApp) refresh() {
// Snapshot shared state once per timer tick. Painting and colour callbacks
// use this GUI-owned cache and never wait on networking locks.
s := w.agent.Snapshot()
cfg := w.agent.Config()
w.stateCache = s
w.configCache = cfg
_, status := w.statusTextFor(s)
w.setControlText(idMainStatus, status)
server := "Не подключён"
if s.ServerConnected {
server = "Подключён"
if host := serverHost(s.ConnectedServer); host != "" {
server += " · " + host
}
} else if s.ServerConnecting {
server = "Подключение…"
}
w.setControlText(idServerValue, server)
vm := "Не найден"
if s.Vmix.Connected {
vm = "Подключён"
if s.Vmix.Version != "" {
vm += " · " + s.Vmix.Version
}
}
w.setControlText(idVmixValue, vm)
w.setControlText(idDeviceValue, cfg.DeviceName)
w.setControlText(idDeviceID, "Device ID: "+cfg.DeviceID)
account := "Не прикреплён"
if s.Paired {
account = s.PairedLogin
if account == "" {
account = "Прикреплён"
}
if !s.Active {
account += " · standby"
}
}
w.setControlText(idAccountValue, account)
match := "—"
if s.MatchID != "" {
match = s.MatchID
}
w.setControlText(idMatchValue, match)
mapping := "Не настроен (следующий этап)"
if s.MappingName != "" {
mapping = s.MappingName
if s.MappingVersion != "" {
mapping += " · v" + s.MappingVersion
}
}
w.setControlText(idMappingValue, mapping)
cmd := "Пока команд не было"
if s.LastCommand != "" {
icon := "✓"
if !s.LastCommandOK {
icon = "✕"
}
cmd = icon + " " + s.LastCommand + " · " + s.LastCommandAt.Format("15:04:05")
}
w.setControlText(idLastCommand, cmd)
w.updateTray(status, s)
}
func (w *WinApp) setControlText(id int, text string) {
if w.lastTexts[id] == text {
return
}
w.lastTexts[id] = text
setText(w.controls[id], text)
// Repaint only when the visible value actually changed.
if h := w.controls[id]; h != 0 {
pInvalidateRect.Call(h, 0, 1)
}
pInvalidateRect.Call(w.hwnd, 0, 0)
}
func serverHost(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err == nil && u.Host != "" {
return u.Host
}
return ""
}
func shortDevice(v string) string {
if len(v) <= 22 {
return v
}
return v[:10] + " … " + v[len(v)-8:]
}
func setText(hwnd uintptr, text string) {
if hwnd != 0 {
pSetWindowText.Call(hwnd, uintptr(unsafe.Pointer(utf16(text))))
}
}
func (w *WinApp) addTray() {
icon := w.iconSmall
if icon == 0 {
icon, _, _ = pLoadIcon.Call(0, IDI_APPLICATION)
}
w.tray = NOTIFYICONDATA{CbSize: uint32(unsafe.Sizeof(NOTIFYICONDATA{})), HWnd: w.hwnd, UID: 1, UFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP, UCallbackMessage: trayMessage, HIcon: icon}
copyUTF16(w.tray.SzTip[:], "Hockey vMix Agent")
pShellNotifyIcon.Call(NIM_ADD, uintptr(unsafe.Pointer(&w.tray)))
}
func (w *WinApp) removeTray() {
if w.tray.CbSize != 0 {
pShellNotifyIcon.Call(NIM_DELETE, uintptr(unsafe.Pointer(&w.tray)))
w.tray.CbSize = 0
}
}
func (w *WinApp) updateTray(status string, s AppState) {
if status == w.lastTrayStatus {
return
}
w.lastTrayStatus = status
w.tray.UFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP
tip := "Hockey Agent · " + strings.TrimPrefix(status, "● ")
if s.MatchID != "" {
tip += " · Match " + s.MatchID
}
copyUTF16(w.tray.SzTip[:], tip)
pShellNotifyIcon.Call(NIM_MODIFY, uintptr(unsafe.Pointer(&w.tray)))
}
func copyUTF16(dst []uint16, s string) {
u, _ := syscall.UTF16FromString(s)
for i := range dst {
dst[i] = 0
}
copy(dst, u)
}
func (w *WinApp) showMain() { pShowWindow.Call(w.hwnd, SW_RESTORE); pSetForegroundWindow.Call(w.hwnd) }
func (w *WinApp) trayMenu() {
menu, _, _ := pCreatePopupMenu.Call()
pAppendMenu.Call(menu, MF_STRING, 1, uintptr(unsafe.Pointer(utf16("Открыть Hockey Agent"))))
pAppendMenu.Call(menu, MF_STRING, 2, uintptr(unsafe.Pointer(utf16("Диагностика"))))
pAppendMenu.Call(menu, MF_STRING, 3, uintptr(unsafe.Pointer(utf16("Настройки"))))
pAppendMenu.Call(menu, MF_STRING, 4, uintptr(unsafe.Pointer(utf16("Выход"))))
var pt POINT
pGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
pSetForegroundWindow.Call(w.hwnd)
cmd, _, _ := pTrackPopupMenu.Call(menu, TPM_RIGHTBUTTON|0x0100, uintptr(pt.X), uintptr(pt.Y), 0, w.hwnd, 0)
pDestroyMenu.Call(menu)
switch cmd {
case 1:
w.showMain()
case 2:
w.openDiagnostics()
case 3:
w.openSettings()
case 4:
w.reallyExit = true
pDestroyWindow.Call(w.hwnd)
}
}
func (w *WinApp) createSettingsWindow() {
if w.settingsHwnd != 0 {
return
}
w.settingsHwnd = w.simpleWindow("Настройки Hockey Agent", 840, 700, syscall.NewCallback(settingsWndProc))
y := 34
w.settingsLabel("Основной Hockey Server URL", 40, y)
w.settingsEdit(3001, "", 40, y+30, 760)
y += 108
w.settingsLabel("Резервный Server URL (необязательно)", 40, y)
w.settingsEdit(3006, "", 40, y+30, 760)
y += 108
w.settingsLabel("vMix API URL", 40, y)
w.settingsEdit(3002, "", 40, y+30, 760)
y += 108
w.settingsLabel("Device name", 40, y)
w.settingsEdit(3003, "", 40, y+30, 760)
w.settingsNote("Для удалённого сервера можно указать https://адрес — Agent сам подключится по WSS.\r\nПри переносе также можно положить server_url.txt рядом с agent.exe: этот адрес будет иметь приоритет.", 40, 470, 760, 58)
w.settingsCheck(3004, "Запускать вместе с Windows", false, 40, 548)
w.settingsCheck(3005, "Запускать свёрнутым в tray", false, 40, 590)
w.settingsButton(idSaveSettings, "Сохранить", 526, 632, 130)
w.settingsButton(idCancelSettings, "Отмена", 670, 632, 130)
w.populateSettings()
}
func (w *WinApp) populateSettings() {
cfg := w.configCache
setText(w.settingsControls[3001], cfg.ServerURL)
setText(w.settingsControls[3006], cfg.FallbackServerURL)
setText(w.settingsControls[3002], cfg.VmixURL)
setText(w.settingsControls[3003], cfg.DeviceName)
check := func(id int, value bool) {
state := uintptr(0)
if value {
state = BST_CHECKED
}
pSendMessage.Call(w.settingsControls[id], BM_SETCHECK, state, 0)
}
check(3004, cfg.RunWithWindows)
check(3005, cfg.StartMinimized)
}
func (w *WinApp) openSettings() {
if w.settingsHwnd == 0 {
w.createSettingsWindow()
}
w.populateSettings()
pShowWindow.Call(w.settingsHwnd, SW_SHOW)
pSetForegroundWindow.Call(w.settingsHwnd)
}
func (w *WinApp) simpleWindow(title string, clientWidth, clientHeight int, proc uintptr) uintptr {
inst, _, _ := pGetModuleHandle.Call(0)
className := utf16(title + "Class123")
cursor, _, _ := pLoadCursor.Call(0, IDC_ARROW)
wc := WNDCLASSEX{CbSize: uint32(unsafe.Sizeof(WNDCLASSEX{})), LpfnWndProc: proc, HInstance: inst, HIcon: w.iconBig, HCursor: cursor, HbrBackground: w.bgBrush, LpszClassName: className, HIconSm: w.iconSmall}
pRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
style := uintptr(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU)
rect := RECT{Right: int32(clientWidth), Bottom: int32(clientHeight)}
pAdjustWindowRectEx.Call(uintptr(unsafe.Pointer(&rect)), style, 0, 0)
outerW := rect.Right - rect.Left
outerH := rect.Bottom - rect.Top
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(utf16(title))), style, 0x80000000, 0x80000000, uintptr(outerW), uintptr(outerH), w.hwnd, 0, inst, 0)
if h != 0 {
pSendMessage.Call(h, WM_SETICON, ICON_BIG, w.iconBig)
pSendMessage.Call(h, WM_SETICON, ICON_SMALL, w.iconSmall)
}
return h
}
func (w *WinApp) settingsLabel(text string, x, y int) {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16("STATIC"))), uintptr(unsafe.Pointer(utf16(text))), WS_CHILD|WS_VISIBLE, uintptr(x), uintptr(y), 760, 28, w.settingsHwnd, 0, 0, 0)
pSendMessage.Call(h, WM_SETFONT, w.fontSmall, 1)
}
func (w *WinApp) settingsNote(text string, x, y, width, height int) {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16("STATIC"))), uintptr(unsafe.Pointer(utf16(text))), WS_CHILD|WS_VISIBLE, uintptr(x), uintptr(y), uintptr(width), uintptr(height), w.settingsHwnd, 0, 0, 0)
pSendMessage.Call(h, WM_SETFONT, w.fontSmall, 1)
}
func (w *WinApp) settingsEdit(id int, text string, x, y, width int) {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16("EDIT"))), uintptr(unsafe.Pointer(utf16(text))), WS_CHILD|WS_VISIBLE|WS_TABSTOP|ES_AUTOHSCROLL, uintptr(x), uintptr(y), uintptr(width), 46, w.settingsHwnd, uintptr(id), 0, 0)
pSendMessage.Call(h, WM_SETFONT, w.font, 1)
w.settingsControls[id] = h
}
func (w *WinApp) settingsCheck(id int, text string, checked bool, x, y int) {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16("BUTTON"))), uintptr(unsafe.Pointer(utf16(text))), WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_AUTOCHECKBOX, uintptr(x), uintptr(y), 600, 34, w.settingsHwnd, uintptr(id), 0, 0)
pSendMessage.Call(h, WM_SETFONT, w.font, 1)
if checked {
pSendMessage.Call(h, BM_SETCHECK, BST_CHECKED, 0)
}
w.settingsControls[id] = h
}
func (w *WinApp) settingsButton(id int, text string, x, y, width int) {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16("BUTTON"))), uintptr(unsafe.Pointer(utf16(text))), WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON, uintptr(x), uintptr(y), uintptr(width), 46, w.settingsHwnd, uintptr(id), 0, 0)
pSendMessage.Call(h, WM_SETFONT, w.font, 1)
}
func settingsWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr {
if app == nil {
r, _, _ := pDefWindowProc.Call(hwnd, uintptr(msg), wparam, lparam)
return r
}
switch msg {
case WM_COMMAND:
if hiword(wparam) == BN_CLICKED {
switch loword(wparam) {
case idSaveSettings:
app.saveSettings()
case idCancelSettings:
pShowWindow.Call(hwnd, SW_HIDE)
}
}
return 0
case WM_CLOSE:
pShowWindow.Call(hwnd, SW_HIDE)
return 0
case WM_CTLCOLORSTATIC:
pSetBkMode.Call(wparam, TRANSPARENT)
pSetTextColor.Call(wparam, rgb(203, 213, 225))
b, _, _ := pGetStockObject.Call(NULL_BRUSH)
return b
case WM_CTLCOLOREDIT:
pSetTextColor.Call(wparam, rgb(226, 232, 240))
pSetBkMode.Call(wparam, TRANSPARENT)
return app.editBrush
}
r, _, _ := pDefWindowProc.Call(hwnd, uintptr(msg), wparam, lparam)
return r
}
func (w *WinApp) saveSettings() {
cfg := w.configCache
cfg.ServerURL = getText(w.settingsControls[3001])
cfg.FallbackServerURL = getText(w.settingsControls[3006])
cfg.VmixURL = getText(w.settingsControls[3002])
cfg.DeviceName = getText(w.settingsControls[3003])
cfg.RunWithWindows = checked(w.settingsControls[3004])
cfg.StartMinimized = checked(w.settingsControls[3005])
w.configCache = cfg
pShowWindow.Call(w.settingsHwnd, SW_HIDE)
// Saving may close a stale WebSocket and update Windows startup settings.
// Those operations are intentionally kept off the GUI thread.
go func(cfg Config) {
var result string
var isErr bool
if err := w.agent.UpdateConfig(cfg); err != nil {
result = err.Error()
isErr = true
} else if err := setRunAtStartup(cfg.RunWithWindows); err != nil {
result = "Настройки сохранены, но не удалось обновить автозапуск: " + err.Error()
isErr = true
} else {
result = "Настройки сохранены. Соединение будет обновлено автоматически."
}
w.uiMu.Lock()
w.settingsResult = result
w.settingsResultError = isErr
w.uiMu.Unlock()
pPostMessage.Call(w.hwnd, settingsDoneMsg, 0, 0)
}(cfg)
}
func checked(hwnd uintptr) bool {
r, _, _ := pSendMessage.Call(hwnd, BM_GETCHECK, 0, 0)
return r == BST_CHECKED
}
func getText(hwnd uintptr) string {
n, _, _ := pGetWindowTextLength.Call(hwnd)
buf := make([]uint16, n+1)
pGetWindowText.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), n+1)
return syscall.UTF16ToString(buf)
}
func message(hwnd uintptr, title, text string, flags uintptr) {
pMessageBox.Call(hwnd, uintptr(unsafe.Pointer(utf16(text))), uintptr(unsafe.Pointer(utf16(title))), MB_OK|flags)
}
func setRunAtStartup(enabled bool) error {
exe, err := os.Executable()
if err != nil {
return err
}
key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
if enabled {
return exec.Command("reg", "add", key, "/v", "HockeyVmixAgent", "/t", "REG_SZ", "/d", `"`+exe+`"`, "/f").Run()
}
_ = exec.Command("reg", "delete", key, "/v", "HockeyVmixAgent", "/f").Run()
return nil
}
func (w *WinApp) createDiagnosticsWindow() {
if w.diagHwnd != 0 {
return
}
w.diagHwnd = w.simpleWindow("Диагностика Hockey Agent", 1080, 760, syscall.NewCallback(diagWndProc))
w.diagEdit = w.diagControl("EDIT", "", WS_CHILD|WS_VISIBLE|ES_MULTILINE|ES_AUTOVSCROLL|ES_READONLY|WS_VSCROLL, 28, 28, 1024, 640, 0)
w.diagControl("BUTTON", "Копировать лог", WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON, 842, 690, 210, 46, idCopyLog)
}
func (w *WinApp) refreshDiagnostics() {
logs := w.agent.Logs()
if logs == w.lastDiagLog {
return
}
w.lastDiagLog = logs
setText(w.diagEdit, logs)
}
func (w *WinApp) openDiagnostics() {
if w.diagHwnd == 0 {
w.createDiagnosticsWindow()
}
w.refreshDiagnostics()
pShowWindow.Call(w.diagHwnd, SW_SHOW)
pSetForegroundWindow.Call(w.diagHwnd)
}
func (w *WinApp) diagControl(class, text string, style uintptr, x, y, width, height, id int) uintptr {
h, _, _ := pCreateWindowEx.Call(0, uintptr(unsafe.Pointer(utf16(class))), uintptr(unsafe.Pointer(utf16(text))), style, uintptr(x), uintptr(y), uintptr(width), uintptr(height), w.diagHwnd, uintptr(id), 0, 0)
font := w.font
if class == "EDIT" {
font = w.fontDiag
}
pSendMessage.Call(h, WM_SETFONT, font, 1)
return h
}
func diagWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr {
if app == nil {
r, _, _ := pDefWindowProc.Call(hwnd, uintptr(msg), wparam, lparam)
return r
}
switch msg {
case WM_COMMAND:
if hiword(wparam) == BN_CLICKED && loword(wparam) == idCopyLog {
copyClipboard(app.agent.Logs())
message(hwnd, "Диагностика", "Лог скопирован в буфер обмена.", MB_ICONINFORMATION)
}
return 0
case WM_CLOSE:
pShowWindow.Call(hwnd, SW_HIDE)
return 0
case WM_CTLCOLORSTATIC:
if lparam == app.diagEdit {
pSetTextColor.Call(wparam, rgb(226, 232, 240))
pSetBkMode.Call(wparam, TRANSPARENT)
return app.editBrush
}
pSetBkMode.Call(wparam, TRANSPARENT)
pSetTextColor.Call(wparam, rgb(203, 213, 225))
b, _, _ := pGetStockObject.Call(NULL_BRUSH)
return b
case WM_CTLCOLOREDIT:
pSetTextColor.Call(wparam, rgb(226, 232, 240))
pSetBkMode.Call(wparam, TRANSPARENT)
return app.editBrush
}
r, _, _ := pDefWindowProc.Call(hwnd, uintptr(msg), wparam, lparam)
return r
}
func copyClipboard(text string) {
u, _ := syscall.UTF16FromString(text)
size := uintptr(len(u) * 2)
h, _, _ := pGlobalAlloc.Call(GMEM_MOVEABLE, size)
if h == 0 {
return
}
ptr, _, _ := pGlobalLock.Call(h)
if ptr == 0 {
return
}
if len(u) > 0 {
pRtlMoveMemory.Call(ptr, uintptr(unsafe.Pointer(&u[0])), size)
}
pGlobalUnlock.Call(h)
if r, _, _ := pOpenClipboard.Call(app.hwnd); r == 0 {
return
}
pEmptyClipboard.Call()
pSetClipboardData.Call(CF_UNICODETEXT, h)
pCloseClipboard.Call()
}