Files
VRCWT-OSC/cmd/vrc_osc_gui/window_windows.go
2026-06-26 09:16:46 +09:00

1073 lines
30 KiB
Go

//go:build windows
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"vrc_osc_go/internal/app"
"vrc_osc_go/internal/config"
)
const (
wsOverlappedWindow = 0x00CF0000
wsVisible = 0x10000000
wsChild = 0x40000000
wsPopup = 0x80000000
wsClipChildren = 0x02000000
wsBorder = 0x00800000
esMultiline = 0x0004
esReadonly = 0x0800
esAutovscroll = 0x0040
esAutohscroll = 0x0080
wmCreateGUI = 0x0001
wmDestroyGUI = 0x0002
wmTimerGUI = 0x0113
wmCommandGUI = 0x0111
wmSetFont = 0x0030
idStatus = 2001
idLog = 2002
timerRefresh = 1
)
var joinLeaveLinePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]`)
var vrcLogTimePattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})`)
var vrcJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
var vrcLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
var hwndTopMostFlag uintptr = ^uintptr(0)
var hwndNotTopMostFlag 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
btnTopMost uintptr
btnFontDown uintptr
btnFontUp uintptr
activeTab string
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")
setTimer := user32.NewProc("SetTimer")
loadCursor := user32.NewProc("LoadCursorW")
setWindowText := user32.NewProc("SetWindowTextW")
getWindowRect := user32.NewProc("GetWindowRect")
isWindowVisible := user32.NewProc("IsWindowVisible")
isIconic := user32.NewProc("IsIconic")
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.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")
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.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), wsVisible|wsChild, 390, 90, 120, 30, hwnd, 3005, hInstance, 0)
app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), wsVisible|wsChild, 390, 135, 50, 30, hwnd, 3006, hInstance, 0)
app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 135, 50, 30, hwnd, 3007, hInstance, 0)
app.activeTab = "join"
setTimer.Call(hwnd, timerRefresh, 2000, 0)
applyFont(&app)
refreshGUI(setWindowText, &app)
applyTopMost(hwnd, &app)
ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic)
return 0
case wmCommandGUI:
needsFullRefresh := false
switch uint16(wParam & 0xffff) {
case 3001:
app.activeTab = "join"
needsFullRefresh = true
case 3002:
app.activeTab = "translate"
needsFullRefresh = true
case 3003:
app.activeTab = "settings"
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)
}
}
if needsFullRefresh {
refreshGUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
} else {
refreshCommandUI(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.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.btnTopMost, app.btnFontDown, app.btnFontUp} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swShowControl)
}
}
return
}
if app.rightHwnd != 0 {
state := readRuntimeSnapshot()
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.btnTopMost, app.btnFontDown, app.btnFontUp} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl)
}
}
}
func refreshCommandUI(setWindowText *syscall.LazyProc, app *guiApp) {
if app == nil || setWindowText == nil {
return
}
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)))
}
}
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.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.btnTopMost, app.btnFontDown, app.btnFontUp} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl)
}
}
guiLog("refreshSettingsControls hidden")
}
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, hwndNotTopMostFlag, 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)
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)
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{TopMost: false, 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{
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("Top 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))
}