Stabilize GUI settings controls

This commit is contained in:
every_holiday
2026-06-26 02:05:43 +09:00
parent 75f2d8b9c2
commit 4d77e67870

View File

@@ -16,12 +16,15 @@ import (
"unsafe" "unsafe"
"vrc_osc_go/internal/app" "vrc_osc_go/internal/app"
"vrc_osc_go/internal/config"
) )
const ( const (
wsOverlappedWindow = 0x00CF0000 wsOverlappedWindow = 0x00CF0000
wsVisible = 0x10000000 wsVisible = 0x10000000
wsChild = 0x40000000 wsChild = 0x40000000
wsPopup = 0x80000000
wsClipChildren = 0x02000000
wsBorder = 0x00800000 wsBorder = 0x00800000
esMultiline = 0x0004 esMultiline = 0x0004
esReadonly = 0x0800 esReadonly = 0x0800
@@ -31,6 +34,7 @@ const (
wmDestroyGUI = 0x0002 wmDestroyGUI = 0x0002
wmTimerGUI = 0x0113 wmTimerGUI = 0x0113
wmCommandGUI = 0x0111 wmCommandGUI = 0x0111
wmSetFont = 0x0030
idStatus = 2001 idStatus = 2001
idLog = 2002 idLog = 2002
timerRefresh = 1 timerRefresh = 1
@@ -38,22 +42,57 @@ const (
var joinLeaveLinePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`) 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 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 { type guiApp struct {
hwnd uintptr hwnd uintptr
leftHwnd uintptr leftHwnd uintptr
rightHwnd uintptr rightHwnd uintptr
tabJoin uintptr settingsPaneHwnd uintptr
tabDiscord uintptr tabJoin uintptr
tabTranslate uintptr tabTranslate uintptr
activeTab string tabSettings uintptr
lastInstanceCount int 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 { type userState struct {
Name string Name string
Present bool Present bool
LastJoin time.Time LastJoin time.Time
LastLeave time.Time LastLeave time.Time
} }
@@ -72,18 +111,30 @@ func guiLog(text string) {
func runNativeGUI() error { func runNativeGUI() error {
user32 := syscall.NewLazyDLL("user32.dll") user32 := syscall.NewLazyDLL("user32.dll")
kernel32 := syscall.NewLazyDLL("kernel32.dll") kernel32 := syscall.NewLazyDLL("kernel32.dll")
gdi32 := syscall.NewLazyDLL("gdi32.dll")
registerClass := user32.NewProc("RegisterClassW") registerClass := user32.NewProc("RegisterClassW")
createWindowEx := user32.NewProc("CreateWindowExW") createWindowEx := user32.NewProc("CreateWindowExW")
defWindowProc := user32.NewProc("DefWindowProcW") defWindowProc := user32.NewProc("DefWindowProcW")
showWindow := user32.NewProc("ShowWindow") showWindowProc = user32.NewProc("ShowWindow")
updateWindow := user32.NewProc("UpdateWindow") updateWindow := user32.NewProc("UpdateWindow")
setWindowPosProc = user32.NewProc("SetWindowPos")
sendMessageProc = user32.NewProc("SendMessageW")
createFontProc = gdi32.NewProc("CreateFontW")
deleteObjectProc = gdi32.NewProc("DeleteObject")
getMessage := user32.NewProc("GetMessageW") getMessage := user32.NewProc("GetMessageW")
translateMessage := user32.NewProc("TranslateMessage") translateMessage := user32.NewProc("TranslateMessage")
dispatchMessage := user32.NewProc("DispatchMessageW") dispatchMessage := user32.NewProc("DispatchMessageW")
postQuitMessage := user32.NewProc("PostQuitMessage") postQuitMessage := user32.NewProc("PostQuitMessage")
setForegroundWindow := user32.NewProc("SetForegroundWindow")
bringWindowToTop := user32.NewProc("BringWindowToTop")
setTimer := user32.NewProc("SetTimer") setTimer := user32.NewProc("SetTimer")
loadCursor := user32.NewProc("LoadCursorW") loadCursor := user32.NewProc("LoadCursorW")
setWindowText := user32.NewProc("SetWindowTextW") 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") getModuleHandle := kernel32.NewProc("GetModuleHandleW")
loadIcon := user32.NewProc("LoadIconW") loadIcon := user32.NewProc("LoadIconW")
@@ -116,39 +167,108 @@ func runNativeGUI() error {
guiLog("stage=prepare window") guiLog("stage=prepare window")
var app guiApp 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 { wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
switch message { switch message {
case wmCreateGUI: case wmCreateGUI:
buttonClass, _ := syscall.UTF16PtrFromString("BUTTON") buttonClass, _ := syscall.UTF16PtrFromString("BUTTON")
leftClass, _ := syscall.UTF16PtrFromString("EDIT") leftClass, _ := syscall.UTF16PtrFromString("STATIC")
rightClass, _ := syscall.UTF16PtrFromString("EDIT") rightClass, _ := syscall.UTF16PtrFromString("STATIC")
leftTitle, _ := syscall.UTF16PtrFromString("") leftTitle, _ := syscall.UTF16PtrFromString("")
rightTitle, _ := syscall.UTF16PtrFromString("") rightTitle, _ := syscall.UTF16PtrFromString("")
joinTitle, _ := syscall.UTF16PtrFromString("Join Log") joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
discordTitle, _ := syscall.UTF16PtrFromString("Discord")
translateTitle, _ := syscall.UTF16PtrFromString("Translate") 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.tabJoin, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(joinTitle)), wsVisible|wsChild, 370, 20, 90, 28, hwnd, 3001, hInstance, 0)
app.tabDiscord, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(discordTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, 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.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, 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|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 20, 20, 330, 300, hwnd, idStatus, 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|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 370, 60, 330, 260, hwnd, idLog, 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.activeTab = "join"
app.activeWindow = true
app.lastActiveWindow = false
setTimer.Call(hwnd, timerRefresh, 2000, 0) setTimer.Call(hwnd, timerRefresh, 2000, 0)
applyFont(&app)
refreshGUI(setWindowText, &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 return 0
case wmCommandGUI: case wmCommandGUI:
switch uint16(wParam & 0xffff) { switch uint16(wParam & 0xffff) {
case 3001: case 3001:
app.activeTab = "join" app.activeTab = "join"
case 3002: case 3002:
app.activeTab = "discord"
case 3003:
app.activeTab = "translate" 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) refreshGUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
return 0 return 0
case wmTimerGUI: case wmTimerGUI:
refreshGUI(setWindowText, &app) refreshGUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
return 0 return 0
case wmDestroyGUI: case wmDestroyGUI:
postQuitMessage.Call(0) postQuitMessage.Call(0)
@@ -174,15 +294,20 @@ func runNativeGUI() error {
} }
guiLog("stage=create window") guiLog("stage=create window")
app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow, 200, 120, 760, 380, 0, 0, hInstance, 0) 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 { if app.hwnd == 0 {
guiLog("stage=create window failed") guiLog("stage=create window failed")
return fmt.Errorf("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") guiLog("stage=show window")
showWindow.Call(app.hwnd, 5) prevVisible, _, _ := showWindowProc.Call(app.hwnd, 1)
guiLog(fmt.Sprintf("stage=show window ret=%d", prevVisible))
updateWindow.Call(app.hwnd) 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") guiLog("stage=message loop")
var m msg var m msg
@@ -197,8 +322,38 @@ func runNativeGUI() error {
return nil 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) { func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
current, instanceCount := currentUsersFromJoinLeave() current, instanceCount := currentUsersFromJoinLeave()
guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount))
if instanceCount == 0 && app.lastInstanceCount > 0 { if instanceCount == 0 && app.lastInstanceCount > 0 {
instanceCount = app.lastInstanceCount instanceCount = app.lastInstanceCount
} }
@@ -209,59 +364,264 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current)) t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t))) 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 { if app.rightHwnd != 0 {
t, _ := syscall.UTF16PtrFromString(formatRightPane(app.activeTab, readRuntimeSnapshot(), currentWorldLabel(), instanceCount)) 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))) 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 currentUsersFromJoinLeave() ([]userState, int) { func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) {
since := currentWorldStartTime() if showWindowProc == nil || setWindowPosProc == nil || app == nil {
users := map[string]*userState{} return
lastLoggedCount := 0 }
for _, s := range readGuestSnapshot() { if app.activeTab == "settings" {
if !s.Present { if app.settingsPaneHwnd != 0 {
continue showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
} }
cp := s for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
users[s.Name] = &userState{ if hwnd != 0 {
Name: s.Name, showWindowProc.Call(hwnd, swShowControl)
Present: true, }
LastJoin: cp.LastJoin, }
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")
}
p := filepath.Join(runtimeDir(), "join_leave.log") func applyActiveWindow(hwnd uintptr, app *guiApp, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic *syscall.LazyProc) {
b, err := os.ReadFile(p) if hwnd == 0 || app.activeWindow == app.lastActiveWindow {
if err != nil { return
out := make([]userState, 0, len(users)) }
for _, u := range users { if app.activeWindow {
out = append(out, *u) 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 { sort.Slice(out, func(i, j int) bool {
if out[i].Present != out[j].Present { if out[i].Present != out[j].Present {
return out[i].Present return out[i].Present
} }
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) 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) return out, countPresent(out)
} }
for _, raw := range strings.Split(string(b), "\n") { 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) line := strings.TrimSpace(raw)
if line == "" { if line == "" || strings.HasPrefix(line, "VRC JOIN/LEAVE") {
continue continue
} }
if c := parseJoinLeaveCount(line); c > 0 {
lastLoggedCount = c
}
at, kind, name := parseJoinLeaveLine(line) at, kind, name := parseJoinLeaveLine(line)
if name == "" { if name == "" {
continue continue
} }
if !since.IsZero() && at.Before(since) {
continue
}
u := users[name] u := users[name]
if u == nil { if u == nil {
u = &userState{Name: name} u = &userState{Name: name}
@@ -270,39 +630,74 @@ func currentUsersFromJoinLeave() ([]userState, int) {
switch kind { switch kind {
case "join": case "join":
u.Present = true u.Present = true
if at.After(u.LastJoin) { if !at.IsZero() {
u.LastJoin = at u.LastJoin = at
} }
case "leave": case "leave":
u.Present = false u.Present = false
if at.After(u.LastLeave) { if !at.IsZero() {
u.LastLeave = at u.LastLeave = at
} }
} }
} }
return sortUserStates(users), len(users) > 0
}
// Keep any snapshot-marked present users that the join/leave delta can't rebuild. func currentUsersFromVRChatLog() ([]userState, bool) {
for _, snap := range readGuestSnapshot() { logPath, err := findLatestVRChatLog()
if !snap.Present { 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 continue
} }
u := users[snap.Name] if strings.Contains(line, "Entering Room") || strings.Contains(line, "Joining or Creating Room") {
if u == nil { users = map[string]*userState{}
users[snap.Name] = &userState{ continue
Name: snap.Name, }
Present: true, at := parseLogTime(line)
LastJoin: snap.LastJoin, 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 continue
} }
if !u.Present { if m := vrcLeftPattern.FindStringSubmatch(line); len(m) == 2 {
u.Present = true name := strings.TrimSpace(m[1])
if u.LastJoin.IsZero() { u := users[name]
u.LastJoin = snap.LastJoin 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)) out := make([]userState, 0, len(users))
for _, u := range users { for _, u := range users {
out = append(out, *u) out = append(out, *u)
@@ -311,13 +706,55 @@ func currentUsersFromJoinLeave() ([]userState, int) {
if out[i].Present != out[j].Present { if out[i].Present != out[j].Present {
return out[i].Present return out[i].Present
} }
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) 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)
}) })
present := countPresent(out) return out
if lastLoggedCount > present { }
present = lastLoggedCount
func parseLogTime(line string) time.Time {
m := vrcLogTimePattern.FindStringSubmatch(line)
if len(m) != 2 {
return time.Time{}
} }
return out, present 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 { func readGuestSnapshot() []app.GuestStatus {
@@ -335,6 +772,9 @@ func readGuestSnapshot() []app.GuestStatus {
func formatGuestPane(title string, items []userState) string { func formatGuestPane(title string, items []userState) string {
var b strings.Builder var b strings.Builder
selfName := currentSelfName()
var selfJoin string
guiLog("formatGuestPane start")
b.WriteString(title) b.WriteString(title)
b.WriteString("\r\n") b.WriteString("\r\n")
b.WriteString(strings.Repeat("=", len(title))) b.WriteString(strings.Repeat("=", len(title)))
@@ -348,21 +788,113 @@ func formatGuestPane(title string, items []userState) string {
b.WriteString("Current in room: ") b.WriteString("Current in room: ")
b.WriteString(strconv.Itoa(countPresent(items))) b.WriteString(strconv.Itoa(countPresent(items)))
b.WriteString("\r\n\r\n") b.WriteString("\r\n\r\n")
present := make([]userState, 0, len(items))
left := make([]userState, 0, len(items))
for _, item := range items { for _, item := range items {
b.WriteString("・ ")
b.WriteString(item.Name)
if item.Present { if item.Present {
b.WriteString(" [present]") present = append(present, item)
} else { } 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]") b.WriteString(" [gray]")
if !item.LastLeave.IsZero() { if !item.LastLeave.IsZero() {
b.WriteString(" ") b.WriteString(" ")
b.WriteString(timeAgo(item.LastLeave)) b.WriteString(timeAgo(item.LastLeave))
} }
b.WriteString("\r\n")
} }
b.WriteString("\r\n")
} }
return strings.TrimRight(b.String(), "\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 { func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int) string {
@@ -372,52 +904,86 @@ func formatRightPane(tab string, state map[string]any, worldLabel string, curren
b.WriteString("Discord") b.WriteString("Discord")
case "translate": case "translate":
b.WriteString("Translate") b.WriteString("Translate")
case "settings":
b.WriteString("Settings")
default: default:
b.WriteString("Current World") b.WriteString("Current World")
} }
b.WriteString("\r\n") b.WriteString("\r\n")
titleLine := "Current World" titleLine := "Current World"
switch tab { switch tab {
case "discord":
titleLine = "Discord"
case "translate": case "translate":
titleLine = "Translate" titleLine = "Translate"
case "settings":
titleLine = "Settings"
case "discord":
titleLine = "Discord"
} }
b.WriteString(strings.Repeat("=", len(titleLine))) b.WriteString(strings.Repeat("=", len(titleLine)))
b.WriteString("\r\n") b.WriteString("\r\n")
discordMuted, _ := state["discord_muted"].(bool)
ocr, _ := state["ocr"].(string) ocr, _ := state["ocr"].(string)
translate, _ := state["translate"].(string) translate, _ := state["translate"].(string)
fontSize, _ := state["font_size"].(int)
switch tab { switch tab {
case "discord":
b.WriteString("Muted: ")
b.WriteString(fmt.Sprintf("%v", discordMuted))
b.WriteString("\r\nWorld: ")
b.WriteString(worldLabel)
b.WriteString("\r\nUsers: ")
b.WriteString(fmt.Sprintf("%d", currentUsers))
case "translate": case "translate":
b.WriteString("Translate: ") b.WriteString("Translate: ")
b.WriteString(translate) if strings.TrimSpace(translate) == "" {
b.WriteString("OFF")
} else {
b.WriteString("ON")
}
b.WriteString("\r\nOCR: ") b.WriteString("\r\nOCR: ")
b.WriteString(ocr) if strings.TrimSpace(ocr) == "" {
b.WriteString("OFF")
} else {
b.WriteString("ON")
}
b.WriteString("\r\nWorld: ") b.WriteString("\r\nWorld: ")
b.WriteString(worldLabel) 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: default:
b.WriteString("World: ") b.WriteString("World: ")
b.WriteString(worldLabel) b.WriteString(worldLabel)
b.WriteString("\r\nUsers: ") b.WriteString("\r\nUsers: ")
b.WriteString(fmt.Sprintf("%d", currentUsers)) b.WriteString(fmt.Sprintf("%d", currentUsers))
b.WriteString("\r\nDiscord muted: ")
b.WriteString(fmt.Sprintf("%v", discordMuted))
b.WriteString("\r\nOCR: ") b.WriteString("\r\nOCR: ")
b.WriteString(ocr) if strings.TrimSpace(ocr) == "" {
b.WriteString("OFF")
} else {
b.WriteString("ON")
}
b.WriteString("\r\nTranslate: ") b.WriteString("\r\nTranslate: ")
b.WriteString(translate) if strings.TrimSpace(translate) == "" {
b.WriteString("OFF")
} else {
b.WriteString("ON")
}
} }
return b.String() return b.String()
} }
func stateBool(state map[string]any, key string) bool {
v, _ := state[key].(bool)
return v
}
func currentWorldLabel() string { func currentWorldLabel() string {
p := filepath.Join(runtimeDir(), "runtime.log") p := filepath.Join(runtimeDir(), "runtime.log")
b, err := os.ReadFile(p) b, err := os.ReadFile(p)
@@ -457,7 +1023,13 @@ func currentWorldStartTime() time.Time {
var latest time.Time var latest time.Time
for _, raw := range strings.Split(string(b), "\n") { for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw) line := strings.TrimSpace(raw)
if line == "" || !strings.Contains(line, "VRC WORLD") { 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 continue
} }
if at, ok := parseRuntimeTime(line); ok && at.After(latest) { if at, ok := parseRuntimeTime(line); ok && at.After(latest) {