Simplify GUI settings and build flags
This commit is contained in:
1
EXE.md
1
EXE.md
@@ -7,6 +7,7 @@ Release の zip は Gitea Actions で作ります。
|
||||
```powershell
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd\vrc_osc_gui
|
||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
6
cmd/vrc_osc_launcher/launcher_icon.go
Normal file
6
cmd/vrc_osc_launcher/launcher_icon.go
Normal 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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
35
config/config.test.toml
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
58
internal/app/guest_status_test.go
Normal file
58
internal/app/guest_status_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user