//go:build windows package main import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "syscall" "time" "unsafe" "vrc_osc_go/internal/app" ) const ( wsOverlappedWindow = 0x00CF0000 wsVisible = 0x10000000 wsChild = 0x40000000 wsBorder = 0x00800000 esMultiline = 0x0004 esReadonly = 0x0800 esAutovscroll = 0x0040 esAutohscroll = 0x0080 wmCreateGUI = 0x0001 wmDestroyGUI = 0x0002 wmTimerGUI = 0x0113 wmCommandGUI = 0x0111 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})\]`) type guiApp struct { hwnd uintptr leftHwnd uintptr rightHwnd uintptr tabJoin uintptr tabDiscord uintptr tabTranslate uintptr activeTab string 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") registerClass := user32.NewProc("RegisterClassW") createWindowEx := user32.NewProc("CreateWindowExW") defWindowProc := user32.NewProc("DefWindowProcW") showWindow := user32.NewProc("ShowWindow") updateWindow := user32.NewProc("UpdateWindow") getMessage := user32.NewProc("GetMessageW") translateMessage := user32.NewProc("TranslateMessage") dispatchMessage := user32.NewProc("DispatchMessageW") postQuitMessage := user32.NewProc("PostQuitMessage") setTimer := user32.NewProc("SetTimer") loadCursor := user32.NewProc("LoadCursorW") setWindowText := user32.NewProc("SetWindowTextW") 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 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") leftTitle, _ := syscall.UTF16PtrFromString("") rightTitle, _ := syscall.UTF16PtrFromString("") joinTitle, _ := syscall.UTF16PtrFromString("Join Log") discordTitle, _ := syscall.UTF16PtrFromString("Discord") translateTitle, _ := syscall.UTF16PtrFromString("Translate") 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.activeTab = "join" setTimer.Call(hwnd, timerRefresh, 2000, 0) refreshGUI(setWindowText, &app) return 0 case wmCommandGUI: switch uint16(wParam & 0xffff) { case 3001: app.activeTab = "join" case 3002: app.activeTab = "discord" case 3003: app.activeTab = "translate" } refreshGUI(setWindowText, &app) return 0 case wmTimerGUI: refreshGUI(setWindowText, &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, 200, 120, 760, 380, 0, 0, hInstance, 0) if app.hwnd == 0 { guiLog("stage=create window failed") return fmt.Errorf("create window failed") } guiLog("stage=show window") showWindow.Call(app.hwnd, 5) updateWindow.Call(app.hwnd) 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 refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { current, instanceCount := currentUsersFromJoinLeave() 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.rightHwnd != 0 { t, _ := syscall.UTF16PtrFromString(formatRightPane(app.activeTab, readRuntimeSnapshot(), currentWorldLabel(), instanceCount)) setWindowText.Call(app.rightHwnd, uintptr(unsafe.Pointer(t))) } } 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{ 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) } sort.Slice(out, func(i, j int) bool { if out[i].Present != out[j].Present { return out[i].Present } return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out, countPresent(out) } for _, raw := range strings.Split(string(b), "\n") { line := strings.TrimSpace(raw) if line == "" { continue } if c := parseJoinLeaveCount(line); c > 0 { lastLoggedCount = c } 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} users[name] = u } switch kind { case "join": u.Present = true if at.After(u.LastJoin) { u.LastJoin = at } case "leave": u.Present = false if at.After(u.LastLeave) { u.LastLeave = at } } } // Keep any snapshot-marked present users that the join/leave delta can't rebuild. for _, snap := range readGuestSnapshot() { if !snap.Present { continue } u := users[snap.Name] if u == nil { users[snap.Name] = &userState{ Name: snap.Name, Present: true, LastJoin: snap.LastJoin, } continue } if !u.Present { u.Present = true if u.LastJoin.IsZero() { u.LastJoin = snap.LastJoin } } } 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 } return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) present := countPresent(out) if lastLoggedCount > present { present = lastLoggedCount } return out, present } 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 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") for _, item := range items { b.WriteString("・ ") b.WriteString(item.Name) if item.Present { b.WriteString(" [present]") } else { 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") } 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") default: b.WriteString("Current World") } b.WriteString("\r\n") titleLine := "Current World" switch tab { case "discord": titleLine = "Discord" case "translate": titleLine = "Translate" } b.WriteString(strings.Repeat("=", len(titleLine))) b.WriteString("\r\n") discordMuted, _ := state["discord_muted"].(bool) ocr, _ := state["ocr"].(string) translate, _ := state["translate"].(string) 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) b.WriteString("\r\nOCR: ") b.WriteString(ocr) b.WriteString("\r\nWorld: ") b.WriteString(worldLabel) 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) b.WriteString("\r\nTranslate: ") b.WriteString(translate) } return b.String() } 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 == "" || !strings.Contains(line, "VRC WORLD") { 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)) }