Simplify GUI settings and build flags

This commit is contained in:
every_holiday
2026-06-26 09:16:46 +09:00
parent 4d77e67870
commit ae426244e0
22 changed files with 846 additions and 237 deletions

1
EXE.md
View File

@@ -7,6 +7,7 @@ Release の zip は Gitea Actions で作ります。
```powershell ```powershell
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc 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_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 go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
``` ```

View File

@@ -62,6 +62,7 @@ Release の zip には次のファイルが入ります。
go test ./... go test ./...
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc 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_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 go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
``` ```

View File

@@ -8,9 +8,12 @@ import (
"path/filepath" "path/filepath"
"vrc_osc_go/internal/app" "vrc_osc_go/internal/app"
"vrc_osc_go/internal/buildinfo"
) )
func main() { func main() {
log.Printf("vrc_osc version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
log.Printf("runtime main begin")
var configPath string var configPath string
var mode string var mode string
var noGUI bool var noGUI bool
@@ -20,23 +23,33 @@ func main() {
flag.Parse() flag.Parse()
if mode == "" && !noGUI { if mode == "" && !noGUI {
log.Printf("launching gui companion")
launchGUI() launchGUI()
} }
log.Printf("calling app.Run config=%s mode=%s noGUI=%v", configPath, mode, noGUI)
if err := app.Run(configPath, mode); err != nil { if err := app.Run(configPath, mode); err != nil {
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
log.Fatalf("vrc_osc_go: %v", err) log.Fatalf("vrc_osc_go: %v", err)
} }
log.Printf("app.Run returned cleanly")
} }
func launchGUI() { func launchGUI() {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
log.Printf("launchGUI: os.Executable failed: %v", err)
return return
} }
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe") 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 { if _, err := os.Stat(gui); err != nil {
log.Printf("launchGUI: gui missing: %v", err)
return 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")
} }

View File

@@ -3,12 +3,22 @@
package main package main
import ( import (
"os"
"log" "log"
"path/filepath"
"vrc_osc_go/internal/app" "vrc_osc_go/internal/app"
) )
func main() { func main() {
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe") if exe, err := os.Executable(); err == nil {
log.Fatal(runNativeGUI()) _ = 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")
} }

View File

