1104 lines
32 KiB
Go
1104 lines
32 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"vrc_osc_go/internal/app"
|
|
"vrc_osc_go/internal/config"
|
|
)
|
|
|
|
const (
|
|
wsOverlappedWindow = 0x00CF0000
|
|
wsVisible = 0x10000000
|
|
wsChild = 0x40000000
|
|
wsPopup = 0x80000000
|
|
wsClipChildren = 0x02000000
|
|
wsBorder = 0x00800000
|
|
esMultiline = 0x0004
|
|
esReadonly = 0x0800
|
|
esAutovscroll = 0x0040
|
|
esAutohscroll = 0x0080
|
|
wmCreateGUI = 0x0001
|
|
wmDestroyGUI = 0x0002
|
|
wmTimerGUI = 0x0113
|
|
wmCommandGUI = 0x0111
|
|
wmSetFont = 0x0030
|
|
idStatus = 2001
|
|
idLog = 2002
|
|
timerRefresh = 1
|
|
)
|
|
|
|
var joinLeaveLinePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
|
|
var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]`)
|
|
var vrcLogTimePattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})`)
|
|
var vrcJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
|
var vrcLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
|
var hwndTopMostFlag uintptr = ^uintptr(0) - 1
|
|
var hwndNoMove uintptr = 0x0002
|
|
var hwndNoSize uintptr = 0x0001
|
|
var hwndNoActivate uintptr = 0x0010
|
|
var hwndShowWindow uintptr = 0x0040
|
|
var swRestore uintptr = 9
|
|
var swHideControl uintptr = 0
|
|
var swShowControl uintptr = 5
|
|
var swpVisibleFlags uintptr = hwndNoActivate | hwndShowWindow
|
|
var swpFlags uintptr = hwndNoMove | hwndNoSize | hwndNoActivate
|
|
var swpShowFlags uintptr = hwndNoMove | hwndNoSize | hwndShowWindow
|
|
var smXVirtualScreen int32 = 76
|
|
var smYVirtualScreen int32 = 77
|
|
var smCXVirtualScreen int32 = 78
|
|
var smCYVirtualScreen int32 = 79
|
|
var setWindowPosProc *syscall.LazyProc
|
|
var sendMessageProc *syscall.LazyProc
|
|
var createFontProc *syscall.LazyProc
|
|
var deleteObjectProc *syscall.LazyProc
|
|
var showWindowProc *syscall.LazyProc
|
|
|
|
type guiApp struct {
|
|
hwnd uintptr
|
|
leftHwnd uintptr
|
|
rightHwnd uintptr
|
|
settingsPaneHwnd uintptr
|
|
tabJoin uintptr
|
|
tabTranslate uintptr
|
|
tabSettings uintptr
|
|
btnActiveWindow uintptr
|
|
btnTopMost uintptr
|
|
btnFontDown uintptr
|
|
btnFontUp uintptr
|
|
activeTab string
|
|
activeWindow bool
|
|
lastActiveWindow bool
|
|
topMost bool
|
|
lastTopMost bool
|
|
fontSize int
|
|
hFont uintptr
|
|
lastSettingsAction time.Time
|
|
lastInstanceCount int
|
|
}
|
|
|
|
type userState struct {
|
|
Name string
|
|
Present bool
|
|
LastJoin time.Time
|
|
LastLeave time.Time
|
|
}
|
|
|
|
func runtimeDir() string {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "runtime"
|
|
}
|
|
return filepath.Join(filepath.Dir(exe), "runtime")
|
|
}
|
|
|
|
func guiLog(text string) {
|
|
_ = app.AppendRuntimeLog("GUI", text)
|
|
}
|
|
|
|
func runNativeGUI() error {
|
|
user32 := syscall.NewLazyDLL("user32.dll")
|
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
|
gdi32 := syscall.NewLazyDLL("gdi32.dll")
|
|
registerClass := user32.NewProc("RegisterClassW")
|
|
createWindowEx := user32.NewProc("CreateWindowExW")
|
|
defWindowProc := user32.NewProc("DefWindowProcW")
|
|
showWindowProc = user32.NewProc("ShowWindow")
|
|
updateWindow := user32.NewProc("UpdateWindow")
|
|
setWindowPosProc = user32.NewProc("SetWindowPos")
|
|
sendMessageProc = user32.NewProc("SendMessageW")
|
|
createFontProc = gdi32.NewProc("CreateFontW")
|
|
deleteObjectProc = gdi32.NewProc("DeleteObject")
|
|
getMessage := user32.NewProc("GetMessageW")
|
|
translateMessage := user32.NewProc("TranslateMessage")
|
|
dispatchMessage := user32.NewProc("DispatchMessageW")
|
|
postQuitMessage := user32.NewProc("PostQuitMessage")
|
|
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
|
bringWindowToTop := user32.NewProc("BringWindowToTop")
|
|
setTimer := user32.NewProc("SetTimer")
|
|
loadCursor := user32.NewProc("LoadCursorW")
|
|
setWindowText := user32.NewProc("SetWindowTextW")
|
|
getWindowRect := user32.NewProc("GetWindowRect")
|
|
isWindowVisible := user32.NewProc("IsWindowVisible")
|
|
isIconic := user32.NewProc("IsIconic")
|
|
getForegroundWindow := user32.NewProc("GetForegroundWindow")
|
|
getSystemMetrics := user32.NewProc("GetSystemMetrics")
|
|
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
|
loadIcon := user32.NewProc("LoadIconW")
|
|
|
|
type wndClass struct {
|
|
style uint32
|
|
lpfnWndProc uintptr
|
|
cbClsExtra int32
|
|
cbWndExtra int32
|
|
hInstance uintptr
|
|
hIcon uintptr
|
|
hCursor uintptr
|
|
hbrBackground uintptr
|
|
lpszMenuName *uint16
|
|
lpszClassName *uint16
|
|
}
|
|
type msg struct {
|
|
hwnd uintptr
|
|
message uint32
|
|
wParam uintptr
|
|
lParam uintptr
|
|
time uint32
|
|
pt struct{ X, Y int32 }
|
|
}
|
|
|
|
hInstance, _, _ := getModuleHandle.Call(0)
|
|
className, _ := syscall.UTF16PtrFromString("VRC_OSC_NATIVE_GUI")
|
|
title, _ := syscall.UTF16PtrFromString("VRC OSC")
|
|
cursor, _, _ := loadCursor.Call(0, 32512)
|
|
icon, _, _ := loadIcon.Call(0, 32512)
|
|
guiLog("stage=prepare window")
|
|
|
|
var app guiApp
|
|
guiCfg := loadGUISettings()
|
|
app.activeWindow = guiCfg.ActiveWindow
|
|
app.topMost = guiCfg.TopMost
|
|
if guiCfg.FontSize > 0 {
|
|
app.fontSize = guiCfg.FontSize
|
|
} else {
|
|
app.fontSize = 18
|
|
}
|
|
app.hFont = createAppFont(app.fontSize)
|
|
wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
|
|
switch message {
|
|
case wmCreateGUI:
|
|
buttonClass, _ := syscall.UTF16PtrFromString("BUTTON")
|
|
leftClass, _ := syscall.UTF16PtrFromString("STATIC")
|
|
rightClass, _ := syscall.UTF16PtrFromString("STATIC")
|
|
leftTitle, _ := syscall.UTF16PtrFromString("")
|
|
rightTitle, _ := syscall.UTF16PtrFromString("")
|
|
joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
|
|
translateTitle, _ := syscall.UTF16PtrFromString("Translate")
|
|
settingsTitle, _ := syscall.UTF16PtrFromString("Settings")
|
|
activeWindowTitle, _ := syscall.UTF16PtrFromString("Active window")
|
|
topMostTitle, _ := syscall.UTF16PtrFromString("Top most")
|
|
fontDownTitle, _ := syscall.UTF16PtrFromString("A-")
|
|
fontUpTitle, _ := syscall.UTF16PtrFromString("A+")
|
|
app.tabJoin, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(joinTitle)), wsVisible|wsChild, 370, 20, 90, 28, hwnd, 3001, hInstance, 0)
|
|
app.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, hInstance, 0)
|
|
app.tabSettings, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(settingsTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, hInstance, 0)
|
|
app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(leftClass)), uintptr(unsafe.Pointer(leftTitle)), wsVisible|wsChild, 20, 20, 330, 300, hwnd, idStatus, hInstance, 0)
|
|
app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog, hInstance, 0)
|
|
settingsPaneTitle, _ := syscall.UTF16PtrFromString("")
|
|
app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(settingsPaneTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog+1, hInstance, 0)
|
|
app.btnActiveWindow, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(activeWindowTitle)), wsVisible|wsChild, 390, 90, 130, 30, hwnd, 3004, hInstance, 0)
|
|
app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), wsVisible|wsChild, 390, 130, 120, 30, hwnd, 3005, hInstance, 0)
|
|
app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), wsVisible|wsChild, 390, 175, 50, 30, hwnd, 3006, hInstance, 0)
|
|
app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 175, 50, 30, hwnd, 3007, hInstance, 0)
|
|
app.activeTab = "join"
|
|
app.activeWindow = true
|
|
app.lastActiveWindow = false
|
|
setTimer.Call(hwnd, timerRefresh, 2000, 0)
|
|
applyFont(&app)
|
|
refreshGUI(setWindowText, &app)
|
|
applyActiveWindow(hwnd, &app, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic)
|
|
ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
|
|
logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic)
|
|
return 0
|
|
case wmCommandGUI:
|
|
switch uint16(wParam & 0xffff) {
|
|
case 3001:
|
|
app.activeTab = "join"
|
|
case 3002:
|
|
app.activeTab = "translate"
|
|
case 3003:
|
|
app.activeTab = "settings"
|
|
case 3004:
|
|
if !allowRapidSettingsAction(&app) {
|
|
return 0
|
|
}
|
|
app.activeWindow = !app.activeWindow
|
|
saveGUISettings(&app)
|
|
applyActiveWindow(hwnd, &app, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic)
|
|
case 3005:
|
|
if !allowRapidSettingsAction(&app) {
|
|
return 0
|
|
}
|
|
app.topMost = !app.topMost
|
|
saveGUISettings(&app)
|
|
applyTopMost(hwnd, &app)
|
|
case 3006:
|
|
if !allowRapidSettingsAction(&app) {
|
|
return 0
|
|
}
|
|
if app.fontSize > 10 {
|
|
app.fontSize -= 2
|
|
oldFont := app.hFont
|
|
app.hFont = createAppFont(app.fontSize)
|
|
applyFont(&app)
|
|
if oldFont != 0 && deleteObjectProc != nil {
|
|
deleteObjectProc.Call(oldFont)
|
|
}
|
|
saveGUISettings(&app)
|
|
}
|
|
case 3007:
|
|
if !allowRapidSettingsAction(&app) {
|
|
return 0
|
|
}
|
|
if app.fontSize < 30 {
|
|
app.fontSize += 2
|
|
oldFont := app.hFont
|
|
app.hFont = createAppFont(app.fontSize)
|
|
applyFont(&app)
|
|
if oldFont != 0 && deleteObjectProc != nil {
|
|
deleteObjectProc.Call(oldFont)
|
|
}
|
|
saveGUISettings(&app)
|
|
}
|
|
}
|
|
refreshGUI(setWindowText, &app)
|
|
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
|
|
return 0
|
|
case wmTimerGUI:
|
|
refreshGUI(setWindowText, &app)
|
|
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
|
|
return 0
|
|
case wmDestroyGUI:
|
|
postQuitMessage.Call(0)
|
|
return 0
|
|
}
|
|
ret, _, _ := defWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
|
|
return ret
|
|
})
|
|
|
|
guiLog("stage=register class")
|
|
atom, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClass{
|
|
style: 0,
|
|
lpfnWndProc: wndProc,
|
|
hInstance: hInstance,
|
|
hIcon: icon,
|
|
hCursor: cursor,
|
|
hbrBackground: 6,
|
|
lpszClassName: className,
|
|
})))
|
|
if atom == 0 {
|
|
guiLog(fmt.Sprintf("stage=register class failed err=%v", regErr))
|
|
return fmt.Errorf("register class: %v", regErr)
|
|
}
|
|
|
|
guiLog("stage=create window")
|
|
app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow|wsVisible, 200, 120, 760, 380, 0, 0, hInstance, 0)
|
|
if app.hwnd == 0 {
|
|
guiLog("stage=create window failed")
|
|
return fmt.Errorf("create window failed")
|
|
}
|
|
guiLog(fmt.Sprintf("stage=create window ok hwnd=%d", app.hwnd))
|
|
logWindowState("after-create", app.hwnd, getWindowRect, isWindowVisible, isIconic)
|
|
|
|
guiLog("stage=show window")
|
|
prevVisible, _, _ := showWindowProc.Call(app.hwnd, 1)
|
|
guiLog(fmt.Sprintf("stage=show window ret=%d", prevVisible))
|
|
updateWindow.Call(app.hwnd)
|
|
ensureWindowVisible("startup", app.hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
|
|
logWindowState("after-show", app.hwnd, getWindowRect, isWindowVisible, isIconic)
|
|
guiLog("stage=message loop")
|
|
|
|
var m msg
|
|
for {
|
|
r, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
|
if int32(r) <= 0 {
|
|
break
|
|
}
|
|
translateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
|
dispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func logWindowState(stage string, hwnd uintptr, getWindowRect, isWindowVisible, isIconic *syscall.LazyProc) {
|
|
if hwnd == 0 {
|
|
guiLog(stage + " hwnd=0")
|
|
return
|
|
}
|
|
var rect struct {
|
|
Left int32
|
|
Top int32
|
|
Right int32
|
|
Bottom int32
|
|
}
|
|
getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
|
|
visible, _, _ := isWindowVisible.Call(hwnd)
|
|
minimized, _, _ := isIconic.Call(hwnd)
|
|
guiLog(fmt.Sprintf(
|
|
"%s hwnd=%d visible=%t minimized=%t rect=(%d,%d)-(%d,%d) size=%dx%d",
|
|
stage,
|
|
hwnd,
|
|
visible != 0,
|
|
minimized != 0,
|
|
rect.Left,
|
|
rect.Top,
|
|
rect.Right,
|
|
rect.Bottom,
|
|
rect.Right-rect.Left,
|
|
rect.Bottom-rect.Top,
|
|
))
|
|
}
|
|
|
|
func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
|
current, instanceCount := currentUsersFromJoinLeave()
|
|
guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount))
|
|
if instanceCount == 0 && app.lastInstanceCount > 0 {
|
|
instanceCount = app.lastInstanceCount
|
|
}
|
|
if instanceCount > 0 {
|
|
app.lastInstanceCount = instanceCount
|
|
}
|
|
if app.leftHwnd != 0 {
|
|
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
|
|
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t)))
|
|
}
|
|
if app.btnActiveWindow != 0 {
|
|
label := "Active window: OFF"
|
|
if app.activeWindow {
|
|
label = "Active window: ON"
|
|
}
|
|
t, _ := syscall.UTF16PtrFromString(label)
|
|
setWindowText.Call(app.btnActiveWindow, uintptr(unsafe.Pointer(t)))
|
|
}
|
|
if app.btnTopMost != 0 {
|
|
label := "Top most: OFF"
|
|
if app.topMost {
|
|
label = "Top most: ON"
|
|
}
|
|
t, _ := syscall.UTF16PtrFromString(label)
|
|
setWindowText.Call(app.btnTopMost, uintptr(unsafe.Pointer(t)))
|
|
}
|
|
if app.activeTab == "settings" {
|
|
if app.rightHwnd != 0 {
|
|
showWindowProc.Call(app.rightHwnd, swHideControl)
|
|
}
|
|
if app.settingsPaneHwnd != 0 {
|
|
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
|
|
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
|
|
}
|
|
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
|
if hwnd != 0 {
|
|
showWindowProc.Call(hwnd, swShowControl)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if app.rightHwnd != 0 {
|
|
state := readRuntimeSnapshot()
|
|
state["active_window"] = app.activeWindow
|
|
state["top_most"] = app.topMost
|
|
state["font_size"] = app.fontSize
|
|
rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount)
|
|
showWindowProc.Call(app.rightHwnd, swShowControl)
|
|
t, _ := syscall.UTF16PtrFromString(rightText)
|
|
setWindowText.Call(app.rightHwnd, uintptr(unsafe.Pointer(t)))
|
|
}
|
|
if app.settingsPaneHwnd != 0 {
|
|
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
|
|
}
|
|
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
|
if hwnd != 0 {
|
|
showWindowProc.Call(hwnd, swHideControl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) {
|
|
if showWindowProc == nil || setWindowPosProc == nil || app == nil {
|
|
return
|
|
}
|
|
if app.activeTab == "settings" {
|
|
if app.settingsPaneHwnd != 0 {
|
|
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
|
|
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
|
|
}
|
|
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
|
if hwnd != 0 {
|
|
showWindowProc.Call(hwnd, swShowControl)
|
|
}
|
|
}
|
|
guiLog("refreshSettingsControls visible")
|
|
return
|
|
}
|
|
if app.settingsPaneHwnd != 0 {
|
|
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
|
|
}
|
|
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
|
if hwnd != 0 {
|
|
showWindowProc.Call(hwnd, swHideControl)
|
|
}
|
|
}
|
|
guiLog("refreshSettingsControls hidden")
|
|
}
|
|
|
|
func applyActiveWindow(hwnd uintptr, app *guiApp, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic *syscall.LazyProc) {
|
|
if hwnd == 0 || app.activeWindow == app.lastActiveWindow {
|
|
return
|
|
}
|
|
if app.activeWindow {
|
|
showWindowProc.Call(hwnd, swRestore)
|
|
setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpShowFlags)
|
|
bringWindowToTop.Call(hwnd)
|
|
setForegroundWindow.Call(hwnd)
|
|
foreground, _, _ := getForegroundWindow.Call()
|
|
guiLog(fmt.Sprintf("applyActiveWindow on foreground=%d target=%d", foreground, hwnd))
|
|
ensureWindowVisible("active-window", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
|
|
} else {
|
|
guiLog("applyActiveWindow off")
|
|
}
|
|
app.lastActiveWindow = app.activeWindow
|
|
}
|
|
|
|
func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics *syscall.LazyProc) {
|
|
if hwnd == 0 || getSystemMetrics == nil {
|
|
return
|
|
}
|
|
var rect struct {
|
|
Left int32
|
|
Top int32
|
|
Right int32
|
|
Bottom int32
|
|
}
|
|
if getWindowRect != nil {
|
|
getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
|
|
}
|
|
minimized := false
|
|
visible := true
|
|
if isIconic != nil {
|
|
v, _, _ := isIconic.Call(hwnd)
|
|
minimized = v != 0
|
|
}
|
|
if isWindowVisible != nil {
|
|
v, _, _ := isWindowVisible.Call(hwnd)
|
|
visible = v != 0
|
|
}
|
|
if minimized && showWindowProc != nil {
|
|
showWindowProc.Call(hwnd, swRestore)
|
|
guiLog(stage + " restored from minimized")
|
|
}
|
|
vx, _, _ := getSystemMetrics.Call(uintptr(smXVirtualScreen))
|
|
vy, _, _ := getSystemMetrics.Call(uintptr(smYVirtualScreen))
|
|
vw, _, _ := getSystemMetrics.Call(uintptr(smCXVirtualScreen))
|
|
vh, _, _ := getSystemMetrics.Call(uintptr(smCYVirtualScreen))
|
|
left := int32(vx)
|
|
top := int32(vy)
|
|
width := int32(vw)
|
|
height := int32(vh)
|
|
w := rect.Right - rect.Left
|
|
h := rect.Bottom - rect.Top
|
|
if w <= 0 {
|
|
w = 760
|
|
}
|
|
if h <= 0 {
|
|
h = 380
|
|
}
|
|
offscreen := rect.Right <= left || rect.Bottom <= top || rect.Left >= left+width || rect.Top >= top+height
|
|
if offscreen && setWindowPosProc != nil {
|
|
nx := left + (width-w)/2
|
|
ny := top + (height-h)/2
|
|
setWindowPosProc.Call(hwnd, 0, uintptr(nx), uintptr(ny), 0, 0, swpShowFlags)
|
|
guiLog(fmt.Sprintf("%s recentered visible=%t minimized=%t old=(%d,%d)-(%d,%d) new=(%d,%d) screen=(%d,%d %dx%d)",
|
|
stage, visible, minimized, rect.Left, rect.Top, rect.Right, rect.Bottom, nx, ny, left, top, width, height))
|
|
return
|
|
}
|
|
guiLog(fmt.Sprintf("%s visible=%t minimized=%t rect=(%d,%d)-(%d,%d) screen=(%d,%d %dx%d)",
|
|
stage, visible, minimized, rect.Left, rect.Top, rect.Right, rect.Bottom, left, top, width, height))
|
|
}
|
|
|
|
func applyTopMost(hwnd uintptr, app *guiApp) {
|
|
if hwnd == 0 || app.topMost == app.lastTopMost {
|
|
return
|
|
}
|
|
if app.topMost {
|
|
setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags)
|
|
} else {
|
|
setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpFlags)
|
|
}
|
|
app.lastTopMost = app.topMost
|
|
}
|
|
|
|
func createAppFont(size int) uintptr {
|
|
if createFontProc == nil {
|
|
return 0
|
|
}
|
|
h, _, _ := createFontProc.Call(
|
|
uintptr(-size), 0, 0, 0, 400, 0, 0, 0,
|
|
128, 0, 0, 0, 0,
|
|
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Yu Gothic UI"))),
|
|
)
|
|
return h
|
|
}
|
|
|
|
func applyFont(app *guiApp) {
|
|
if sendMessageProc == nil || app.hFont == 0 {
|
|
return
|
|
}
|
|
targets := []uintptr{app.leftHwnd, app.rightHwnd, app.tabJoin, app.tabTranslate, app.tabSettings}
|
|
for _, hwnd := range targets {
|
|
if hwnd == 0 {
|
|
continue
|
|
}
|
|
sendMessageProc.Call(hwnd, wmSetFont, app.hFont, 1)
|
|
}
|
|
}
|
|
|
|
func allowRapidSettingsAction(app *guiApp) bool {
|
|
if app == nil {
|
|
return false
|
|
}
|
|
now := time.Now()
|
|
if !app.lastSettingsAction.IsZero() && now.Sub(app.lastSettingsAction) < 150*time.Millisecond {
|
|
guiLog("settings action skipped: debounce")
|
|
return false
|
|
}
|
|
app.lastSettingsAction = now
|
|
return true
|
|
}
|
|
|
|
func currentUsersFromJoinLeave() ([]userState, int) {
|
|
if snap := readGuestSnapshot(); len(snap) > 0 {
|
|
out := make([]userState, 0, len(snap))
|
|
for _, s := range snap {
|
|
out = append(out, userState{
|
|
Name: s.Name,
|
|
Present: s.Present,
|
|
LastJoin: s.LastJoin,
|
|
LastLeave: s.LastLeave,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Present != out[j].Present {
|
|
return out[i].Present
|
|
}
|
|
if out[i].LastLeave.Equal(out[j].LastLeave) {
|
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
|
}
|
|
return out[i].LastLeave.After(out[j].LastLeave)
|
|
})
|
|
guiLog(fmt.Sprintf("currentUsers source=snapshot count=%d total=%d", countPresent(out), len(out)))
|
|
return out, countPresent(out)
|
|
}
|
|
|
|
if out, ok := currentUsersFromJoinLeaveLog(); ok {
|
|
guiLog(fmt.Sprintf("currentUsers source=join_leave count=%d total=%d", countPresent(out), len(out)))
|
|
return out, countPresent(out)
|
|
}
|
|
|
|
if out, ok := currentUsersFromVRChatLog(); ok {
|
|
guiLog(fmt.Sprintf("currentUsers source=vrc_log count=%d total=%d", countPresent(out), len(out)))
|
|
return out, countPresent(out)
|
|
}
|
|
|
|
guiLog("currentUsers source=none")
|
|
return nil, 0
|
|
}
|
|
|
|
func currentUsersFromJoinLeaveLog() ([]userState, bool) {
|
|
p := filepath.Join(runtimeDir(), "join_leave.log")
|
|
b, err := os.ReadFile(p)
|
|
if err != nil || len(b) == 0 {
|
|
return nil, false
|
|
}
|
|
users := map[string]*userState{}
|
|
lines := strings.Split(string(b), "\n")
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" || strings.HasPrefix(line, "VRC JOIN/LEAVE") {
|
|
continue
|
|
}
|
|
at, kind, name := parseJoinLeaveLine(line)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
u := users[name]
|
|
if u == nil {
|
|
u = &userState{Name: name}
|
|
users[name] = u
|
|
}
|
|
switch kind {
|
|
case "join":
|
|
u.Present = true
|
|
if !at.IsZero() {
|
|
u.LastJoin = at
|
|
}
|
|
case "leave":
|
|
u.Present = false
|
|
if !at.IsZero() {
|
|
u.LastLeave = at
|
|
}
|
|
}
|
|
}
|
|
return sortUserStates(users), len(users) > 0
|
|
}
|
|
|
|
func currentUsersFromVRChatLog() ([]userState, bool) {
|
|
logPath, err := findLatestVRChatLog()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
b, err := os.ReadFile(logPath)
|
|
if err != nil || len(b) == 0 {
|
|
return nil, false
|
|
}
|
|
text := app.DecodeVRChatLog(b)
|
|
if strings.TrimSpace(text) == "" {
|
|
return nil, false
|
|
}
|
|
users := map[string]*userState{}
|
|
lines := strings.Split(text, "\n")
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "Entering Room") || strings.Contains(line, "Joining or Creating Room") {
|
|
users = map[string]*userState{}
|
|
continue
|
|
}
|
|
at := parseLogTime(line)
|
|
if m := vrcJoinPattern.FindStringSubmatch(line); len(m) == 2 {
|
|
name := strings.TrimSpace(m[1])
|
|
u := users[name]
|
|
if u == nil {
|
|
u = &userState{Name: name}
|
|
users[name] = u
|
|
}
|
|
u.Present = true
|
|
if !at.IsZero() {
|
|
u.LastJoin = at
|
|
}
|
|
continue
|
|
}
|
|
if m := vrcLeftPattern.FindStringSubmatch(line); len(m) == 2 {
|
|
name := strings.TrimSpace(m[1])
|
|
u := users[name]
|
|
if u == nil {
|
|
u = &userState{Name: name}
|
|
users[name] = u
|
|
}
|
|
u.Present = false
|
|
if !at.IsZero() {
|
|
u.LastLeave = at
|
|
}
|
|
}
|
|
}
|
|
return sortUserStates(users), len(users) > 0
|
|
}
|
|
|
|
func sortUserStates(users map[string]*userState) []userState {
|
|
out := make([]userState, 0, len(users))
|
|
for _, u := range users {
|
|
out = append(out, *u)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Present != out[j].Present {
|
|
return out[i].Present
|
|
}
|
|
if out[i].LastLeave.Equal(out[j].LastLeave) {
|
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
|
}
|
|
return out[i].LastLeave.After(out[j].LastLeave)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func parseLogTime(line string) time.Time {
|
|
m := vrcLogTimePattern.FindStringSubmatch(line)
|
|
if len(m) != 2 {
|
|
return time.Time{}
|
|
}
|
|
at, err := time.Parse("2006.01.02 15:04:05", m[1])
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return at
|
|
}
|
|
|
|
func findLatestVRChatLog() (string, error) {
|
|
dir := filepath.Join(os.Getenv("USERPROFILE"), "AppData", "LocalLow", "VRChat", "VRChat")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var latest string
|
|
var latestMod time.Time
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if info.ModTime().After(latestMod) {
|
|
latestMod = info.ModTime()
|
|
latest = filepath.Join(dir, name)
|
|
}
|
|
}
|
|
if latest == "" {
|
|
return "", os.ErrNotExist
|
|
}
|
|
return latest, nil
|
|
}
|
|
|
|
func readGuestSnapshot() []app.GuestStatus {
|
|
p := filepath.Join(runtimeDir(), "guest_snapshot.json")
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []app.GuestStatus
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatGuestPane(title string, items []userState) string {
|
|
var b strings.Builder
|
|
selfName := currentSelfName()
|
|
var selfJoin string
|
|
guiLog("formatGuestPane start")
|
|
b.WriteString(title)
|
|
b.WriteString("\r\n")
|
|
b.WriteString(strings.Repeat("=", len(title)))
|
|
b.WriteString("\r\n")
|
|
if len(items) == 0 {
|
|
b.WriteString("Current in room: 0")
|
|
b.WriteString("\r\n")
|
|
b.WriteString("(none)")
|
|
return b.String()
|
|
}
|
|
b.WriteString("Current in room: ")
|
|
b.WriteString(strconv.Itoa(countPresent(items)))
|
|
b.WriteString("\r\n\r\n")
|
|
present := make([]userState, 0, len(items))
|
|
left := make([]userState, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Present {
|
|
present = append(present, item)
|
|
} else {
|
|
left = append(left, item)
|
|
}
|
|
if selfName != "" && strings.EqualFold(strings.TrimSpace(item.Name), selfName) && !item.LastJoin.IsZero() {
|
|
selfJoin = item.LastJoin.Format("15:04:05")
|
|
}
|
|
}
|
|
if selfJoin != "" {
|
|
b.WriteString("You joined at ")
|
|
b.WriteString(selfJoin)
|
|
b.WriteString(" (self=")
|
|
b.WriteString(selfName)
|
|
b.WriteString(")")
|
|
b.WriteString("\r\n\r\n")
|
|
}
|
|
if len(present) > 0 {
|
|
b.WriteString("Present now")
|
|
b.WriteString("\r\n")
|
|
for _, item := range present {
|
|
b.WriteString("・ ")
|
|
b.WriteString(item.Name)
|
|
b.WriteString(" [present]")
|
|
if !item.LastJoin.IsZero() {
|
|
b.WriteString(" ")
|
|
b.WriteString(item.LastJoin.Format("15:04:05"))
|
|
}
|
|
b.WriteString("\r\n")
|
|
}
|
|
b.WriteString("\r\n")
|
|
}
|
|
if len(left) > 0 {
|
|
b.WriteString("Left already")
|
|
b.WriteString("\r\n")
|
|
sort.SliceStable(left, func(i, j int) bool {
|
|
if left[i].LastLeave.Equal(left[j].LastLeave) {
|
|
return strings.ToLower(left[i].Name) < strings.ToLower(left[j].Name)
|
|
}
|
|
return left[i].LastLeave.After(left[j].LastLeave)
|
|
})
|
|
for _, item := range left {
|
|
b.WriteString("・ ")
|
|
b.WriteString(item.Name)
|
|
b.WriteString(" [gray]")
|
|
if !item.LastLeave.IsZero() {
|
|
b.WriteString(" ")
|
|
b.WriteString(timeAgo(item.LastLeave))
|
|
}
|
|
b.WriteString("\r\n")
|
|
}
|
|
}
|
|
text := strings.TrimRight(b.String(), "\r\n")
|
|
guiLog("formatGuestPane done")
|
|
if err := app.AppendDesktopJoinLog(title, currentWorldLabel(), text); err != nil {
|
|
_ = app.AppendRuntimeLog("GUI JOIN LOG WRITE FAIL", err.Error())
|
|
} else {
|
|
_ = app.AppendRuntimeLog("GUI JOIN LOG WRITE OK", "written desktop join.txt")
|
|
}
|
|
return text
|
|
}
|
|
|
|
func currentSelfName() string {
|
|
if cfg, err := config.Load(""); err == nil && cfg != nil {
|
|
if name := strings.TrimSpace(cfg.VrcLog.SelfName); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
cfg, err := config.Load(filepath.Join(cwd, "config", "config.toml"))
|
|
if err != nil || cfg == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(cfg.VrcLog.SelfName)
|
|
}
|
|
|
|
func loadGUISettings() config.GUIConfig {
|
|
cfg, err := config.Load("")
|
|
if err != nil || cfg == nil {
|
|
return config.GUIConfig{ActiveWindow: true, FontSize: 18}
|
|
}
|
|
if cfg.GUI.FontSize <= 0 {
|
|
cfg.GUI.FontSize = 18
|
|
}
|
|
return cfg.GUI
|
|
}
|
|
|
|
func saveGUISettings(app *guiApp) {
|
|
if app == nil {
|
|
return
|
|
}
|
|
guiCfg := config.GUIConfig{
|
|
ActiveWindow: app.activeWindow,
|
|
TopMost: app.topMost,
|
|
FontSize: app.fontSize,
|
|
}
|
|
if err := config.SaveGUI("", guiCfg); err != nil {
|
|
guiLog("saveGUISettings failed: " + err.Error())
|
|
return
|
|
}
|
|
guiLog("saveGUISettings ok")
|
|
}
|
|
|
|
func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int) string {
|
|
var b strings.Builder
|
|
switch tab {
|
|
case "discord":
|
|
b.WriteString("Discord")
|
|
case "translate":
|
|
b.WriteString("Translate")
|
|
case "settings":
|
|
b.WriteString("Settings")
|
|
default:
|
|
b.WriteString("Current World")
|
|
}
|
|
b.WriteString("\r\n")
|
|
titleLine := "Current World"
|
|
switch tab {
|
|
case "translate":
|
|
titleLine = "Translate"
|
|
case "settings":
|
|
titleLine = "Settings"
|
|
case "discord":
|
|
titleLine = "Discord"
|
|
}
|
|
b.WriteString(strings.Repeat("=", len(titleLine)))
|
|
b.WriteString("\r\n")
|
|
ocr, _ := state["ocr"].(string)
|
|
translate, _ := state["translate"].(string)
|
|
fontSize, _ := state["font_size"].(int)
|
|
switch tab {
|
|
case "translate":
|
|
b.WriteString("Translate: ")
|
|
if strings.TrimSpace(translate) == "" {
|
|
b.WriteString("OFF")
|
|
} else {
|
|
b.WriteString("ON")
|
|
}
|
|
b.WriteString("\r\nOCR: ")
|
|
if strings.TrimSpace(ocr) == "" {
|
|
b.WriteString("OFF")
|
|
} else {
|
|
b.WriteString("ON")
|
|
}
|
|
b.WriteString("\r\nWorld: ")
|
|
b.WriteString(worldLabel)
|
|
case "settings":
|
|
b.WriteString("Active window: ")
|
|
if stateBool(state, "active_window") {
|
|
b.WriteString("ON")
|
|
} else {
|
|
b.WriteString("OFF")
|
|
}
|
|
b.WriteString("\r\nTop most: ")
|
|
if stateBool(state, "top_most") {
|
|
b.WriteString("ON")
|
|
} else {
|
|
b.WriteString("OFF")
|
|
}
|
|
b.WriteString("\r\nFont size: ")
|
|
if fontSize == 0 {
|
|
fontSize = 18
|
|
}
|
|
b.WriteString(strconv.Itoa(fontSize))
|
|
default:
|
|
b.WriteString("World: ")
|
|
b.WriteString(worldLabel)
|
|
b.WriteString("\r\nUsers: ")
|
|
b.WriteString(fmt.Sprintf("%d", currentUsers))
|
|
b.WriteString("\r\nOCR: ")
|
|
if strings.TrimSpace(ocr) == "" {
|
|
b.WriteString("OFF")
|
|
} else {
|
|
b.WriteString("ON")
|
|
}
|
|
b.WriteString("\r\nTranslate: ")
|
|
if strings.TrimSpace(translate) == "" {
|
|
b.WriteString("OFF")
|
|
} else {
|
|
b.WriteString("ON")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func stateBool(state map[string]any, key string) bool {
|
|
v, _ := state[key].(bool)
|
|
return v
|
|
}
|
|
|
|
func currentWorldLabel() string {
|
|
p := filepath.Join(runtimeDir(), "runtime.log")
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
lines := strings.Split(string(b), "\n")
|
|
var latest string
|
|
for i := 0; i < len(lines); i++ {
|
|
if !strings.Contains(lines[i], "VRC WORLD") {
|
|
continue
|
|
}
|
|
if i+1 < len(lines) {
|
|
latest = strings.TrimSpace(lines[i+1])
|
|
}
|
|
}
|
|
return latest
|
|
}
|
|
|
|
func readRuntimeSnapshot() map[string]any {
|
|
p := filepath.Join(runtimeDir(), "state.json")
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return map[string]any{}
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(b, &out)
|
|
return out
|
|
}
|
|
|
|
func currentWorldStartTime() time.Time {
|
|
p := filepath.Join(runtimeDir(), "runtime.log")
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
var latest time.Time
|
|
for _, raw := range strings.Split(string(b), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if !(strings.Contains(line, "VRC WORLD") ||
|
|
strings.Contains(line, "Joining or Creating Room") ||
|
|
strings.Contains(line, "world loaded") ||
|
|
strings.Contains(line, "Successfully joined room")) {
|
|
continue
|
|
}
|
|
if at, ok := parseRuntimeTime(line); ok && at.After(latest) {
|
|
latest = at
|
|
}
|
|
}
|
|
return latest
|
|
}
|
|
|
|
func parseJoinLeaveLine(line string) (time.Time, string, string) {
|
|
m := joinLeaveLinePattern.FindStringSubmatch(line)
|
|
if len(m) != 4 {
|
|
return time.Time{}, "", ""
|
|
}
|
|
at, err := time.Parse("2006-01-02 15:04:05", m[1])
|
|
if err != nil {
|
|
return time.Time{}, "", ""
|
|
}
|
|
return at, m[2], strings.TrimSpace(m[3])
|
|
}
|
|
|
|
func parseJoinLeaveCount(line string) int {
|
|
m := regexp.MustCompile(`\((\d+)\)$`).FindStringSubmatch(line)
|
|
if len(m) != 2 {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(m[1])
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func parseRuntimeTime(line string) (time.Time, bool) {
|
|
m := runtimeTimePattern.FindStringSubmatch(line)
|
|
if len(m) != 2 {
|
|
return time.Time{}, false
|
|
}
|
|
at, err := time.Parse("2006-01-02 15:04:05", m[1])
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return at, true
|
|
}
|
|
|
|
func countPresent(items []userState) int {
|
|
n := 0
|
|
for _, item := range items {
|
|
if item.Present {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func timeAgo(at time.Time) string {
|
|
if at.IsZero() {
|
|
return ""
|
|
}
|
|
d := time.Since(at)
|
|
if d < time.Minute {
|
|
return "0分前"
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%d分前", int(d.Minutes()))
|
|
}
|
|
if d < 24*time.Hour {
|
|
return fmt.Sprintf("%d時間前", int(d.Hours()))
|
|
}
|
|
return fmt.Sprintf("%d日前", int(d.Hours()/24))
|
|
}
|