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"
"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
@@ -31,6 +34,7 @@ const (
wmDestroyGUI = 0x0002
wmTimerGUI = 0x0113
wmCommandGUI = 0x0111
wmSetFont = 0x0030
idStatus = 2001
idLog = 2002
timerRefresh = 1
@@ -38,15 +42,50 @@ const (
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
tabDiscord 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
}
@@ -72,18 +111,30 @@ func guiLog(text string) {
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")
showWindow := user32.NewProc("ShowWindow")
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")
@@ -116,39 +167,108 @@ func runNativeGUI() error {
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("EDIT")
rightClass, _ := syscall.UTF16PtrFromString("EDIT")
leftClass, _ := syscall.UTF16PtrFromString("STATIC")
rightClass, _ := syscall.UTF16PtrFromString("STATIC")
leftTitle, _ := syscall.UTF16PtrFromString("")
rightTitle, _ := syscall.UTF16PtrFromString("")
joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
discordTitle, _ := syscall.UTF16PtrFromString("Discord")
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.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, 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.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.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 = "discord"
case 3003:
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)
@@ -174,15 +294,20 @@ func runNativeGUI() error {
}
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 {
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")
showWindow.Call(app.hwnd, 5)
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
@@ -197,8 +322,38 @@ func runNativeGUI() error {
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
}
@@ -209,59 +364,264 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
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 {
t, _ := syscall.UTF16PtrFromString(formatRightPane(app.activeTab, readRuntimeSnapshot(), currentWorldLabel(), instanceCount))
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) {
since := currentWorldStartTime()
users := map[string]*userState{}
lastLoggedCount := 0
for _, s := range readGuestSnapshot() {
if !s.Present {
continue
}
cp := s
users[s.Name] = &userState{
if snap := readGuestSnapshot(); len(snap) > 0 {
out := make([]userState, 0, len(snap))
for _, s := range snap {
out = append(out, userState{
Name: s.Name,
Present: true,
LastJoin: cp.LastJoin,
}
}
p := filepath.Join(runtimeDir(), "join_leave.log")
b, err := os.ReadFile(p)
if err != nil {
out := make([]userState, 0, len(users))
for _, u := range users {
out = append(out, *u)
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)
}
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
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 c := parseJoinLeaveCount(line); c > 0 {
lastLoggedCount = c
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
}
if !since.IsZero() && at.Before(since) {
continue
}
u := users[name]
if u == nil {
u = &userState{Name: name}
@@ -270,39 +630,74 @@ func currentUsersFromJoinLeave() ([]userState, int) {
switch kind {
case "join":
u.Present = true
if at.After(u.LastJoin) {
if !at.IsZero() {
u.LastJoin = at
}
case "leave":
u.Present = false
if at.After(u.LastLeave) {
if !at.IsZero() {
u.LastLeave = at
}
}
}
return sortUserStates(users), len(users) > 0
}
// Keep any snapshot-marked present users that the join/leave delta can't rebuild.
for _, snap := range readGuestSnapshot() {
if !snap.Present {
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
}
u := users[snap.Name]
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 {
users[snap.Name] = &userState{
Name: snap.Name,
Present: true,
LastJoin: snap.LastJoin,
u = &userState{Name: name}
users[name] = u
}
u.Present = true
if !at.IsZero() {
u.LastJoin = at
}
continue
}
if !u.Present {
u.Present = true
if u.LastJoin.IsZero() {
u.LastJoin = snap.LastJoin
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)
@@ -311,13 +706,55 @@ func currentUsersFromJoinLeave() ([]userState, int) {
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)
})
present := countPresent(out)
if lastLoggedCount > present {
present = lastLoggedCount
}
return out, present
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 {
@@ -335,6 +772,9 @@ func readGuestSnapshot() []app.GuestStatus {
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)))
@@ -348,21 +788,113 @@ func formatGuestPane(title string, items []userState) 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)
if item.Present {
b.WriteString(" [present]")
} else {
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")
}
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 {
@@ -372,52 +904,86 @@ func formatRightPane(tab string, state map[string]any, worldLabel string, curren
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 "discord":
titleLine = "Discord"
case "translate":
titleLine = "Translate"
case "settings":
titleLine = "Settings"
case "discord":
titleLine = "Discord"
}
b.WriteString(strings.Repeat("=", len(titleLine)))
b.WriteString("\r\n")
discordMuted, _ := state["discord_muted"].(bool)
ocr, _ := state["ocr"].(string)
translate, _ := state["translate"].(string)
fontSize, _ := state["font_size"].(int)
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":
b.WriteString("Translate: ")
b.WriteString(translate)
if strings.TrimSpace(translate) == "" {
b.WriteString("OFF")
} else {
b.WriteString("ON")
}
b.WriteString("\r\nOCR: ")
b.WriteString(ocr)
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\nDiscord muted: ")
b.WriteString(fmt.Sprintf("%v", discordMuted))
b.WriteString("\r\nOCR: ")
b.WriteString(ocr)
if strings.TrimSpace(ocr) == "" {
b.WriteString("OFF")
} else {
b.WriteString("ON")
}
b.WriteString("\r\nTranslate: ")
b.WriteString(translate)
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)
@@ -457,7 +1023,13 @@ func currentWorldStartTime() time.Time {
var latest time.Time
for _, raw := range strings.Split(string(b), "\n") {
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
}
if at, ok := parseRuntimeTime(line); ok && at.After(latest) {