@@ -5,7 +5,9 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"os"
"os/exec" "os/exec"
"path/filepath"
"syscall" "syscall"
"unsafe" "unsafe"
) )
@@ -55,6 +57,14 @@ func openBrowser(url string) {
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() _ = 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 { func runTray(uiURL string, open func()) error {
user32 := syscall.NewLazyDLL("user32.dll") user32 := syscall.NewLazyDLL("user32.dll")
shell32 := syscall.NewLazyDLL("shell32.dll") shell32 := syscall.NewLazyDLL("shell32.dll")
@@ -72,7 +82,7 @@ func runTray(uiURL string, open func()) error {
setForegroundWindow := user32.NewProc("SetForegroundWindow") setForegroundWindow := user32.NewProc("SetForegroundWindow")
trackPopupMenu := user32.NewProc("TrackPopupMenu") trackPopupMenu := user32.NewProc("TrackPopupMenu")
showWindow := user32.NewProc("ShowWindow") showWindow := user32.NewProc("ShowWindow")
loadIcon := user32.NewProc("LoadIconW") loadImage := user32.NewProc("LoadImageW")
shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW") shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW")
getModuleHandle := kernel32.NewProc("GetModuleHandleW") getModuleHandle := kernel32.NewProc("GetModuleHandleW")
@@ -119,7 +129,13 @@ func runTray(uiURL string, open func()) error {
className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY") className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY")
windowTitle, _ := 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 hwnd uintptr
var menu uintptr var menu uintptr

View File

@@ -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 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 vrcJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
var vrcLeftPattern = regexp.MustCompile(`OnPlayerLeft\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 hwndNoMove uintptr = 0x0002
var hwndNoSize uintptr = 0x0001 var hwndNoSize uintptr = 0x0001
var hwndNoActivate uintptr = 0x0010 var hwndNoActivate uintptr = 0x0010
@@ -74,13 +75,10 @@ type guiApp struct {
tabJoin uintptr tabJoin uintptr
tabTranslate uintptr tabTranslate uintptr
tabSettings uintptr tabSettings uintptr
btnActiveWindow uintptr
btnTopMost uintptr btnTopMost uintptr
btnFontDown uintptr btnFontDown uintptr
btnFontUp uintptr btnFontUp uintptr
activeTab string activeTab string
activeWindow bool
lastActiveWindow bool
topMost bool topMost bool
lastTopMost bool lastTopMost bool
fontSize int fontSize int
@@ -125,15 +123,12 @@ func runNativeGUI() error {
translateMessage := user32.NewProc("TranslateMessage") translateMessage := user32.NewProc("TranslateMessage")
dispatchMessage := user32.NewProc("DispatchMessageW") dispatchMessage := user32.NewProc("DispatchMessageW")
postQuitMessage := user32.NewProc("PostQuitMessage") postQuitMessage := user32.NewProc("PostQuitMessage")
setForegroundWindow := user32.NewProc("SetForegroundWindow")
bringWindowToTop := user32.NewProc("BringWindowToTop")
setTimer := user32.NewProc("SetTimer") setTimer := user32.NewProc("SetTimer")
loadCursor := user32.NewProc("LoadCursorW") loadCursor := user32.NewProc("LoadCursorW")
setWindowText := user32.NewProc("SetWindowTextW") setWindowText := user32.NewProc("SetWindowTextW")
getWindowRect := user32.NewProc("GetWindowRect") getWindowRect := user32.NewProc("GetWindowRect")
isWindowVisible := user32.NewProc("IsWindowVisible") isWindowVisible := user32.NewProc("IsWindowVisible")
isIconic := user32.NewProc("IsIconic") isIconic := user32.NewProc("IsIconic")
getForegroundWindow := user32.NewProc("GetForegroundWindow")
getSystemMetrics := user32.NewProc("GetSystemMetrics") getSystemMetrics := user32.NewProc("GetSystemMetrics")
getModuleHandle := kernel32.NewProc("GetModuleHandleW") getModuleHandle := kernel32.NewProc("GetModuleHandleW")
loadIcon := user32.NewProc("LoadIconW") loadIcon := user32.NewProc("LoadIconW")
@@ -168,7 +163,6 @@ func runNativeGUI() error {
var app guiApp var app guiApp
guiCfg := loadGUISettings() guiCfg := loadGUISettings()
app.activeWindow = guiCfg.ActiveWindow
app.topMost = guiCfg.TopMost app.topMost = guiCfg.TopMost
if guiCfg.FontSize > 0 { if guiCfg.FontSize > 0 {
app.fontSize = guiCfg.FontSize app.fontSize = guiCfg.FontSize
@@ -187,7 +181,6 @@ func runNativeGUI() error {
joinTitle, _ := syscall.UTF16PtrFromString("Join Log") joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
translateTitle, _ := syscall.UTF16PtrFromString("Translate") translateTitle, _ := syscall.UTF16PtrFromString("Translate")
settingsTitle, _ := syscall.UTF16PtrFromString("Settings") settingsTitle, _ := syscall.UTF16PtrFromString("Settings")
activeWindowTitle, _ := syscall.UTF16PtrFromString("Active window")
topMostTitle, _ := syscall.UTF16PtrFromString("Top most") topMostTitle, _ := syscall.UTF16PtrFromString("Top most")
fontDownTitle, _ := syscall.UTF16PtrFromString("A-") fontDownTitle, _ := syscall.UTF16PtrFromString("A-")
fontUpTitle, _ := 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) 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("") 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.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, 90, 120, 30, hwnd, 3005, 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, 135, 50, 30, hwnd, 3006, 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, 135, 50, 30, hwnd, 3007, hInstance, 0)
app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 175, 50, 30, hwnd, 3007, hInstance, 0)
app.activeTab = "join" app.activeTab = "join"
app.activeWindow = true
app.lastActiveWindow = false
setTimer.Call(hwnd, timerRefresh, 2000, 0) setTimer.Call(hwnd, timerRefresh, 2000, 0)
applyFont(&app) applyFont(&app)
refreshGUI(setWindowText, &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) ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic) logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic)
return 0 return 0
case wmCommandGUI: case wmCommandGUI:
needsFullRefresh := false
switch uint16(wParam & 0xffff) { switch uint16(wParam & 0xffff) {
case 3001: case 3001:
app.activeTab = "join" app.activeTab = "join"
needsFullRefresh = true
case 3002: case 3002:
app.activeTab = "translate" app.activeTab = "translate"
needsFullRefresh = true
case 3003: case 3003:
app.activeTab = "settings" 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: case 3005:
if !allowRapidSettingsAction(&app) { if !allowRapidSettingsAction(&app) {
return 0 return 0
@@ -263,8 +249,13 @@ func runNativeGUI() error {
saveGUISettings(&app) saveGUISettings(&app)
} }
} }
refreshGUI(setWindowText, &app) if needsFullRefresh {
refreshSettingsControls(showWindowProc, setWindowPosProc, &app) refreshGUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
} else {
refreshCommandUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
}
return 0 return 0
case wmTimerGUI: case wmTimerGUI:
refreshGUI(setWindowText, &app) refreshGUI(setWindowText, &app)
@@ -364,14 +355,6 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current)) t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t))) 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 { if app.btnTopMost != 0 {
label := "Top most: OFF" label := "Top most: OFF"
if app.topMost { if app.topMost {
@@ -388,7 +371,7 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
showWindowProc.Call(app.settingsPaneHwnd, swShowControl) showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags) 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 { if hwnd != 0 {
showWindowProc.Call(hwnd, swShowControl) showWindowProc.Call(hwnd, swShowControl)
} }
@@ -397,7 +380,6 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
} }
if app.rightHwnd != 0 { if app.rightHwnd != 0 {
state := readRuntimeSnapshot() state := readRuntimeSnapshot()
state["active_window"] = app.activeWindow
state["top_most"] = app.topMost state["top_most"] = app.topMost
state["font_size"] = app.fontSize state["font_size"] = app.fontSize
rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount) rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount)
@@ -408,13 +390,27 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
if app.settingsPaneHwnd != 0 { if app.settingsPaneHwnd != 0 {
showWindowProc.Call(app.settingsPaneHwnd, swHideControl) 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 { if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl) 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) { func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) {
if showWindowProc == nil || setWindowPosProc == nil || app == nil { if showWindowProc == nil || setWindowPosProc == nil || app == nil {
return return
@@ -424,7 +420,7 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc,
showWindowProc.Call(app.settingsPaneHwnd, swShowControl) showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags) 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 { if hwnd != 0 {
showWindowProc.Call(hwnd, swShowControl) showWindowProc.Call(hwnd, swShowControl)
} }
@@ -435,7 +431,7 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc,
if app.settingsPaneHwnd != 0 { if app.settingsPaneHwnd != 0 {
showWindowProc.Call(app.settingsPaneHwnd, swHideControl) 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 { if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl) showWindowProc.Call(hwnd, swHideControl)
} }
@@ -443,24 +439,6 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc,
guiLog("refreshSettingsControls hidden") 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) { func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics *syscall.LazyProc) {
if hwnd == 0 || getSystemMetrics == nil { if hwnd == 0 || getSystemMetrics == nil {
return return
@@ -524,7 +502,7 @@ func applyTopMost(hwnd uintptr, app *guiApp) {
if app.topMost { if app.topMost {
setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags) setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags)
} else { } else {
setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpFlags) setWindowPosProc.Call(hwnd, hwndNotTopMostFlag, 0, 0, 0, 0, swpFlags)
} }
app.lastTopMost = app.topMost app.lastTopMost = app.topMost
} }
@@ -814,7 +792,6 @@ func formatGuestPane(title string, items []userState) string {
for _, item := range present { for _, item := range present {
b.WriteString("・ ") b.WriteString("・ ")
b.WriteString(item.Name) b.WriteString(item.Name)
b.WriteString(" [present]")
if !item.LastJoin.IsZero() { if !item.LastJoin.IsZero() {
b.WriteString(" ") b.WriteString(" ")
b.WriteString(item.LastJoin.Format("15:04:05")) b.WriteString(item.LastJoin.Format("15:04:05"))
@@ -835,7 +812,6 @@ func formatGuestPane(title string, items []userState) string {
for _, item := range left { for _, item := range left {
b.WriteString("・ ") b.WriteString("・ ")
b.WriteString(item.Name) b.WriteString(item.Name)
b.WriteString(" [gray]")
if !item.LastLeave.IsZero() { if !item.LastLeave.IsZero() {
b.WriteString(" ") b.WriteString(" ")
b.WriteString(timeAgo(item.LastLeave)) b.WriteString(timeAgo(item.LastLeave))
@@ -873,7 +849,7 @@ func currentSelfName() string {
func loadGUISettings() config.GUIConfig { func loadGUISettings() config.GUIConfig {
cfg, err := config.Load("") cfg, err := config.Load("")
if err != nil || cfg == nil { if err != nil || cfg == nil {
return config.GUIConfig{ActiveWindow: true, FontSize: 18} return config.GUIConfig{TopMost: false, FontSize: 18}
} }
if cfg.GUI.FontSize <= 0 { if cfg.GUI.FontSize <= 0 {
cfg.GUI.FontSize = 18 cfg.GUI.FontSize = 18
@@ -886,9 +862,8 @@ func saveGUISettings(app *guiApp) {
return return
} }
guiCfg := config.GUIConfig{ guiCfg := config.GUIConfig{
ActiveWindow: app.activeWindow, TopMost: app.topMost,
TopMost: app.topMost, FontSize: app.fontSize,
FontSize: app.fontSize,
} }
if err := config.SaveGUI("", guiCfg); err != nil { if err := config.SaveGUI("", guiCfg); err != nil {
guiLog("saveGUISettings failed: " + err.Error()) 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("\r\nWorld: ")
b.WriteString(worldLabel) b.WriteString(worldLabel)
case "settings": case "settings":
b.WriteString("Active window: ") b.WriteString("Top most: ")
if stateBool(state, "active_window") {
b.WriteString("ON")
} else {
b.WriteString("OFF")
}
b.WriteString("\r\nTop most: ")
if stateBool(state, "top_most") { if stateBool(state, "top_most") {
b.WriteString("ON") b.WriteString("ON")
} else { } else {

View File

@@ -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.

View File

@@ -6,15 +6,18 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"unsafe" "unsafe"
"vrc_osc_go/internal/app"
"vrc_osc_go/internal/buildinfo" "vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/common" "vrc_osc_go/internal/common"
"vrc_osc_go/internal/update" "vrc_osc_go/internal/update"
) )
func main() { func main() {
_ = appendLauncherLog("LAUNCHER", "launcher main begin")
var baseURL string var baseURL string
var ownerRepo string var ownerRepo string
var assetName string var assetName string
@@ -32,22 +35,34 @@ func main() {
} }
if _, err := mgr.CheckAndUpdate(); err != nil { if _, err := mgr.CheckAndUpdate(); err != nil {
log.Printf("auto update skipped: %v", err) log.Printf("auto update skipped: %v", err)
_ = appendLauncherLog("LAUNCHER", "auto update skipped: "+err.Error())
} else {
_ = appendLauncherLog("LAUNCHER", "auto update ok")
} }
base := runtimeBaseDir() base := runtimeBaseDir()
exe := filepath.Join(base, "vrc_osc.exe") exe := filepath.Join(base, "vrc_osc.exe")
gui := filepath.Join(base, "vrc_osc_gui.exe") gui := filepath.Join(base, "vrc_osc_gui.exe")
_ = appendLauncherLog("LAUNCHER", "runtimeBaseDir="+base)
if _, err := os.Stat(exe); err != nil { if _, err := os.Stat(exe); err != nil {
_ = appendLauncherLog("LAUNCHER", "missing runtime exe: "+err.Error())
showError("vrc_osc_launcher", "missing exe: "+err.Error()) showError("vrc_osc_launcher", "missing exe: "+err.Error())
log.Fatalf("missing exe: %v", err) 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 { if _, err := os.Stat(gui); err == nil {
_ = appendLauncherLog("LAUNCHER", "starting gui companion")
_ = exec.Command(gui).Start() _ = exec.Command(gui).Start()
} else {
_ = appendLauncherLog("LAUNCHER", "gui companion missing: "+err.Error())
} }
args := append([]string{"--no-gui"}, os.Args[1:]...) args := append([]string{"--no-gui"}, os.Args[1:]...)
_ = appendLauncherLog("LAUNCHER", "launching runtime args="+strings.Join(args, " "))
if err := update.Launch(exe, args); err != nil { if err := update.Launch(exe, args); err != nil {
_ = appendLauncherLog("LAUNCHER", "launch failed: "+err.Error())
showError("vrc_osc_launcher", "launch failed: "+err.Error()) showError("vrc_osc_launcher", "launch failed: "+err.Error())
log.Fatalf("launch failed: %v", err) log.Fatalf("launch failed: %v", err)
} }
_ = appendLauncherLog("LAUNCHER", "runtime launch returned")
} }
func showError(title, message string) { func showError(title, message string) {
@@ -73,3 +88,7 @@ func runtimeBaseDir() string {
} }
return root return root
} }
func appendLauncherLog(title, text string) error {
return app.AppendRuntimeLog(title, text)
}

View File

@@ -7,6 +7,10 @@ names = []
[guest] [guest]
file = "config/guests.txt" file = "config/guests.txt"
[gui]
top_most = false
font_size = 18
[notice] [notice]
missing_count = 0 missing_count = 0

35
config/config.test.toml Normal file
View File

@@ -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

View File

@@ -10,6 +10,7 @@ import (
"vrc_osc_go/internal/config" "vrc_osc_go/internal/config"
"vrc_osc_go/internal/consenttool" "vrc_osc_go/internal/consenttool"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/osc" "vrc_osc_go/internal/osc"
) )
@@ -18,17 +19,25 @@ type discordMuteState struct {
muted bool muted bool
buttonPressed bool buttonPressed bool
lastAction time.Time lastAction time.Time
lastPressEdge time.Time
} }
func Run(configPath string, mode string) error { func Run(configPath string, mode string) error {
if mode != "" { if mode != "" {
return consenttool.Run(configPath, 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) cfg, err := config.Load(configPath)
if err != nil { if err != nil {
log.Printf("config load failed: %v", err)
return err return err
} }
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames)) InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath) 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 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) errCh := make(chan error, 1)
go func() { go func() {
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port) log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
errCh <- server.Serve() errCh <- server.Serve()
}() }()
log.Printf("starting vrchat log watcher")
go watchVrchatLog() go watchVrchatLog()
log.Printf("vrchat log watcher goroutine started")
startSelfMonitor(cfg, discordState) startSelfMonitor(cfg, discordState)
log.Printf("self monitor started")
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

View File

@@ -3,120 +3,87 @@ package app
import ( import (
"fmt" "fmt"
"log" "log"
"os/exec" "strings"
"syscall"
"unsafe"
)
const (
vkControl = 0x11
vkShift = 0x10
vkM = 0x4D
keyUp = 0x0002
swRestore = 9
) )
func pressDiscordMuteHotkey() error { func pressDiscordMuteHotkey() error {
script := ` user32 := syscall.NewLazyDLL("user32.dll")
$ErrorActionPreference = "Stop" 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 @" var target uintptr
using System; cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
using System.Runtime.InteropServices; vis, _, _ := isWindowVisible.Call(hwnd)
public static class Win32 { if vis == 0 {
[StructLayout(LayoutKind.Sequential)] return 1
public struct RECT { }
public int Left; buf := make([]uint16, 512)
public int Top; n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
public int Right; if n == 0 {
public int Bottom; return 1
} }
[DllImport("user32.dll")] title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n])))
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); if !strings.Contains(title, "discord") {
[DllImport("user32.dll")] return 1
public static extern bool SetCursorPos(int X, int Y); }
[DllImport("user32.dll")] target = hwnd
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); return 0
} })
"@ r1, _, err1 := enumWindows.Call(cb, 0)
if r1 == 0 && target == 0 {
function Click-WindowCenter($p) { return fmt.Errorf("discord window not found: %v", err1)
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))
} }
if err != nil { if target == 0 {
return fmt.Errorf("discord mute failed: %w", err) 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 return nil
} }

