From ae426244e030c41d3a554d0ac68133377ade8634 Mon Sep 17 00:00:00 2001 From: every_holiday Date: Fri, 26 Jun 2026 09:16:46 +0900 Subject: [PATCH] Simplify GUI settings and build flags --- EXE.md | 1 + README.md | 1 + cmd/vrc_osc/main.go | 15 ++- cmd/vrc_osc_gui/main.go | 14 +- cmd/vrc_osc_gui/tray_windows.go | 20 ++- cmd/vrc_osc_gui/window_windows.go | 109 ++++++--------- cmd/vrc_osc_launcher/launcher_icon.go | 6 + cmd/vrc_osc_launcher/main.go | 19 +++ config/config.example.toml | 4 + config/config.test.toml | 35 +++++ internal/app/app.go | 20 +++ internal/app/discord.go | 185 +++++++++++--------------- internal/app/discord_mute.go | 32 ++--- internal/app/guest_status_test.go | 58 ++++++++ internal/app/ocr.go | 124 +++++++++++++++-- internal/app/runtime.go | 72 ++++++++++ internal/app/self_monitor.go | 3 + internal/app/state.go | 27 ++++ internal/app/vrc_log.go | 184 ++++++++++++++++++++++--- internal/common/project_paths.go | 12 +- internal/config/config.go | 100 ++++++++++++++ internal/config/config_test.go | 42 ++++++ 22 files changed, 846 insertions(+), 237 deletions(-) create mode 100644 cmd/vrc_osc_launcher/launcher_icon.go create mode 100644 config/config.test.toml create mode 100644 internal/app/guest_status_test.go diff --git a/EXE.md b/EXE.md index 46446a6..26ff80c 100644 --- a/EXE.md +++ b/EXE.md @@ -7,6 +7,7 @@ Release の zip は Gitea Actions で作ります。 ```powershell go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher +go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd\vrc_osc_gui go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool ``` diff --git a/README.md b/README.md index c5692aa..34f4f6a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Release の zip には次のファイルが入ります。 go test ./... go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd/vrc_osc_launcher +go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd/vrc_osc_gui go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool ``` diff --git a/cmd/vrc_osc/main.go b/cmd/vrc_osc/main.go index 67bca30..b1b67ce 100644 --- a/cmd/vrc_osc/main.go +++ b/cmd/vrc_osc/main.go @@ -8,9 +8,12 @@ import ( "path/filepath" "vrc_osc_go/internal/app" + "vrc_osc_go/internal/buildinfo" ) func main() { + log.Printf("vrc_osc version=%s build=%s", buildinfo.Version, buildinfo.BuildTime) + log.Printf("runtime main begin") var configPath string var mode string var noGUI bool @@ -20,23 +23,33 @@ func main() { flag.Parse() if mode == "" && !noGUI { + log.Printf("launching gui companion") launchGUI() } + log.Printf("calling app.Run config=%s mode=%s noGUI=%v", configPath, mode, noGUI) if err := app.Run(configPath, mode); err != nil { log.SetOutput(os.Stderr) log.Fatalf("vrc_osc_go: %v", err) } + log.Printf("app.Run returned cleanly") } func launchGUI() { exe, err := os.Executable() if err != nil { + log.Printf("launchGUI: os.Executable failed: %v", err) return } gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe") + log.Printf("launchGUI: exe=%s gui=%s", exe, gui) if _, err := os.Stat(gui); err != nil { + log.Printf("launchGUI: gui missing: %v", err) return } - _ = exec.Command(gui).Start() + if err := exec.Command(gui).Start(); err != nil { + log.Printf("launchGUI: start failed: %v", err) + return + } + log.Printf("launchGUI: started gui companion") } diff --git a/cmd/vrc_osc_gui/main.go b/cmd/vrc_osc_gui/main.go index fbe31c2..e3eb11e 100644 --- a/cmd/vrc_osc_gui/main.go +++ b/cmd/vrc_osc_gui/main.go @@ -3,12 +3,22 @@ package main import ( + "os" "log" + "path/filepath" "vrc_osc_go/internal/app" ) func main() { - _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe") - log.Fatal(runNativeGUI()) + if exe, err := os.Executable(); err == nil { + _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe)) + } else { + _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error()) + } + if err := runNativeGUI(); err != nil { + _ = app.AppendRuntimeLog("GUI", "runNativeGUI failed: "+err.Error()) + log.Fatal(err) + } + _ = app.AppendRuntimeLog("GUI", "runNativeGUI returned cleanly") } diff --git a/cmd/vrc_osc_gui/tray_windows.go b/cmd/vrc_osc_gui/tray_windows.go index 6218cbe..a55d68d 100644 --- a/cmd/vrc_osc_gui/tray_windows.go +++ b/cmd/vrc_osc_gui/tray_windows.go @@ -5,7 +5,9 @@ package main import ( "fmt" "log" + "os" "os/exec" + "path/filepath" "syscall" "unsafe" ) @@ -55,6 +57,14 @@ func openBrowser(url string) { _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() } +func trayIconPath() string { + exe, err := os.Executable() + if err != nil { + return filepath.Join("doc", "vrcworldtour_new.ico") + } + return filepath.Join(filepath.Dir(exe), "..", "doc", "vrcworldtour_new.ico") +} + func runTray(uiURL string, open func()) error { user32 := syscall.NewLazyDLL("user32.dll") shell32 := syscall.NewLazyDLL("shell32.dll") @@ -72,7 +82,7 @@ func runTray(uiURL string, open func()) error { setForegroundWindow := user32.NewProc("SetForegroundWindow") trackPopupMenu := user32.NewProc("TrackPopupMenu") showWindow := user32.NewProc("ShowWindow") - loadIcon := user32.NewProc("LoadIconW") + loadImage := user32.NewProc("LoadImageW") shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW") getModuleHandle := kernel32.NewProc("GetModuleHandleW") @@ -119,7 +129,13 @@ func runTray(uiURL string, open func()) error { className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY") windowTitle, _ := syscall.UTF16PtrFromString("VRC OSC Tray") - icon, _, _ := loadIcon.Call(0, 32512) // IDI_APPLICATION + iconPath := trayIconPath() + iconPathPtr, _ := syscall.UTF16PtrFromString(iconPath) + const ( + lrLoadFromFile = 0x00000010 + imageIcon = 1 + ) + icon, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(iconPathPtr)), imageIcon, 0, 0, lrLoadFromFile) var hwnd uintptr var menu uintptr diff --git a/cmd/vrc_osc_gui/window_windows.go b/cmd/vrc_osc_gui/window_windows.go index 0a70f53..219ac48 100644 --- a/cmd/vrc_osc_gui/window_windows.go +++ b/cmd/vrc_osc_gui/window_windows.go @@ -45,7 +45,8 @@ var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\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 hwndTopMostFlag uintptr = ^uintptr(0) +var hwndNotTopMostFlag uintptr = ^uintptr(0) - 1 var hwndNoMove uintptr = 0x0002 var hwndNoSize uintptr = 0x0001 var hwndNoActivate uintptr = 0x0010 @@ -74,13 +75,10 @@ type guiApp struct { 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 @@ -125,15 +123,12 @@ func runNativeGUI() error { 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") @@ -168,7 +163,6 @@ func runNativeGUI() error { var app guiApp guiCfg := loadGUISettings() - app.activeWindow = guiCfg.ActiveWindow app.topMost = guiCfg.TopMost if guiCfg.FontSize > 0 { app.fontSize = guiCfg.FontSize @@ -187,7 +181,6 @@ func runNativeGUI() error { 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+") @@ -198,35 +191,28 @@ func runNativeGUI() error { 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.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" - 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) + 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 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 @@ -263,8 +249,13 @@ func runNativeGUI() error { saveGUISettings(&app) } } - refreshGUI(setWindowText, &app) - refreshSettingsControls(showWindowProc, setWindowPosProc, &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) @@ -364,14 +355,6 @@ 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 { @@ -388,7 +371,7 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { 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} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { showWindowProc.Call(hwnd, swShowControl) } @@ -397,7 +380,6 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { } 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) @@ -408,13 +390,27 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { if app.settingsPaneHwnd != 0 { showWindowProc.Call(app.settingsPaneHwnd, swHideControl) } - for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} { + 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 @@ -424,7 +420,7 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, 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} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { showWindowProc.Call(hwnd, swShowControl) } @@ -435,7 +431,7 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, if app.settingsPaneHwnd != 0 { showWindowProc.Call(app.settingsPaneHwnd, swHideControl) } - for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { showWindowProc.Call(hwnd, swHideControl) } @@ -443,24 +439,6 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, 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 @@ -524,7 +502,7 @@ func applyTopMost(hwnd uintptr, app *guiApp) { if app.topMost { setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags) } else { - setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpFlags) + setWindowPosProc.Call(hwnd, hwndNotTopMostFlag, 0, 0, 0, 0, swpFlags) } app.lastTopMost = app.topMost } @@ -814,7 +792,6 @@ func formatGuestPane(title string, items []userState) string { 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")) @@ -835,7 +812,6 @@ func formatGuestPane(title string, items []userState) string { for _, item := range left { b.WriteString("・ ") b.WriteString(item.Name) - b.WriteString(" [gray]") if !item.LastLeave.IsZero() { b.WriteString(" ") b.WriteString(timeAgo(item.LastLeave)) @@ -873,7 +849,7 @@ func currentSelfName() string { func loadGUISettings() config.GUIConfig { cfg, err := config.Load("") if err != nil || cfg == nil { - return config.GUIConfig{ActiveWindow: true, FontSize: 18} + return config.GUIConfig{TopMost: false, FontSize: 18} } if cfg.GUI.FontSize <= 0 { cfg.GUI.FontSize = 18 @@ -886,9 +862,8 @@ func saveGUISettings(app *guiApp) { return } guiCfg := config.GUIConfig{ - ActiveWindow: app.activeWindow, - TopMost: app.topMost, - FontSize: app.fontSize, + TopMost: app.topMost, + FontSize: app.fontSize, } if err := config.SaveGUI("", guiCfg); err != nil { guiLog("saveGUISettings failed: " + err.Error()) @@ -941,13 +916,7 @@ func formatRightPane(tab string, state map[string]any, worldLabel string, curren 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: ") + b.WriteString("Top most: ") if stateBool(state, "top_most") { b.WriteString("ON") } else { diff --git a/cmd/vrc_osc_launcher/launcher_icon.go b/cmd/vrc_osc_launcher/launcher_icon.go new file mode 100644 index 0000000..fee303f --- /dev/null +++ b/cmd/vrc_osc_launcher/launcher_icon.go @@ -0,0 +1,6 @@ +//go:build windows + +package main + +// This file exists so the launcher package has a Windows build target +// alongside the generated .syso resource file. diff --git a/cmd/vrc_osc_launcher/main.go b/cmd/vrc_osc_launcher/main.go index 07f7bdb..9665acc 100644 --- a/cmd/vrc_osc_launcher/main.go +++ b/cmd/vrc_osc_launcher/main.go @@ -6,15 +6,18 @@ import ( "os" "os/exec" "path/filepath" + "strings" "syscall" "unsafe" + "vrc_osc_go/internal/app" "vrc_osc_go/internal/buildinfo" "vrc_osc_go/internal/common" "vrc_osc_go/internal/update" ) func main() { + _ = appendLauncherLog("LAUNCHER", "launcher main begin") var baseURL string var ownerRepo string var assetName string @@ -32,22 +35,34 @@ func main() { } if _, err := mgr.CheckAndUpdate(); err != nil { log.Printf("auto update skipped: %v", err) + _ = appendLauncherLog("LAUNCHER", "auto update skipped: "+err.Error()) + } else { + _ = appendLauncherLog("LAUNCHER", "auto update ok") } base := runtimeBaseDir() exe := filepath.Join(base, "vrc_osc.exe") gui := filepath.Join(base, "vrc_osc_gui.exe") + _ = appendLauncherLog("LAUNCHER", "runtimeBaseDir="+base) if _, err := os.Stat(exe); err != nil { + _ = appendLauncherLog("LAUNCHER", "missing runtime exe: "+err.Error()) showError("vrc_osc_launcher", "missing exe: "+err.Error()) log.Fatalf("missing exe: %v", err) } + _ = appendLauncherLog("LAUNCHER", "version="+buildinfo.Version+" build="+buildinfo.BuildTime+" base="+base+" exe="+exe+" gui="+gui) if _, err := os.Stat(gui); err == nil { + _ = appendLauncherLog("LAUNCHER", "starting gui companion") _ = exec.Command(gui).Start() + } else { + _ = appendLauncherLog("LAUNCHER", "gui companion missing: "+err.Error()) } args := append([]string{"--no-gui"}, os.Args[1:]...) + _ = appendLauncherLog("LAUNCHER", "launching runtime args="+strings.Join(args, " ")) if err := update.Launch(exe, args); err != nil { + _ = appendLauncherLog("LAUNCHER", "launch failed: "+err.Error()) showError("vrc_osc_launcher", "launch failed: "+err.Error()) log.Fatalf("launch failed: %v", err) } + _ = appendLauncherLog("LAUNCHER", "runtime launch returned") } func showError(title, message string) { @@ -73,3 +88,7 @@ func runtimeBaseDir() string { } return root } + +func appendLauncherLog(title, text string) error { + return app.AppendRuntimeLog(title, text) +} diff --git a/config/config.example.toml b/config/config.example.toml index eda415b..e3e8293 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -7,6 +7,10 @@ names = [] [guest] file = "config/guests.txt" +[gui] +top_most = false +font_size = 18 + [notice] missing_count = 0 diff --git a/config/config.test.toml b/config/config.test.toml new file mode 100644 index 0000000..1a46212 --- /dev/null +++ b/config/config.test.toml @@ -0,0 +1,35 @@ +[self] +name = "毎日がHoliday'" + +[staff] +file = "config/staff.txt" +[guest] +file = "config/guests.txt" + +[gui] +top_most = false +font_size = 18 + +[notice] +missing_count = 0 + +[vrc_log] +patterns = [ + "OnPlayerJoined", + "OnPlayerLeft", + "Entering Room", + "Joining or Creating Room", + "worldId=", + "wrld_", +] + +[ocr.crop] +left = 0.05 +top = 0.35 +right = 0.95 +bottom = 0.95 + +[ocr] +preprocess = true +scale = 1.5 +use_angle_cls = false diff --git a/internal/app/app.go b/internal/app/app.go index 5960fb1..8ca0b1a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,6 +10,7 @@ import ( "vrc_osc_go/internal/config" "vrc_osc_go/internal/consenttool" + "vrc_osc_go/internal/buildinfo" "vrc_osc_go/internal/osc" ) @@ -18,17 +19,25 @@ type discordMuteState struct { muted bool buttonPressed bool lastAction time.Time + lastPressEdge time.Time } func Run(configPath string, mode string) error { if mode != "" { return consenttool.Run(configPath, mode) } + log.Printf("app run begin config=%s mode=%s", configPath, mode) + if err := setupRuntimeLogger(); err != nil { + return err + } + log.Printf("runtime logger ready") cfg, err := config.Load(configPath) if err != nil { + log.Printf("config load failed: %v", err) return err } + log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime) InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames)) log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath) @@ -52,14 +61,25 @@ func Run(configPath string, mode string) error { } return nil }) + server.Map("translation", func(_ string, args []osc.Value) error { + log.Printf("received translation args=%+v", args) + if err := runPythonTranslationFromLastOCR(); err != nil { + log.Printf("translation failed: %v", err) + return err + } + return nil + }) errCh := make(chan error, 1) go func() { log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port) errCh <- server.Serve() }() + log.Printf("starting vrchat log watcher") go watchVrchatLog() + log.Printf("vrchat log watcher goroutine started") startSelfMonitor(cfg, discordState) + log.Printf("self monitor started") sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) diff --git a/internal/app/discord.go b/internal/app/discord.go index 02dba8a..c77ac63 100644 --- a/internal/app/discord.go +++ b/internal/app/discord.go @@ -3,120 +3,87 @@ package app import ( "fmt" "log" - "os/exec" + "strings" + "syscall" + "unsafe" +) + +const ( + vkControl = 0x11 + vkShift = 0x10 + vkM = 0x4D + keyUp = 0x0002 + swRestore = 9 ) func pressDiscordMuteHotkey() error { - script := ` -$ErrorActionPreference = "Stop" + user32 := syscall.NewLazyDLL("user32.dll") + enumWindows := user32.NewProc("EnumWindows") + getWindowTextW := user32.NewProc("GetWindowTextW") + isWindowVisible := user32.NewProc("IsWindowVisible") + setForegroundWindow := user32.NewProc("SetForegroundWindow") + showWindow := user32.NewProc("ShowWindow") + getWindowRect := user32.NewProc("GetWindowRect") + keybdEvent := user32.NewProc("keybd_event") + setCursorPos := user32.NewProc("SetCursorPos") + sleep := syscall.NewLazyDLL("kernel32.dll").NewProc("Sleep") -Add-Type @" -using System; -using System.Runtime.InteropServices; -public static class Win32 { - [StructLayout(LayoutKind.Sequential)] - public struct RECT { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - [DllImport("user32.dll")] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - [DllImport("user32.dll")] - public static extern bool SetCursorPos(int X, int Y); - [DllImport("user32.dll")] - public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); -} -"@ - -function Click-WindowCenter($p) { - if ($null -eq $p) { return } - $hWnd = [IntPtr]$p.MainWindowHandle - if ($hWnd -eq [IntPtr]::Zero) { return } - - $rect = New-Object 'Win32+RECT' - if (-not [Win32]::GetWindowRect($hWnd, [ref]$rect)) { return } - - $x = [int](($rect.Left + $rect.Right) / 2) - $y = [int](($rect.Top + $rect.Bottom) / 2) - [Win32]::SetCursorPos($x, $y) | Out-Null - Start-Sleep -Milliseconds 50 - [Win32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) - [Win32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) - Start-Sleep -Milliseconds 100 -} - -function Get-DiscordWindows { - $windows = foreach ($p in Get-Process) { - $title = ($p.MainWindowTitle | Out-String).Trim() - if (-not $title) { continue } - if (-not $title.ToLower().Contains('discord')) { continue } - $p - } - if (-not $windows) { return @() } - return $windows -} - -function Get-BestDiscordWindow { - $windows = Get-DiscordWindows - if (-not $windows -or $windows.Count -eq 0) { return $null } - - $browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera') - $ranked = $windows | Sort-Object { - $title = ($_.MainWindowTitle | Out-String).Trim().ToLower() - $score = 0 - foreach ($keyword in $browserKeywords) { - if ($title.Contains($keyword)) { $score += 10 } - } - if ($title -match '^\(\d+\)\s*discord') { $score += 20 } - if ($title.Contains('discord')) { $score += 5 } - $score - } -Descending - - return $ranked | Select-Object -First 1 -} - -function Get-VRChatWindow { - $windows = Get-Process | Where-Object { - $_.MainWindowTitle -and ( - $_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ') - ) - } - if (-not $windows) { return $null } - return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1 -} - -function Activate-Window($p) { - if ($null -eq $p) { return } - $wshell = New-Object -ComObject WScript.Shell - try { $null = $wshell.AppActivate($p.Id) } catch {} - Start-Sleep -Milliseconds 200 - Click-WindowCenter $p -} - -$discord = Get-BestDiscordWindow -if ($null -eq $discord) { exit 2 } - -Activate-Window $discord -$wshell = New-Object -ComObject WScript.Shell -Start-Sleep -Milliseconds 200 -$wshell.SendKeys('^+m') -Start-Sleep -Milliseconds 200 - -$vrchat = Get-VRChatWindow -if ($null -ne $vrchat) { - Activate-Window $vrchat -} -` - - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) - out, err := cmd.CombinedOutput() - if len(out) > 0 { - log.Printf("discord mute output: %s", string(out)) + var target uintptr + cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr { + vis, _, _ := isWindowVisible.Call(hwnd) + if vis == 0 { + return 1 + } + buf := make([]uint16, 512) + n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if n == 0 { + return 1 + } + title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n]))) + if !strings.Contains(title, "discord") { + return 1 + } + target = hwnd + return 0 + }) + r1, _, err1 := enumWindows.Call(cb, 0) + if r1 == 0 && target == 0 { + return fmt.Errorf("discord window not found: %v", err1) } - if err != nil { - return fmt.Errorf("discord mute failed: %w", err) + if target == 0 { + return fmt.Errorf("discord window not found") } + + showWindow.Call(target, swRestore) + setForegroundWindow.Call(target) + sleep.Call(150) + + type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 + } + var r rect + if v, _, _ := getWindowRect.Call(target, uintptr(unsafe.Pointer(&r))); v != 0 { + x := int((r.Left + r.Right) / 2) + y := int((r.Top + r.Bottom) / 2) + setCursorPos.Call(uintptr(x), uintptr(y)) + sleep.Call(50) + } + + send := func(vk byte, flags uintptr) { + keybdEvent.Call(uintptr(vk), 0, flags, 0) + } + send(vkControl, 0) + send(vkShift, 0) + send(vkM, 0) + send(vkM, keyUp) + send(vkShift, keyUp) + send(vkControl, keyUp) + sleep.Call(100) + + SetDiscordWindow("Discord") + log.Printf("discord hotkey sent via keybd_event") return nil } diff --git a/internal/app/discord_mute.go b/internal/app/discord_mute.go index 7165d9d..31cb5e5 100644 --- a/internal/app/discord_mute.go +++ b/internal/app/discord_mute.go @@ -30,33 +30,25 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error { if isPressed && !state.buttonPressed { state.buttonPressed = true - if state.muted { - log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey") - if err := pressDiscordMuteHotkey(); err != nil { - return err - } - state.muted = false - SetDiscordMuted(state.muted) - state.lastAction = now + log.Printf("ACTION VRChat button pressed -> Discord mute toggle") + if err := pressDiscordMuteHotkey(); err != nil { + log.Printf("discord hotkey failed: %v", err) + SetDiscordAction("hotkey_failed") } else { - log.Printf("INFO already unmuted") + state.muted = !state.muted + SetDiscordMuted(state.muted) + if state.muted { + SetDiscordAction("muted") + } else { + SetDiscordAction("unmuted") + } } + state.lastAction = now return nil } if !isPressed && state.buttonPressed { state.buttonPressed = false - if !state.muted { - log.Printf("ACTION VRChat button released -> Discord mute hotkey") - if err := pressDiscordMuteHotkey(); err != nil { - return err - } - state.muted = true - SetDiscordMuted(state.muted) - state.lastAction = now - } else { - log.Printf("INFO already muted") - } } return nil diff --git a/internal/app/guest_status_test.go b/internal/app/guest_status_test.go new file mode 100644 index 0000000..3959f6f --- /dev/null +++ b/internal/app/guest_status_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + "time" + + "vrc_osc_go/internal/common" +) + +func TestGuestTrackerJoinLeave(t *testing.T) { + base := t.TempDir() + prev := common.SetRootDirForTest(func() string { return base }) + defer prev() + + tracker := NewGuestTracker([]string{"Alice", "Bob"}) + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.Local) + + tracker.MarkJoin("Alice", now) + tracker.MarkJoin("Bob", now.Add(10*time.Second)) + + got := tracker.Snapshot(now.Add(20 * time.Second)) + if len(got) != 2 { + t.Fatalf("expected 2 guests, got %d", len(got)) + } + + present := 0 + for _, g := range got { + if g.Present { + present++ + } + } + if present != 2 { + t.Fatalf("expected 2 present guests, got %#v", got) + } + + tracker.MarkLeave("Alice", now.Add(30*time.Second)) + got = tracker.Snapshot(now.Add(40 * time.Second)) + + var alice GuestStatus + for _, g := range got { + if g.Name == "Alice" { + alice = g + } + } + if alice.Present { + t.Fatalf("expected Alice to be absent, got %#v", alice) + } + if alice.AbsentFor == "" { + t.Fatalf("expected Alice absent duration, got %#v", alice) + } + + snapshotPath := filepath.Join(base, "runtime", "guest_snapshot.json") + if _, err := os.Stat(snapshotPath); err != nil { + t.Fatalf("expected snapshot file: %v", err) + } +} diff --git a/internal/app/ocr.go b/internal/app/ocr.go index 6f6e2ca..8b255c8 100644 --- a/internal/app/ocr.go +++ b/internal/app/ocr.go @@ -1,30 +1,128 @@ package app import ( + "encoding/json" "fmt" "log" "os/exec" + "strings" ) -func runPythonOcrFromScreen() error { - script := ` -import sys -from pathlib import Path -root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC") -sys.path.insert(0, str(root / "src")) -from ocr.ocr_actions import runOcrFromScreen -runOcrFromScreen() -` +type OcrResult struct { + ImagePath string + TextPath string + Text string + Translate string + ErrReason string +} + +func runPythonScript(script string) (string, error) { cmd := exec.Command("python", "-c", script) cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC` out, err := cmd.CombinedOutput() if len(out) > 0 { - log.Printf("ocr output: %s", string(out)) - SetOCRText(string(out)) - SetTranslateText("翻訳: 未実装") + log.Printf("python output: %s", string(out)) } if err != nil { - return fmt.Errorf("ocr failed: %w", err) + return string(out), fmt.Errorf("python failed: %w", err) + } + return string(out), nil +} + +func capturePythonOcr() (map[string]any, error) { + script := ` +import sys, json +from pathlib import Path +root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC") +sys.path.insert(0, str(root / "src")) +from ocr.ocr_actions import runOcrFromScreen +result = runOcrFromScreen() +print(json.dumps(result, ensure_ascii=False)) +` + out, err := runPythonScript(script) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err == nil { + return m, nil + } + } + } + return nil, fmt.Errorf("ocr result json not found") +} + +func runPythonOcrFromScreen() error { + log.Printf("ocr trigger begin") + result, err := capturePythonOcr() + if err != nil { + log.Printf("ocr trigger failed: %v", err) + SetOCRText("OCRテキストなし") + SetTranslateText("翻訳エンジン未設定") + _ = AppendRuntimeLog("OCR ERROR", err.Error()) + return err + } + text, _ := result["text"].(string) + imagePath, _ := result["image_path"].(string) + textPath, _ := result["text_path"].(string) + if strings.TrimSpace(text) == "" { + text = "OCRテキストなし" + } + SetOCRText(text) + SetTranslateText("") + _ = AppendRuntimeLog("OCR INPUT", fmt.Sprintf("image=%s\n%s", imagePath, text)) + if textPath != "" { + _ = AppendRuntimeLog("OCR TEXT", fmt.Sprintf("image=%s\ntext_path=%s\n%s", imagePath, textPath, text)) } return nil } + +func runPythonTranslationFromLastOCR() error { + runtime := SnapshotRuntime() + source := strings.TrimSpace(runtime.LastOCRText) + if source == "" || source == "OCRテキストなし" { + SetTranslateText("OCRテキストなし") + _ = AppendRuntimeLog("TRANSLATION ERROR", "OCRテキストなし") + return nil + } + + log.Printf("translation trigger begin") + SetTranslateText("翻訳実行中") + _ = AppendRuntimeLog("TRANSLATION INPUT", source) + script := ` +import sys, json +from pathlib import Path +root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC") +sys.path.insert(0, str(root / "src")) +from translate.translate_actions import translateTextToJapanese, saveTranslationText +text = sys.argv[1] +translated = translateTextToJapanese(text) +print(translated or "") +if translated: + saveTranslationText("runtime", translated) +` + cmd := exec.Command("python", "-c", script, source) + cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC` + out, err := cmd.CombinedOutput() + if len(out) > 0 { + log.Printf("translation output: %s", string(out)) + } + if err != nil { + SetTranslateText("翻訳失敗") + _ = AppendRuntimeLog("TRANSLATION ERROR", err.Error()) + return err + } + translated := strings.TrimSpace(string(out)) + if translated == "" { + SetTranslateText("翻訳結果なし") + _ = AppendRuntimeLog("TRANSLATION ERROR", "翻訳結果なし") + return nil + } + SetTranslateText(translated) + _ = AppendRuntimeLog("TRANSLATION RESULT", translated) + return nil +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index d306a7b..50894e3 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -2,13 +2,45 @@ package app import ( "fmt" + "io" + "log" "os" "path/filepath" + "sync" "time" "vrc_osc_go/internal/common" ) +type runtimeLogWriter struct { + mu sync.Mutex + file *os.File +} + +func (w *runtimeLogWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return len(p), nil + } + return w.file.Write(p) +} + +func setupRuntimeLogger() error { + dir := filepath.Join(common.RootDir(), "runtime") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "runtime.log") + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + logWriter := &runtimeLogWriter{file: f} + log.SetOutput(io.MultiWriter(os.Stderr, logWriter)) + return nil +} + func appendRuntimeLog(title, text string) error { dir := filepath.Join(common.RootDir(), "runtime") if err := os.MkdirAll(dir, 0o755); err != nil { @@ -50,3 +82,43 @@ func appendJoinLeaveLog(text string) error { _, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body) return err } + +func appendDesktopJoinLog(title, worldLabel, text string) error { + dir := filepath.Join(os.Getenv("USERPROFILE"), "Desktop") + if _, err := os.Stat(dir); err != nil { + if home, herr := os.UserHomeDir(); herr == nil { + dir = filepath.Join(home, "Desktop") + } + } + if err := os.MkdirAll(dir, 0o755); err != nil { + dir = filepath.Join(common.RootDir(), "runtime") + if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil { + return err + } + } + path := filepath.Join(dir, "join.txt") + body := text + if body == "" { + body = "(empty)" + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + fallbackDir := filepath.Join(common.RootDir(), "runtime") + if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil { + return err + } + fallbackPath := filepath.Join(fallbackDir, "join.txt") + f, err = os.OpenFile(fallbackPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + path = fallbackPath + } + defer f.Close() + _, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body) + return err +} + +func AppendDesktopJoinLog(title, worldLabel, text string) error { + return appendDesktopJoinLog(title, worldLabel, text) +} diff --git a/internal/app/self_monitor.go b/internal/app/self_monitor.go index 6ac10a6..be5a32a 100644 --- a/internal/app/self_monitor.go +++ b/internal/app/self_monitor.go @@ -38,12 +38,15 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) { if nextState == "joined" { if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil { log.Printf("self monitor discord mute failed: %v", err) + SetDiscordSource("self_monitor_error") } } else if nextState == "left" { if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil { log.Printf("self monitor discord mute failed: %v", err) + SetDiscordSource("self_monitor_error") } } + SetDiscordSource("self_monitor") lastState = nextState } time.Sleep(2 * time.Second) diff --git a/internal/app/state.go b/internal/app/state.go index 4240c98..be072d1 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -13,6 +13,9 @@ import ( type RuntimeState struct { mu sync.RWMutex DiscordMuted bool + DiscordWindow string + DiscordSource string + DiscordAction string LastOCRText string LastTranslate string CurrentWorld string @@ -39,6 +42,27 @@ func SetDiscordMuted(v bool) { _ = persistRuntimeState() } +func SetDiscordWindow(v string) { + runtimeState.mu.Lock() + runtimeState.DiscordWindow = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + +func SetDiscordSource(v string) { + runtimeState.mu.Lock() + runtimeState.DiscordSource = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + +func SetDiscordAction(v string) { + runtimeState.mu.Lock() + runtimeState.DiscordAction = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + func SetOCRText(v string) { runtimeState.mu.Lock() runtimeState.LastOCRText = v @@ -81,6 +105,9 @@ func persistRuntimeState() error { enc.SetIndent("", " ") return enc.Encode(map[string]any{ "discord_muted": s.DiscordMuted, + "discord_window": s.DiscordWindow, + "discord_source": s.DiscordSource, + "discord_action": s.DiscordAction, "ocr": s.LastOCRText, "translate": s.LastTranslate, "world": s.CurrentWorld, diff --git a/internal/app/vrc_log.go b/internal/app/vrc_log.go index 9a1fdf8..6562338 100644 --- a/internal/app/vrc_log.go +++ b/internal/app/vrc_log.go @@ -1,7 +1,6 @@ package app import ( - "bufio" "fmt" "log" "os" @@ -9,6 +8,9 @@ import ( "regexp" "strings" "time" + "unicode/utf8" + "syscall" + "unsafe" ) var ( @@ -77,6 +79,12 @@ func findLatestVrchatLog() (string, error) { } func watchVrchatLog() { + log.Printf("vrchat log watcher started") + defer func() { + if r := recover(); r != nil { + log.Printf("vrchat log watcher panic: %v", r) + } + }() lastPath := "" lastSize := int64(0) state := &vrcLogState{ @@ -85,20 +93,26 @@ func watchVrchatLog() { for { path, err := findLatestVrchatLog() if err != nil { + log.Printf("vrchat log not found: %v", err) time.Sleep(2 * time.Second) continue } info, err := os.Stat(path) if err != nil { + log.Printf("vrchat log stat failed: %v", err) time.Sleep(2 * time.Second) continue } + log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize) if path != lastPath { log.Printf("vrchat log watching %s", path) lastPath = path lastSize = 0 + log.Printf("vrchat log initial scan begin path=%s", path) if err := scanExistingVrchatLog(path, state); err != nil { log.Printf("vrchat log initial scan failed: %v", err) + } else { + log.Printf("vrchat log initial scan ok path=%s", path) } if info2, err := os.Stat(path); err == nil { lastSize = info2.Size() @@ -113,15 +127,22 @@ func watchVrchatLog() { lastSize = 0 } if info.Size() > lastSize { - f, err := os.Open(path) + log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size()) + b, err := os.ReadFile(path) if err != nil { + log.Printf("vrchat log read failed: %v", err) time.Sleep(2 * time.Second) continue } - _, _ = f.Seek(lastSize, 0) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() + text := decodeVRChatLog(b) + lines := strings.Split(text, "\n") + joinHits := 0 + leaveHits := 0 + for _, line := range lines { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } if err := appendRuntimeLog("VRC LOG", line); err != nil { log.Printf("append runtime log failed: %v", err) } @@ -137,41 +158,47 @@ func watchVrchatLog() { at := extractLineTime(line) if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { name := strings.TrimSpace(m[1]) + log.Printf("join detected: %s", name) + joinHits++ state.presentSet[name] = struct{}{} if tracker := GetGuestTracker(); tracker != nil { tracker.MarkJoin(name, at) } out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) log.Print(out) - _ = appendJoinLeaveLog(out) + if err := appendJoinLeaveLog(out); err != nil { + log.Printf("append join log failed: %v", err) + } continue } if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { name := strings.TrimSpace(m[1]) + log.Printf("leave detected: %s", name) + leaveHits++ delete(state.presentSet, name) if tracker := GetGuestTracker(); tracker != nil { tracker.MarkLeave(name, at) } out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) log.Print(out) - _ = appendJoinLeaveLog(out) + if err := appendJoinLeaveLog(out); err != nil { + log.Printf("append leave log failed: %v", err) + } continue } } + log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits) lastSize = info.Size() - _ = f.Close() } time.Sleep(2 * time.Second) } } func scanExistingVrchatLog(path string, state *vrcLogState) error { - f, err := os.Open(path) + b, err := os.ReadFile(path) if err != nil { return err } - defer f.Close() - state.presentSet = map[string]struct{}{} state.location = "" state.worldID = "" @@ -180,9 +207,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error { state.pendingWorldName = "" state.initialized = false - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() + text := decodeVRChatLog(b) + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimRight(raw, "\r") + if line == "" { + continue + } if changed, _ := updateWorldState(state, line); changed { state.presentSet = map[string]struct{}{} } @@ -203,9 +233,6 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error { continue } } - if err := scanner.Err(); err != nil { - return err - } state.initialized = true if state.worldName != "" { log.Printf("world changed: %s", state.worldName) @@ -213,11 +240,130 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error { if tracker := GetGuestTracker(); tracker != nil { tracker.SetCurrentInstance(state.worldName) } - _ = appendRuntimeLog("VRC WORLD", state.worldName) + if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil { + log.Printf("append world log failed: %v", err) + } } return nil } +func decodeVRChatLog(b []byte) string { + if len(b) == 0 { + return "" + } + if utf8.Valid(b) { + return strings.TrimPrefix(string(b), "\ufeff") + } + if len(b) >= 2 { + if b[0] == 0xff && b[1] == 0xfe { + u16 := make([]uint16, 0, (len(b)-2)/2) + for i := 2; i+1 < len(b); i += 2 { + u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8) + } + return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff") + } + if b[0] == 0xfe && b[1] == 0xff { + u16 := make([]uint16, 0, (len(b)-2)/2) + for i := 2; i+1 < len(b); i += 2 { + u16 = append(u16, uint16(b[i+1])|uint16(b[i])<<8) + } + return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff") + } + } + for _, cp := range []uint32{932, 1252, 65001} { + if decoded := decodeWithCodePage(b, cp); decoded != "" { + return strings.TrimPrefix(fixMojibake(decoded), "\ufeff") + } + } + return strings.TrimPrefix(fixMojibake(string(b)), "\ufeff") +} + +func DecodeVRChatLog(b []byte) string { + return decodeVRChatLog(b) +} + +func fixMojibake(s string) string { + if s == "" { + return "" + } + candidates := []string{s} + if repaired := tryRepairShiftJISMojibake(s); repaired != "" { + candidates = append(candidates, repaired) + } + best := s + bestScore := scoreReadable(best) + for _, cand := range candidates { + if score := scoreReadable(cand); score > bestScore { + best = cand + bestScore = score + } + } + return best +} + +func tryRepairShiftJISMojibake(s string) string { + raw := []byte(s) + // Try the common "UTF-8 bytes interpreted as CP932" pattern repair by + // round-tripping through CP932 in the reverse direction. + if repaired := decodeWithCodePage(raw, 932); repaired != "" && repaired != s { + return repaired + } + return "" +} + +func scoreReadable(s string) int { + if s == "" { + return -1 + } + score := 0 + for _, r := range s { + switch { + case r == '�': + score -= 8 + case r >= 0x3040 && r <= 0x30ff: + score += 3 + case r >= 0x4e00 && r <= 0x9fff: + score += 3 + case r >= 0x0020 && r <= 0x007e: + score += 1 + case r == '\n' || r == '\r' || r == '\t': + score += 1 + default: + score -= 1 + } + } + return score +} + +func decodeWithCodePage(b []byte, codePage uint32) string { + if len(b) == 0 { + return "" + } + kernel32 := syscall.NewLazyDLL("kernel32.dll") + multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar") + n, _, _ := multiByteToWideChar.Call( + uintptr(codePage), + 0, + uintptr(unsafe.Pointer(&b[0])), + uintptr(len(b)), + 0, + 0, + ) + if n == 0 { + return "" + } + buf := make([]uint16, n) + multiByteToWideChar.Call( + uintptr(codePage), + 0, + uintptr(unsafe.Pointer(&b[0])), + uintptr(len(b)), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + ) + return syscall.UTF16ToString(buf) +} + func updateWorldState(state *vrcLogState, line string) (bool, string) { worldID := "" instanceID := "" diff --git a/internal/common/project_paths.go b/internal/common/project_paths.go index 76b587c..5c5d3d5 100644 --- a/internal/common/project_paths.go +++ b/internal/common/project_paths.go @@ -5,6 +5,8 @@ import ( "path/filepath" ) +var rootDirFunc = RuntimeBaseDir + func RuntimeBaseDir() string { exe, err := os.Executable() if err == nil { @@ -13,4 +15,12 @@ func RuntimeBaseDir() string { return "." } -func RootDir() string { return RuntimeBaseDir() } +func RootDir() string { return rootDirFunc() } + +func SetRootDirForTest(fn func() string) func() { + prev := rootDirFunc + rootDirFunc = fn + return func() { + rootDirFunc = prev + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e584686..310a35f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "bufio" + "fmt" "os" "path/filepath" "strconv" @@ -13,6 +14,7 @@ import ( type Config struct { OSC OSCConfig VrcLog VrcLogConfig + GUI GUIConfig } type OSCConfig struct { @@ -29,9 +31,15 @@ type VrcLogConfig struct { LogPatterns []string } +type GUIConfig struct { + TopMost bool + FontSize int +} + func Load(path string) (*Config, error) { cfg := &Config{ OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, + GUI: GUIConfig{FontSize: 18}, } if path == "" { path = filepath.Join(common.RootDir(), "config", "config.toml") @@ -98,6 +106,15 @@ func Load(path string) (*Config, error) { case "file": cfg.VrcLog.GuestFile = val } + case "gui": + switch key { + case "top_most": + cfg.GUI.TopMost = parseBool(val, cfg.GUI.TopMost) + case "font_size": + if n, err := strconv.Atoi(val); err == nil && n > 0 { + cfg.GUI.FontSize = n + } + } } } if cfg.VrcLog.GuestFile != "" { @@ -111,6 +128,19 @@ func Load(path string) (*Config, error) { return cfg, scanner.Err() } +func SaveGUI(path string, gui GUIConfig) error { + path = resolvePath(path) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + content := upsertGUISection(string(data), gui) + return os.WriteFile(path, []byte(content), 0o644) +} + func parseList(value string) []string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, "[") @@ -129,6 +159,76 @@ func parseList(value string) []string { return out } +func parseBool(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "on", "yes": + return true + case "false", "0", "off", "no": + return false + default: + return fallback + } +} + +func resolvePath(path string) string { + if path == "" { + return filepath.Join(common.RootDir(), "config", "config.toml") + } + if !filepath.IsAbs(path) { + return filepath.Join(common.RootDir(), path) + } + return path +} + +func upsertGUISection(existing string, gui GUIConfig) string { + normalized := strings.ReplaceAll(existing, "\r\n", "\n") + lines := strings.Split(normalized, "\n") + out := make([]string, 0, len(lines)+6) + inGUI := false + replaced := false + for _, raw := range lines { + line := raw + trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + section := strings.Trim(trimmed, "[]") + if section == "gui" { + if !replaced { + appendGUISection(&out, gui) + replaced = true + } + inGUI = true + continue + } + if inGUI { + inGUI = false + } + out = append(out, line) + continue + } + if inGUI { + continue + } + out = append(out, line) + } + if !replaced { + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + appendGUISection(&out, gui) + } + content := strings.Join(out, "\n") + content = strings.TrimRight(content, "\n") + return content + "\n" +} + +func appendGUISection(out *[]string, gui GUIConfig) { + *out = append(*out, + "[gui]", + fmt.Sprintf("top_most = %t", gui.TopMost), + fmt.Sprintf("font_size = %d", gui.FontSize), + ) +} + func loadLines(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6e95697..22f1cac 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -31,3 +32,44 @@ func TestLoadOSCValues(t *testing.T) { } } +func TestLoadGUIValues(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 { + t.Fatalf("unexpected gui values: %+v", cfg.GUI) + } +} + +func TestSaveGUIUpdatesSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + initial := []byte("[self]\nname = \"alice\"\n\n[gui]\ntop_most = false\nfont_size = 18\n") + if err := os.WriteFile(path, initial, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26}); err != nil { + t.Fatalf("SaveGUI returned error: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 { + t.Fatalf("unexpected saved gui values: %+v", cfg.GUI) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + text := string(data) + if !strings.Contains(text, "[self]") || !strings.Contains(text, "name = \"alice\"") { + t.Fatalf("non-gui config content was lost: %s", text) + } +}