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