View File

@@ -30,33 +30,25 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
if isPressed && !state.buttonPressed { if isPressed && !state.buttonPressed {
state.buttonPressed = true state.buttonPressed = true
if state.muted { log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey") if err := pressDiscordMuteHotkey(); err != nil {
if err := pressDiscordMuteHotkey(); err != nil { log.Printf("discord hotkey failed: %v", err)
return err SetDiscordAction("hotkey_failed")
}
state.muted = false
SetDiscordMuted(state.muted)
state.lastAction = now
} else { } 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 return nil
} }
if !isPressed && state.buttonPressed { if !isPressed && state.buttonPressed {
state.buttonPressed = false 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 return nil

View File

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

View File

@@ -1,30 +1,128 @@
package app package app
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os/exec" "os/exec"
"strings"
) )
func runPythonOcrFromScreen() error { type OcrResult struct {
script := ` ImagePath string
import sys TextPath string
from pathlib import Path Text string
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC") Translate string
sys.path.insert(0, str(root / "src")) ErrReason string
from ocr.ocr_actions import runOcrFromScreen }
runOcrFromScreen()
` func runPythonScript(script string) (string, error) {
cmd := exec.Command("python", "-c", script) cmd := exec.Command("python", "-c", script)
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC` cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if len(out) > 0 { if len(out) > 0 {
log.Printf("ocr output: %s", string(out)) log.Printf("python output: %s", string(out))
SetOCRText(string(out))
SetTranslateText("翻訳: 未実装")
} }
if err != nil { 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 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
}

View File

@@ -2,13 +2,45 @@ package app
import ( import (
"fmt" "fmt"
"io"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"vrc_osc_go/internal/common" "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 { func appendRuntimeLog(title, text string) error {
dir := filepath.Join(common.RootDir(), "runtime") dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil { 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) _, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
return err 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)
}

View File

@@ -38,12 +38,15 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
if nextState == "joined" { if nextState == "joined" {
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil { if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
log.Printf("self monitor discord mute failed: %v", err) log.Printf("self monitor discord mute failed: %v", err)
SetDiscordSource("self_monitor_error")
} }
} else if nextState == "left" { } else if nextState == "left" {
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil { if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
log.Printf("self monitor discord mute failed: %v", err) log.Printf("self monitor discord mute failed: %v", err)
SetDiscordSource("self_monitor_error")
} }
} }
SetDiscordSource("self_monitor")
lastState = nextState lastState = nextState
} }
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)

View File

@@ -13,6 +13,9 @@ import (
type RuntimeState struct { type RuntimeState struct {
mu sync.RWMutex mu sync.RWMutex
DiscordMuted bool DiscordMuted bool
DiscordWindow string
DiscordSource string
DiscordAction string
LastOCRText string LastOCRText string
LastTranslate string LastTranslate string
CurrentWorld string CurrentWorld string
@@ -39,6 +42,27 @@ func SetDiscordMuted(v bool) {
_ = persistRuntimeState() _ = 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) { func SetOCRText(v string) {
runtimeState.mu.Lock() runtimeState.mu.Lock()
runtimeState.LastOCRText = v runtimeState.LastOCRText = v
@@ -81,6 +105,9 @@ func persistRuntimeState() error {
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(map[string]any{ return enc.Encode(map[string]any{
"discord_muted": s.DiscordMuted, "discord_muted": s.DiscordMuted,
"discord_window": s.DiscordWindow,
"discord_source": s.DiscordSource,
"discord_action": s.DiscordAction,
"ocr": s.LastOCRText, "ocr": s.LastOCRText,
"translate": s.LastTranslate, "translate": s.LastTranslate,
"world": s.CurrentWorld, "world": s.CurrentWorld,

View File

@@ -1,7 +1,6 @@
package app package app
import ( import (
"bufio"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -9,6 +8,9 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"unicode/utf8"
"syscall"
"unsafe"
) )
var ( var (
@@ -77,6 +79,12 @@ func findLatestVrchatLog() (string, error) {
} }
func watchVrchatLog() { func watchVrchatLog() {
log.Printf("vrchat log watcher started")
defer func() {
if r := recover(); r != nil {
log.Printf("vrchat log watcher panic: %v", r)
}
}()
lastPath := "" lastPath := ""
lastSize := int64(0) lastSize := int64(0)
state := &vrcLogState{ state := &vrcLogState{
@@ -85,20 +93,26 @@ func watchVrchatLog() {
for { for {
path, err := findLatestVrchatLog() path, err := findLatestVrchatLog()
if err != nil { if err != nil {
log.Printf("vrchat log not found: %v", err)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
continue continue
} }
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
log.Printf("vrchat log stat failed: %v", err)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
continue continue
} }
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
if path != lastPath { if path != lastPath {
log.Printf("vrchat log watching %s", path) log.Printf("vrchat log watching %s", path)
lastPath = path lastPath = path
lastSize = 0 lastSize = 0
log.Printf("vrchat log initial scan begin path=%s", path)
if err := scanExistingVrchatLog(path, state); err != nil { if err := scanExistingVrchatLog(path, state); err != nil {
log.Printf("vrchat log initial scan failed: %v", err) 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 { if info2, err := os.Stat(path); err == nil {
lastSize = info2.Size() lastSize = info2.Size()
@@ -113,15 +127,22 @@ func watchVrchatLog() {
lastSize = 0 lastSize = 0
} }
if info.Size() > lastSize { 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 { if err != nil {
log.Printf("vrchat log read failed: %v", err)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
continue continue
} }
_, _ = f.Seek(lastSize, 0) text := decodeVRChatLog(b)
scanner := bufio.NewScanner(f) lines := strings.Split(text, "\n")
for scanner.Scan() { joinHits := 0
line := scanner.Text() leaveHits := 0
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
if err := appendRuntimeLog("VRC LOG", line); err != nil { if err := appendRuntimeLog("VRC LOG", line); err != nil {
log.Printf("append runtime log failed: %v", err) log.Printf("append runtime log failed: %v", err)
} }
@@ -137,41 +158,47 @@ func watchVrchatLog() {
at := extractLineTime(line) at := extractLineTime(line)
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
log.Printf("join detected: %s", name)
joinHits++
state.presentSet[name] = struct{}{} state.presentSet[name] = struct{}{}
if tracker := GetGuestTracker(); tracker != nil { if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkJoin(name, at) tracker.MarkJoin(name, at)
} }
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
log.Print(out) log.Print(out)
_ = appendJoinLeaveLog(out) if err := appendJoinLeaveLog(out); err != nil {
log.Printf("append join log failed: %v", err)
}
continue continue
} }
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
log.Printf("leave detected: %s", name)
leaveHits++
delete(state.presentSet, name) delete(state.presentSet, name)
if tracker := GetGuestTracker(); tracker != nil { if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkLeave(name, at) tracker.MarkLeave(name, at)
} }
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
log.Print(out) log.Print(out)
_ = appendJoinLeaveLog(out) if err := appendJoinLeaveLog(out); err != nil {
log.Printf("append leave log failed: %v", err)
}
continue continue
} }
} }
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
lastSize = info.Size() lastSize = info.Size()
_ = f.Close()
} }
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
} }
func scanExistingVrchatLog(path string, state *vrcLogState) error { func scanExistingVrchatLog(path string, state *vrcLogState) error {
f, err := os.Open(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return err
} }
defer f.Close()
state.presentSet = map[string]struct{}{} state.presentSet = map[string]struct{}{}
state.location = "" state.location = ""
state.worldID = "" state.worldID = ""
@@ -180,9 +207,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
state.pendingWorldName = "" state.pendingWorldName = ""
state.initialized = false state.initialized = false
scanner := bufio.NewScanner(f) text := decodeVRChatLog(b)
for scanner.Scan() { for _, raw := range strings.Split(text, "\n") {
line := scanner.Text() line := strings.TrimRight(raw, "\r")
if line == "" {
continue
}
if changed, _ := updateWorldState(state, line); changed { if changed, _ := updateWorldState(state, line); changed {
state.presentSet = map[string]struct{}{} state.presentSet = map[string]struct{}{}
} }
@@ -203,9 +233,6 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
continue continue
} }
} }
if err := scanner.Err(); err != nil {
return err
}
state.initialized = true state.initialized = true
if state.worldName != "" { if state.worldName != "" {
log.Printf("world changed: %s", 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 { if tracker := GetGuestTracker(); tracker != nil {
tracker.SetCurrentInstance(state.worldName) 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 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 == '<27>':
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) { func updateWorldState(state *vrcLogState, line string) (bool, string) {
worldID := "" worldID := ""
instanceID := "" instanceID := ""

View File

@@ -5,6 +5,8 @@ import (
"path/filepath" "path/filepath"
) )
var rootDirFunc = RuntimeBaseDir
func RuntimeBaseDir() string { func RuntimeBaseDir() string {
exe, err := os.Executable() exe, err := os.Executable()
if err == nil { if err == nil {
@@ -13,4 +15,12 @@ func RuntimeBaseDir() string {
return "." 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
}
}

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"bufio" "bufio"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -13,6 +14,7 @@ import (
type Config struct { type Config struct {
OSC OSCConfig OSC OSCConfig
VrcLog VrcLogConfig VrcLog VrcLogConfig
GUI GUIConfig
} }
type OSCConfig struct { type OSCConfig struct {
@@ -29,9 +31,15 @@ type VrcLogConfig struct {
LogPatterns []string LogPatterns []string
} }
type GUIConfig struct {
TopMost bool
FontSize int
}
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
cfg := &Config{ cfg := &Config{
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
GUI: GUIConfig{FontSize: 18},
} }
if path == "" { if path == "" {
path = filepath.Join(common.RootDir(), "config", "config.toml") path = filepath.Join(common.RootDir(), "config", "config.toml")
@@ -98,6 +106,15 @@ func Load(path string) (*Config, error) {
case "file": case "file":
cfg.VrcLog.GuestFile = val 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 != "" { if cfg.VrcLog.GuestFile != "" {
@@ -111,6 +128,19 @@ func Load(path string) (*Config, error) {
return cfg, scanner.Err() 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 { func parseList(value string) []string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "[") value = strings.TrimPrefix(value, "[")
@@ -129,6 +159,76 @@ func parseList(value string) []string {
return out 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) { func loadLines(path string) ([]string, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "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)
}
}