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

View File

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

View File

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

View File

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

View File

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

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 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)
}
}
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,7 +862,6 @@ func saveGUISettings(app *guiApp) {
return
}
guiCfg := config.GUIConfig{
ActiveWindow: app.activeWindow,
TopMost: app.topMost,
FontSize: app.fontSize,
}
@@ -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 {

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

View File

@@ -7,6 +7,10 @@ names = []
[guest]
file = "config/guests.txt"
[gui]
top_most = false
font_size = 18
[notice]
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/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)

View File

@@ -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;
var target uintptr
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
vis, _, _ := isWindowVisible.Call(hwnd)
if vis == 0 {
return 1
}
[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);
buf := make([]uint16, 512)
n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
if n == 0 {
return 1
}
"@
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
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 target == 0 {
return fmt.Errorf("discord window not found")
}
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
showWindow.Call(target, swRestore)
setForegroundWindow.Call(target)
sleep.Call(150)
type rect struct {
Left int32
Top int32
Right int32
Bottom int32
}
if (-not $windows) { return @() }
return $windows
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)
}
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 }
send := func(vk byte, flags uintptr) {
keybdEvent.Call(uintptr(vk), 0, flags, 0)
}
if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
if ($title.Contains('discord')) { $score += 5 }
$score
} -Descending
send(vkControl, 0)
send(vkShift, 0)
send(vkM, 0)
send(vkM, keyUp)
send(vkShift, keyUp)
send(vkControl, keyUp)
sleep.Call(100)
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 {
return fmt.Errorf("discord mute failed: %w", err)
}
SetDiscordWindow("Discord")
log.Printf("discord hotkey sent via keybd_event")
return nil
}

View File

@@ -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")
log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
if err := pressDiscordMuteHotkey(); err != nil {
return err
}
state.muted = false
SetDiscordMuted(state.muted)
state.lastAction = now
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

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

View File

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

View File

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

View File

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

View File

@@ -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 == '<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) {
worldID := ""
instanceID := ""

View File

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

View File

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

View File

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