//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() }