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
|
```powershell
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
||||||
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd\vrc_osc_gui
|
||||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Release の zip には次のファイルが入ります。
|
|||||||
go test ./...
|
go test ./...
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||||
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd/vrc_osc_gui
|
||||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"vrc_osc_go/internal/app"
|
"vrc_osc_go/internal/app"
|
||||||
|
"vrc_osc_go/internal/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Printf("vrc_osc version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
|
||||||
|
log.Printf("runtime main begin")
|
||||||
var configPath string
|
var configPath string
|
||||||
var mode string
|
var mode string
|
||||||
var noGUI bool
|
var noGUI bool
|
||||||
@@ -20,23 +23,33 @@ func main() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if mode == "" && !noGUI {
|
if mode == "" && !noGUI {
|
||||||
|
log.Printf("launching gui companion")
|
||||||
launchGUI()
|
launchGUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("calling app.Run config=%s mode=%s noGUI=%v", configPath, mode, noGUI)
|
||||||
if err := app.Run(configPath, mode); err != nil {
|
if err := app.Run(configPath, mode); err != nil {
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
log.Fatalf("vrc_osc_go: %v", err)
|
log.Fatalf("vrc_osc_go: %v", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("app.Run returned cleanly")
|
||||||
}
|
}
|
||||||
|
|
||||||
func launchGUI() {
|
func launchGUI() {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("launchGUI: os.Executable failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
||||||
|
log.Printf("launchGUI: exe=%s gui=%s", exe, gui)
|
||||||
if _, err := os.Stat(gui); err != nil {
|
if _, err := os.Stat(gui); err != nil {
|
||||||
|
log.Printf("launchGUI: gui missing: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = exec.Command(gui).Start()
|
if err := exec.Command(gui).Start(); err != nil {
|
||||||
|
log.Printf("launchGUI: start failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("launchGUI: started gui companion")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"log"
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"vrc_osc_go/internal/app"
|
"vrc_osc_go/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe")
|
if exe, err := os.Executable(); err == nil {
|
||||||
log.Fatal(runNativeGUI())
|
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe))
|
||||||
|
} else {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error())
|
||||||
|
}
|
||||||
|
if err := runNativeGUI(); err != nil {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "runNativeGUI failed: "+err.Error())
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "runNativeGUI returned cleanly")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
@@ -55,6 +57,14 @@ func openBrowser(url string) {
|
|||||||
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trayIconPath() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Join("doc", "vrcworldtour_new.ico")
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(exe), "..", "doc", "vrcworldtour_new.ico")
|
||||||
|
}
|
||||||
|
|
||||||
func runTray(uiURL string, open func()) error {
|
func runTray(uiURL string, open func()) error {
|
||||||
user32 := syscall.NewLazyDLL("user32.dll")
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
shell32 := syscall.NewLazyDLL("shell32.dll")
|
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||||
@@ -72,7 +82,7 @@ func runTray(uiURL string, open func()) error {
|
|||||||
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||||
trackPopupMenu := user32.NewProc("TrackPopupMenu")
|
trackPopupMenu := user32.NewProc("TrackPopupMenu")
|
||||||
showWindow := user32.NewProc("ShowWindow")
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
loadIcon := user32.NewProc("LoadIconW")
|
loadImage := user32.NewProc("LoadImageW")
|
||||||
shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW")
|
shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW")
|
||||||
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||||
|
|
||||||
@@ -119,7 +129,13 @@ func runTray(uiURL string, open func()) error {
|
|||||||
|
|
||||||
className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY")
|
className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY")
|
||||||
windowTitle, _ := syscall.UTF16PtrFromString("VRC OSC Tray")
|
windowTitle, _ := syscall.UTF16PtrFromString("VRC OSC Tray")
|
||||||
icon, _, _ := loadIcon.Call(0, 32512) // IDI_APPLICATION
|
iconPath := trayIconPath()
|
||||||
|
iconPathPtr, _ := syscall.UTF16PtrFromString(iconPath)
|
||||||
|
const (
|
||||||
|
lrLoadFromFile = 0x00000010
|
||||||
|
imageIcon = 1
|
||||||
|
)
|
||||||
|
icon, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(iconPathPtr)), imageIcon, 0, 0, lrLoadFromFile)
|
||||||
|
|
||||||
var hwnd uintptr
|
var hwnd uintptr
|
||||||
var menu uintptr
|
var menu uintptr
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\
|
|||||||
var vrcLogTimePattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})`)
|
var vrcLogTimePattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})`)
|
||||||
var vrcJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
var vrcJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||||
var vrcLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
var vrcLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||||
var hwndTopMostFlag uintptr = ^uintptr(0) - 1
|
var hwndTopMostFlag uintptr = ^uintptr(0)
|
||||||
|
var hwndNotTopMostFlag uintptr = ^uintptr(0) - 1
|
||||||
var hwndNoMove uintptr = 0x0002
|
var hwndNoMove uintptr = 0x0002
|
||||||
var hwndNoSize uintptr = 0x0001
|
var hwndNoSize uintptr = 0x0001
|
||||||
var hwndNoActivate uintptr = 0x0010
|
var hwndNoActivate uintptr = 0x0010
|
||||||
@@ -74,13 +75,10 @@ type guiApp struct {
|
|||||||
tabJoin uintptr
|
tabJoin uintptr
|
||||||
tabTranslate uintptr
|
tabTranslate uintptr
|
||||||
tabSettings uintptr
|
tabSettings uintptr
|
||||||
btnActiveWindow uintptr
|
|
||||||
btnTopMost uintptr
|
btnTopMost uintptr
|
||||||
btnFontDown uintptr
|
btnFontDown uintptr
|
||||||
btnFontUp uintptr
|
btnFontUp uintptr
|
||||||
activeTab string
|
activeTab string
|
||||||
activeWindow bool
|
|
||||||
lastActiveWindow bool
|
|
||||||
topMost bool
|
topMost bool
|
||||||
lastTopMost bool
|
lastTopMost bool
|
||||||
fontSize int
|
fontSize int
|
||||||
@@ -125,15 +123,12 @@ func runNativeGUI() error {
|
|||||||
translateMessage := user32.NewProc("TranslateMessage")
|
translateMessage := user32.NewProc("TranslateMessage")
|
||||||
dispatchMessage := user32.NewProc("DispatchMessageW")
|
dispatchMessage := user32.NewProc("DispatchMessageW")
|
||||||
postQuitMessage := user32.NewProc("PostQuitMessage")
|
postQuitMessage := user32.NewProc("PostQuitMessage")
|
||||||
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
|
||||||
bringWindowToTop := user32.NewProc("BringWindowToTop")
|
|
||||||
setTimer := user32.NewProc("SetTimer")
|
setTimer := user32.NewProc("SetTimer")
|
||||||
loadCursor := user32.NewProc("LoadCursorW")
|
loadCursor := user32.NewProc("LoadCursorW")
|
||||||
setWindowText := user32.NewProc("SetWindowTextW")
|
setWindowText := user32.NewProc("SetWindowTextW")
|
||||||
getWindowRect := user32.NewProc("GetWindowRect")
|
getWindowRect := user32.NewProc("GetWindowRect")
|
||||||
isWindowVisible := user32.NewProc("IsWindowVisible")
|
isWindowVisible := user32.NewProc("IsWindowVisible")
|
||||||
isIconic := user32.NewProc("IsIconic")
|
isIconic := user32.NewProc("IsIconic")
|
||||||
getForegroundWindow := user32.NewProc("GetForegroundWindow")
|
|
||||||
getSystemMetrics := user32.NewProc("GetSystemMetrics")
|
getSystemMetrics := user32.NewProc("GetSystemMetrics")
|
||||||
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||||
loadIcon := user32.NewProc("LoadIconW")
|
loadIcon := user32.NewProc("LoadIconW")
|
||||||
@@ -168,7 +163,6 @@ func runNativeGUI() error {
|
|||||||
|
|
||||||
var app guiApp
|
var app guiApp
|
||||||
guiCfg := loadGUISettings()
|
guiCfg := loadGUISettings()
|
||||||
app.activeWindow = guiCfg.ActiveWindow
|
|
||||||
app.topMost = guiCfg.TopMost
|
app.topMost = guiCfg.TopMost
|
||||||
if guiCfg.FontSize > 0 {
|
if guiCfg.FontSize > 0 {
|
||||||
app.fontSize = guiCfg.FontSize
|
app.fontSize = guiCfg.FontSize
|
||||||
@@ -187,7 +181,6 @@ func runNativeGUI() error {
|
|||||||
joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
|
joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
|
||||||
translateTitle, _ := syscall.UTF16PtrFromString("Translate")
|
translateTitle, _ := syscall.UTF16PtrFromString("Translate")
|
||||||
settingsTitle, _ := syscall.UTF16PtrFromString("Settings")
|
settingsTitle, _ := syscall.UTF16PtrFromString("Settings")
|
||||||
activeWindowTitle, _ := syscall.UTF16PtrFromString("Active window")
|
|
||||||
topMostTitle, _ := syscall.UTF16PtrFromString("Top most")
|
topMostTitle, _ := syscall.UTF16PtrFromString("Top most")
|
||||||
fontDownTitle, _ := syscall.UTF16PtrFromString("A-")
|
fontDownTitle, _ := syscall.UTF16PtrFromString("A-")
|
||||||
fontUpTitle, _ := syscall.UTF16PtrFromString("A+")
|
fontUpTitle, _ := syscall.UTF16PtrFromString("A+")
|
||||||
@@ -198,35 +191,28 @@ func runNativeGUI() error {
|
|||||||
app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog, hInstance, 0)
|
app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog, hInstance, 0)
|
||||||
settingsPaneTitle, _ := syscall.UTF16PtrFromString("")
|
settingsPaneTitle, _ := syscall.UTF16PtrFromString("")
|
||||||
app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(settingsPaneTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog+1, hInstance, 0)
|
app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(settingsPaneTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog+1, hInstance, 0)
|
||||||
app.btnActiveWindow, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(activeWindowTitle)), wsVisible|wsChild, 390, 90, 130, 30, hwnd, 3004, hInstance, 0)
|
app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), wsVisible|wsChild, 390, 90, 120, 30, hwnd, 3005, hInstance, 0)
|
||||||
app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), wsVisible|wsChild, 390, 130, 120, 30, hwnd, 3005, hInstance, 0)
|
app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), wsVisible|wsChild, 390, 135, 50, 30, hwnd, 3006, hInstance, 0)
|
||||||
app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), wsVisible|wsChild, 390, 175, 50, 30, hwnd, 3006, hInstance, 0)
|
app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 135, 50, 30, hwnd, 3007, hInstance, 0)
|
||||||
app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 175, 50, 30, hwnd, 3007, hInstance, 0)
|
|
||||||
app.activeTab = "join"
|
app.activeTab = "join"
|
||||||
app.activeWindow = true
|
|
||||||
app.lastActiveWindow = false
|
|
||||||
setTimer.Call(hwnd, timerRefresh, 2000, 0)
|
setTimer.Call(hwnd, timerRefresh, 2000, 0)
|
||||||
applyFont(&app)
|
applyFont(&app)
|
||||||
refreshGUI(setWindowText, &app)
|
refreshGUI(setWindowText, &app)
|
||||||
applyActiveWindow(hwnd, &app, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic)
|
applyTopMost(hwnd, &app)
|
||||||
ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
|
ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
|
||||||
logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic)
|
logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic)
|
||||||
return 0
|
return 0
|
||||||
case wmCommandGUI:
|
case wmCommandGUI:
|
||||||
|
needsFullRefresh := false
|
||||||
switch uint16(wParam & 0xffff) {
|
switch uint16(wParam & 0xffff) {
|
||||||
case 3001:
|
case 3001:
|
||||||
app.activeTab = "join"
|
app.activeTab = "join"
|
||||||
|
needsFullRefresh = true
|
||||||
case 3002:
|
case 3002:
|
||||||
app.activeTab = "translate"
|
app.activeTab = "translate"
|
||||||
|
needsFullRefresh = true
|
||||||
case 3003:
|
case 3003:
|
||||||
app.activeTab = "settings"
|
app.activeTab = "settings"
|
||||||
case 3004:
|
|
||||||
if !allowRapidSettingsAction(&app) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
app.activeWindow = !app.activeWindow
|
|
||||||
saveGUISettings(&app)
|
|
||||||
applyActiveWindow(hwnd, &app, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic)
|
|
||||||
case 3005:
|
case 3005:
|
||||||
if !allowRapidSettingsAction(&app) {
|
if !allowRapidSettingsAction(&app) {
|
||||||
return 0
|
return 0
|
||||||
@@ -263,8 +249,13 @@ func runNativeGUI() error {
|
|||||||
saveGUISettings(&app)
|
saveGUISettings(&app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshGUI(setWindowText, &app)
|
if needsFullRefresh {
|
||||||
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
|
refreshGUI(setWindowText, &app)
|
||||||
|
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
|
||||||
|
} else {
|
||||||
|
refreshCommandUI(setWindowText, &app)
|
||||||
|
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
case wmTimerGUI:
|
case wmTimerGUI:
|
||||||
refreshGUI(setWindowText, &app)
|
refreshGUI(setWindowText, &app)
|
||||||
@@ -364,14 +355,6 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
|||||||
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
|
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
|
||||||
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t)))
|
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t)))
|
||||||
}
|
}
|
||||||
if app.btnActiveWindow != 0 {
|
|
||||||
label := "Active window: OFF"
|
|
||||||
if app.activeWindow {
|
|
||||||
label = "Active window: ON"
|
|
||||||
}
|
|
||||||
t, _ := syscall.UTF16PtrFromString(label)
|
|
||||||
setWindowText.Call(app.btnActiveWindow, uintptr(unsafe.Pointer(t)))
|
|
||||||
}
|
|
||||||
if app.btnTopMost != 0 {
|
if app.btnTopMost != 0 {
|
||||||
label := "Top most: OFF"
|
label := "Top most: OFF"
|
||||||
if app.topMost {
|
if app.topMost {
|
||||||
@@ -388,7 +371,7 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
|||||||
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
|
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
|
||||||
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
|
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
|
||||||
}
|
}
|
||||||
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
||||||
if hwnd != 0 {
|
if hwnd != 0 {
|
||||||
showWindowProc.Call(hwnd, swShowControl)
|
showWindowProc.Call(hwnd, swShowControl)
|
||||||
}
|
}
|
||||||
@@ -397,7 +380,6 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
|||||||
}
|
}
|
||||||
if app.rightHwnd != 0 {
|
if app.rightHwnd != 0 {
|
||||||
state := readRuntimeSnapshot()
|
state := readRuntimeSnapshot()
|
||||||
state["active_window"] = app.activeWindow
|
|
||||||
state["top_most"] = app.topMost
|
state["top_most"] = app.topMost
|
||||||
state["font_size"] = app.fontSize
|
state["font_size"] = app.fontSize
|
||||||
rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount)
|
rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount)
|
||||||
@@ -408,13 +390,27 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
|||||||
if app.settingsPaneHwnd != 0 {
|
if app.settingsPaneHwnd != 0 {
|
||||||
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
|
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
|
||||||
}
|
}
|
||||||
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
||||||
if hwnd != 0 {
|
if hwnd != 0 {
|
||||||
showWindowProc.Call(hwnd, swHideControl)
|
showWindowProc.Call(hwnd, swHideControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshCommandUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
||||||
|
if app == nil || setWindowText == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if app.btnTopMost != 0 {
|
||||||
|
label := "Top most: OFF"
|
||||||
|
if app.topMost {
|
||||||
|
label = "Top most: ON"
|
||||||
|
}
|
||||||
|
t, _ := syscall.UTF16PtrFromString(label)
|
||||||
|
setWindowText.Call(app.btnTopMost, uintptr(unsafe.Pointer(t)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) {
|
func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) {
|
||||||
if showWindowProc == nil || setWindowPosProc == nil || app == nil {
|
if showWindowProc == nil || setWindowPosProc == nil || app == nil {
|
||||||
return
|
return
|
||||||
@@ -424,7 +420,7 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc,
|
|||||||
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
|
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
|
||||||
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
|
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags)
|
||||||
}
|
}
|
||||||
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
||||||
if hwnd != 0 {
|
if hwnd != 0 {
|
||||||
showWindowProc.Call(hwnd, swShowControl)
|
showWindowProc.Call(hwnd, swShowControl)
|
||||||
}
|
}
|
||||||
@@ -435,7 +431,7 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc,
|
|||||||
if app.settingsPaneHwnd != 0 {
|
if app.settingsPaneHwnd != 0 {
|
||||||
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
|
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
|
||||||
}
|
}
|
||||||
for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} {
|
||||||
if hwnd != 0 {
|
if hwnd != 0 {
|
||||||
showWindowProc.Call(hwnd, swHideControl)
|
showWindowProc.Call(hwnd, swHideControl)
|
||||||
}
|
}
|
||||||
@@ -443,24 +439,6 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc,
|
|||||||
guiLog("refreshSettingsControls hidden")
|
guiLog("refreshSettingsControls hidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyActiveWindow(hwnd uintptr, app *guiApp, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic *syscall.LazyProc) {
|
|
||||||
if hwnd == 0 || app.activeWindow == app.lastActiveWindow {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if app.activeWindow {
|
|
||||||
showWindowProc.Call(hwnd, swRestore)
|
|
||||||
setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpShowFlags)
|
|
||||||
bringWindowToTop.Call(hwnd)
|
|
||||||
setForegroundWindow.Call(hwnd)
|
|
||||||
foreground, _, _ := getForegroundWindow.Call()
|
|
||||||
guiLog(fmt.Sprintf("applyActiveWindow on foreground=%d target=%d", foreground, hwnd))
|
|
||||||
ensureWindowVisible("active-window", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics)
|
|
||||||
} else {
|
|
||||||
guiLog("applyActiveWindow off")
|
|
||||||
}
|
|
||||||
app.lastActiveWindow = app.activeWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics *syscall.LazyProc) {
|
func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics *syscall.LazyProc) {
|
||||||
if hwnd == 0 || getSystemMetrics == nil {
|
if hwnd == 0 || getSystemMetrics == nil {
|
||||||
return
|
return
|
||||||
@@ -524,7 +502,7 @@ func applyTopMost(hwnd uintptr, app *guiApp) {
|
|||||||
if app.topMost {
|
if app.topMost {
|
||||||
setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags)
|
setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags)
|
||||||
} else {
|
} else {
|
||||||
setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpFlags)
|
setWindowPosProc.Call(hwnd, hwndNotTopMostFlag, 0, 0, 0, 0, swpFlags)
|
||||||
}
|
}
|
||||||
app.lastTopMost = app.topMost
|
app.lastTopMost = app.topMost
|
||||||
}
|
}
|
||||||
@@ -814,7 +792,6 @@ func formatGuestPane(title string, items []userState) string {
|
|||||||
for _, item := range present {
|
for _, item := range present {
|
||||||
b.WriteString("・ ")
|
b.WriteString("・ ")
|
||||||
b.WriteString(item.Name)
|
b.WriteString(item.Name)
|
||||||
b.WriteString(" [present]")
|
|
||||||
if !item.LastJoin.IsZero() {
|
if !item.LastJoin.IsZero() {
|
||||||
b.WriteString(" ")
|
b.WriteString(" ")
|
||||||
b.WriteString(item.LastJoin.Format("15:04:05"))
|
b.WriteString(item.LastJoin.Format("15:04:05"))
|
||||||
@@ -835,7 +812,6 @@ func formatGuestPane(title string, items []userState) string {
|
|||||||
for _, item := range left {
|
for _, item := range left {
|
||||||
b.WriteString("・ ")
|
b.WriteString("・ ")
|
||||||
b.WriteString(item.Name)
|
b.WriteString(item.Name)
|
||||||
b.WriteString(" [gray]")
|
|
||||||
if !item.LastLeave.IsZero() {
|
if !item.LastLeave.IsZero() {
|
||||||
b.WriteString(" ")
|
b.WriteString(" ")
|
||||||
b.WriteString(timeAgo(item.LastLeave))
|
b.WriteString(timeAgo(item.LastLeave))
|
||||||
@@ -873,7 +849,7 @@ func currentSelfName() string {
|
|||||||
func loadGUISettings() config.GUIConfig {
|
func loadGUISettings() config.GUIConfig {
|
||||||
cfg, err := config.Load("")
|
cfg, err := config.Load("")
|
||||||
if err != nil || cfg == nil {
|
if err != nil || cfg == nil {
|
||||||
return config.GUIConfig{ActiveWindow: true, FontSize: 18}
|
return config.GUIConfig{TopMost: false, FontSize: 18}
|
||||||
}
|
}
|
||||||
if cfg.GUI.FontSize <= 0 {
|
if cfg.GUI.FontSize <= 0 {
|
||||||
cfg.GUI.FontSize = 18
|
cfg.GUI.FontSize = 18
|
||||||
@@ -886,9 +862,8 @@ func saveGUISettings(app *guiApp) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guiCfg := config.GUIConfig{
|
guiCfg := config.GUIConfig{
|
||||||
ActiveWindow: app.activeWindow,
|
TopMost: app.topMost,
|
||||||
TopMost: app.topMost,
|
FontSize: app.fontSize,
|
||||||
FontSize: app.fontSize,
|
|
||||||
}
|
}
|
||||||
if err := config.SaveGUI("", guiCfg); err != nil {
|
if err := config.SaveGUI("", guiCfg); err != nil {
|
||||||
guiLog("saveGUISettings failed: " + err.Error())
|
guiLog("saveGUISettings failed: " + err.Error())
|
||||||
@@ -941,13 +916,7 @@ func formatRightPane(tab string, state map[string]any, worldLabel string, curren
|
|||||||
b.WriteString("\r\nWorld: ")
|
b.WriteString("\r\nWorld: ")
|
||||||
b.WriteString(worldLabel)
|
b.WriteString(worldLabel)
|
||||||
case "settings":
|
case "settings":
|
||||||
b.WriteString("Active window: ")
|
b.WriteString("Top most: ")
|
||||||
if stateBool(state, "active_window") {
|
|
||||||
b.WriteString("ON")
|
|
||||||
} else {
|
|
||||||
b.WriteString("OFF")
|
|
||||||
}
|
|
||||||
b.WriteString("\r\nTop most: ")
|
|
||||||
if stateBool(state, "top_most") {
|
if stateBool(state, "top_most") {
|
||||||
b.WriteString("ON")
|
b.WriteString("ON")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/app"
|
||||||
"vrc_osc_go/internal/buildinfo"
|
"vrc_osc_go/internal/buildinfo"
|
||||||
"vrc_osc_go/internal/common"
|
"vrc_osc_go/internal/common"
|
||||||
"vrc_osc_go/internal/update"
|
"vrc_osc_go/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "launcher main begin")
|
||||||
var baseURL string
|
var baseURL string
|
||||||
var ownerRepo string
|
var ownerRepo string
|
||||||
var assetName string
|
var assetName string
|
||||||
@@ -32,22 +35,34 @@ func main() {
|
|||||||
}
|
}
|
||||||
if _, err := mgr.CheckAndUpdate(); err != nil {
|
if _, err := mgr.CheckAndUpdate(); err != nil {
|
||||||
log.Printf("auto update skipped: %v", err)
|
log.Printf("auto update skipped: %v", err)
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "auto update skipped: "+err.Error())
|
||||||
|
} else {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "auto update ok")
|
||||||
}
|
}
|
||||||
base := runtimeBaseDir()
|
base := runtimeBaseDir()
|
||||||
exe := filepath.Join(base, "vrc_osc.exe")
|
exe := filepath.Join(base, "vrc_osc.exe")
|
||||||
gui := filepath.Join(base, "vrc_osc_gui.exe")
|
gui := filepath.Join(base, "vrc_osc_gui.exe")
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "runtimeBaseDir="+base)
|
||||||
if _, err := os.Stat(exe); err != nil {
|
if _, err := os.Stat(exe); err != nil {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "missing runtime exe: "+err.Error())
|
||||||
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
||||||
log.Fatalf("missing exe: %v", err)
|
log.Fatalf("missing exe: %v", err)
|
||||||
}
|
}
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "version="+buildinfo.Version+" build="+buildinfo.BuildTime+" base="+base+" exe="+exe+" gui="+gui)
|
||||||
if _, err := os.Stat(gui); err == nil {
|
if _, err := os.Stat(gui); err == nil {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "starting gui companion")
|
||||||
_ = exec.Command(gui).Start()
|
_ = exec.Command(gui).Start()
|
||||||
|
} else {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "gui companion missing: "+err.Error())
|
||||||
}
|
}
|
||||||
args := append([]string{"--no-gui"}, os.Args[1:]...)
|
args := append([]string{"--no-gui"}, os.Args[1:]...)
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "launching runtime args="+strings.Join(args, " "))
|
||||||
if err := update.Launch(exe, args); err != nil {
|
if err := update.Launch(exe, args); err != nil {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "launch failed: "+err.Error())
|
||||||
showError("vrc_osc_launcher", "launch failed: "+err.Error())
|
showError("vrc_osc_launcher", "launch failed: "+err.Error())
|
||||||
log.Fatalf("launch failed: %v", err)
|
log.Fatalf("launch failed: %v", err)
|
||||||
}
|
}
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "runtime launch returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
func showError(title, message string) {
|
func showError(title, message string) {
|
||||||
@@ -73,3 +88,7 @@ func runtimeBaseDir() string {
|
|||||||
}
|
}
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendLauncherLog(title, text string) error {
|
||||||
|
return app.AppendRuntimeLog(title, text)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ names = []
|
|||||||
[guest]
|
[guest]
|
||||||
file = "config/guests.txt"
|
file = "config/guests.txt"
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
top_most = false
|
||||||
|
font_size = 18
|
||||||
|
|
||||||
[notice]
|
[notice]
|
||||||
missing_count = 0
|
missing_count = 0
|
||||||
|
|
||||||
|
|||||||
35
config/config.test.toml
Normal file
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/config"
|
||||||
"vrc_osc_go/internal/consenttool"
|
"vrc_osc_go/internal/consenttool"
|
||||||
|
"vrc_osc_go/internal/buildinfo"
|
||||||
"vrc_osc_go/internal/osc"
|
"vrc_osc_go/internal/osc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,17 +19,25 @@ type discordMuteState struct {
|
|||||||
muted bool
|
muted bool
|
||||||
buttonPressed bool
|
buttonPressed bool
|
||||||
lastAction time.Time
|
lastAction time.Time
|
||||||
|
lastPressEdge time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(configPath string, mode string) error {
|
func Run(configPath string, mode string) error {
|
||||||
if mode != "" {
|
if mode != "" {
|
||||||
return consenttool.Run(configPath, mode)
|
return consenttool.Run(configPath, mode)
|
||||||
}
|
}
|
||||||
|
log.Printf("app run begin config=%s mode=%s", configPath, mode)
|
||||||
|
if err := setupRuntimeLogger(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("runtime logger ready")
|
||||||
|
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("config load failed: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
|
||||||
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
|
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
|
||||||
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
||||||
|
|
||||||
@@ -52,14 +61,25 @@ func Run(configPath string, mode string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
server.Map("translation", func(_ string, args []osc.Value) error {
|
||||||
|
log.Printf("received translation args=%+v", args)
|
||||||
|
if err := runPythonTranslationFromLastOCR(); err != nil {
|
||||||
|
log.Printf("translation failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
||||||
errCh <- server.Serve()
|
errCh <- server.Serve()
|
||||||
}()
|
}()
|
||||||
|
log.Printf("starting vrchat log watcher")
|
||||||
go watchVrchatLog()
|
go watchVrchatLog()
|
||||||
|
log.Printf("vrchat log watcher goroutine started")
|
||||||
startSelfMonitor(cfg, discordState)
|
startSelfMonitor(cfg, discordState)
|
||||||
|
log.Printf("self monitor started")
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|||||||
@@ -3,120 +3,87 @@ package app
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vkControl = 0x11
|
||||||
|
vkShift = 0x10
|
||||||
|
vkM = 0x4D
|
||||||
|
keyUp = 0x0002
|
||||||
|
swRestore = 9
|
||||||
)
|
)
|
||||||
|
|
||||||
func pressDiscordMuteHotkey() error {
|
func pressDiscordMuteHotkey() error {
|
||||||
script := `
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
$ErrorActionPreference = "Stop"
|
enumWindows := user32.NewProc("EnumWindows")
|
||||||
|
getWindowTextW := user32.NewProc("GetWindowTextW")
|
||||||
|
isWindowVisible := user32.NewProc("IsWindowVisible")
|
||||||
|
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||||
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
|
getWindowRect := user32.NewProc("GetWindowRect")
|
||||||
|
keybdEvent := user32.NewProc("keybd_event")
|
||||||
|
setCursorPos := user32.NewProc("SetCursorPos")
|
||||||
|
sleep := syscall.NewLazyDLL("kernel32.dll").NewProc("Sleep")
|
||||||
|
|
||||||
Add-Type @"
|
var target uintptr
|
||||||
using System;
|
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
|
||||||
using System.Runtime.InteropServices;
|
vis, _, _ := isWindowVisible.Call(hwnd)
|
||||||
public static class Win32 {
|
if vis == 0 {
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
return 1
|
||||||
public struct RECT {
|
}
|
||||||
public int Left;
|
buf := make([]uint16, 512)
|
||||||
public int Top;
|
n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
||||||
public int Right;
|
if n == 0 {
|
||||||
public int Bottom;
|
return 1
|
||||||
}
|
}
|
||||||
[DllImport("user32.dll")]
|
title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n])))
|
||||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
if !strings.Contains(title, "discord") {
|
||||||
[DllImport("user32.dll")]
|
return 1
|
||||||
public static extern bool SetCursorPos(int X, int Y);
|
}
|
||||||
[DllImport("user32.dll")]
|
target = hwnd
|
||||||
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
return 0
|
||||||
}
|
})
|
||||||
"@
|
r1, _, err1 := enumWindows.Call(cb, 0)
|
||||||
|
if r1 == 0 && target == 0 {
|
||||||
function Click-WindowCenter($p) {
|
return fmt.Errorf("discord window not found: %v", err1)
|
||||||
if ($null -eq $p) { return }
|
|
||||||
$hWnd = [IntPtr]$p.MainWindowHandle
|
|
||||||
if ($hWnd -eq [IntPtr]::Zero) { return }
|
|
||||||
|
|
||||||
$rect = New-Object 'Win32+RECT'
|
|
||||||
if (-not [Win32]::GetWindowRect($hWnd, [ref]$rect)) { return }
|
|
||||||
|
|
||||||
$x = [int](($rect.Left + $rect.Right) / 2)
|
|
||||||
$y = [int](($rect.Top + $rect.Bottom) / 2)
|
|
||||||
[Win32]::SetCursorPos($x, $y) | Out-Null
|
|
||||||
Start-Sleep -Milliseconds 50
|
|
||||||
[Win32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
|
||||||
[Win32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-DiscordWindows {
|
|
||||||
$windows = foreach ($p in Get-Process) {
|
|
||||||
$title = ($p.MainWindowTitle | Out-String).Trim()
|
|
||||||
if (-not $title) { continue }
|
|
||||||
if (-not $title.ToLower().Contains('discord')) { continue }
|
|
||||||
$p
|
|
||||||
}
|
|
||||||
if (-not $windows) { return @() }
|
|
||||||
return $windows
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-BestDiscordWindow {
|
|
||||||
$windows = Get-DiscordWindows
|
|
||||||
if (-not $windows -or $windows.Count -eq 0) { return $null }
|
|
||||||
|
|
||||||
$browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera')
|
|
||||||
$ranked = $windows | Sort-Object {
|
|
||||||
$title = ($_.MainWindowTitle | Out-String).Trim().ToLower()
|
|
||||||
$score = 0
|
|
||||||
foreach ($keyword in $browserKeywords) {
|
|
||||||
if ($title.Contains($keyword)) { $score += 10 }
|
|
||||||
}
|
|
||||||
if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
|
|
||||||
if ($title.Contains('discord')) { $score += 5 }
|
|
||||||
$score
|
|
||||||
} -Descending
|
|
||||||
|
|
||||||
return $ranked | Select-Object -First 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-VRChatWindow {
|
|
||||||
$windows = Get-Process | Where-Object {
|
|
||||||
$_.MainWindowTitle -and (
|
|
||||||
$_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (-not $windows) { return $null }
|
|
||||||
return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function Activate-Window($p) {
|
|
||||||
if ($null -eq $p) { return }
|
|
||||||
$wshell = New-Object -ComObject WScript.Shell
|
|
||||||
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
|
||||||
Start-Sleep -Milliseconds 200
|
|
||||||
Click-WindowCenter $p
|
|
||||||
}
|
|
||||||
|
|
||||||
$discord = Get-BestDiscordWindow
|
|
||||||
if ($null -eq $discord) { exit 2 }
|
|
||||||
|
|
||||||
Activate-Window $discord
|
|
||||||
$wshell = New-Object -ComObject WScript.Shell
|
|
||||||
Start-Sleep -Milliseconds 200
|
|
||||||
$wshell.SendKeys('^+m')
|
|
||||||
Start-Sleep -Milliseconds 200
|
|
||||||
|
|
||||||
$vrchat = Get-VRChatWindow
|
|
||||||
if ($null -ne $vrchat) {
|
|
||||||
Activate-Window $vrchat
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if len(out) > 0 {
|
|
||||||
log.Printf("discord mute output: %s", string(out))
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if target == 0 {
|
||||||
return fmt.Errorf("discord mute failed: %w", err)
|
return fmt.Errorf("discord window not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showWindow.Call(target, swRestore)
|
||||||
|
setForegroundWindow.Call(target)
|
||||||
|
sleep.Call(150)
|
||||||
|
|
||||||
|
type rect struct {
|
||||||
|
Left int32
|
||||||
|
Top int32
|
||||||
|
Right int32
|
||||||
|
Bottom int32
|
||||||
|
}
|
||||||
|
var r rect
|
||||||
|
if v, _, _ := getWindowRect.Call(target, uintptr(unsafe.Pointer(&r))); v != 0 {
|
||||||
|
x := int((r.Left + r.Right) / 2)
|
||||||
|
y := int((r.Top + r.Bottom) / 2)
|
||||||
|
setCursorPos.Call(uintptr(x), uintptr(y))
|
||||||
|
sleep.Call(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
send := func(vk byte, flags uintptr) {
|
||||||
|
keybdEvent.Call(uintptr(vk), 0, flags, 0)
|
||||||
|
}
|
||||||
|
send(vkControl, 0)
|
||||||
|
send(vkShift, 0)
|
||||||
|
send(vkM, 0)
|
||||||
|
send(vkM, keyUp)
|
||||||
|
send(vkShift, keyUp)
|
||||||
|
send(vkControl, keyUp)
|
||||||
|
sleep.Call(100)
|
||||||
|
|
||||||
|
SetDiscordWindow("Discord")
|
||||||
|
log.Printf("discord hotkey sent via keybd_event")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,33 +30,25 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
|||||||
|
|
||||||
if isPressed && !state.buttonPressed {
|
if isPressed && !state.buttonPressed {
|
||||||
state.buttonPressed = true
|
state.buttonPressed = true
|
||||||
if state.muted {
|
log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
|
||||||
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
|
if err := pressDiscordMuteHotkey(); err != nil {
|
||||||
if err := pressDiscordMuteHotkey(); err != nil {
|
log.Printf("discord hotkey failed: %v", err)
|
||||||
return err
|
SetDiscordAction("hotkey_failed")
|
||||||
}
|
|
||||||
state.muted = false
|
|
||||||
SetDiscordMuted(state.muted)
|
|
||||||
state.lastAction = now
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO already unmuted")
|
state.muted = !state.muted
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
|
if state.muted {
|
||||||
|
SetDiscordAction("muted")
|
||||||
|
} else {
|
||||||
|
SetDiscordAction("unmuted")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
state.lastAction = now
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isPressed && state.buttonPressed {
|
if !isPressed && state.buttonPressed {
|
||||||
state.buttonPressed = false
|
state.buttonPressed = false
|
||||||
if !state.muted {
|
|
||||||
log.Printf("ACTION VRChat button released -> Discord mute hotkey")
|
|
||||||
if err := pressDiscordMuteHotkey(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state.muted = true
|
|
||||||
SetDiscordMuted(state.muted)
|
|
||||||
state.lastAction = now
|
|
||||||
} else {
|
|
||||||
log.Printf("INFO already muted")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runPythonOcrFromScreen() error {
|
type OcrResult struct {
|
||||||
script := `
|
ImagePath string
|
||||||
import sys
|
TextPath string
|
||||||
from pathlib import Path
|
Text string
|
||||||
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
|
Translate string
|
||||||
sys.path.insert(0, str(root / "src"))
|
ErrReason string
|
||||||
from ocr.ocr_actions import runOcrFromScreen
|
}
|
||||||
runOcrFromScreen()
|
|
||||||
`
|
func runPythonScript(script string) (string, error) {
|
||||||
cmd := exec.Command("python", "-c", script)
|
cmd := exec.Command("python", "-c", script)
|
||||||
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if len(out) > 0 {
|
if len(out) > 0 {
|
||||||
log.Printf("ocr output: %s", string(out))
|
log.Printf("python output: %s", string(out))
|
||||||
SetOCRText(string(out))
|
|
||||||
SetTranslateText("翻訳: 未実装")
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ocr failed: %w", err)
|
return string(out), fmt.Errorf("python failed: %w", err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePythonOcr() (map[string]any, error) {
|
||||||
|
script := `
|
||||||
|
import sys, json
|
||||||
|
from pathlib import Path
|
||||||
|
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
|
||||||
|
sys.path.insert(0, str(root / "src"))
|
||||||
|
from ocr.ocr_actions import runOcrFromScreen
|
||||||
|
result = runOcrFromScreen()
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
`
|
||||||
|
out, err := runPythonScript(script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &m); err == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ocr result json not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPythonOcrFromScreen() error {
|
||||||
|
log.Printf("ocr trigger begin")
|
||||||
|
result, err := capturePythonOcr()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ocr trigger failed: %v", err)
|
||||||
|
SetOCRText("OCRテキストなし")
|
||||||
|
SetTranslateText("翻訳エンジン未設定")
|
||||||
|
_ = AppendRuntimeLog("OCR ERROR", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text, _ := result["text"].(string)
|
||||||
|
imagePath, _ := result["image_path"].(string)
|
||||||
|
textPath, _ := result["text_path"].(string)
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
text = "OCRテキストなし"
|
||||||
|
}
|
||||||
|
SetOCRText(text)
|
||||||
|
SetTranslateText("")
|
||||||
|
_ = AppendRuntimeLog("OCR INPUT", fmt.Sprintf("image=%s\n%s", imagePath, text))
|
||||||
|
if textPath != "" {
|
||||||
|
_ = AppendRuntimeLog("OCR TEXT", fmt.Sprintf("image=%s\ntext_path=%s\n%s", imagePath, textPath, text))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPythonTranslationFromLastOCR() error {
|
||||||
|
runtime := SnapshotRuntime()
|
||||||
|
source := strings.TrimSpace(runtime.LastOCRText)
|
||||||
|
if source == "" || source == "OCRテキストなし" {
|
||||||
|
SetTranslateText("OCRテキストなし")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION ERROR", "OCRテキストなし")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("translation trigger begin")
|
||||||
|
SetTranslateText("翻訳実行中")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION INPUT", source)
|
||||||
|
script := `
|
||||||
|
import sys, json
|
||||||
|
from pathlib import Path
|
||||||
|
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
|
||||||
|
sys.path.insert(0, str(root / "src"))
|
||||||
|
from translate.translate_actions import translateTextToJapanese, saveTranslationText
|
||||||
|
text = sys.argv[1]
|
||||||
|
translated = translateTextToJapanese(text)
|
||||||
|
print(translated or "")
|
||||||
|
if translated:
|
||||||
|
saveTranslationText("runtime", translated)
|
||||||
|
`
|
||||||
|
cmd := exec.Command("python", "-c", script, source)
|
||||||
|
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if len(out) > 0 {
|
||||||
|
log.Printf("translation output: %s", string(out))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
SetTranslateText("翻訳失敗")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION ERROR", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
translated := strings.TrimSpace(string(out))
|
||||||
|
if translated == "" {
|
||||||
|
SetTranslateText("翻訳結果なし")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION ERROR", "翻訳結果なし")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
SetTranslateText(translated)
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION RESULT", translated)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,45 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vrc_osc_go/internal/common"
|
"vrc_osc_go/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type runtimeLogWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
file *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *runtimeLogWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.file == nil {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.file.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRuntimeLogger() error {
|
||||||
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "runtime.log")
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logWriter := &runtimeLogWriter{file: f}
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stderr, logWriter))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func appendRuntimeLog(title, text string) error {
|
func appendRuntimeLog(title, text string) error {
|
||||||
dir := filepath.Join(common.RootDir(), "runtime")
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
@@ -50,3 +82,43 @@ func appendJoinLeaveLog(text string) error {
|
|||||||
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendDesktopJoinLog(title, worldLabel, text string) error {
|
||||||
|
dir := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
if home, herr := os.UserHomeDir(); herr == nil {
|
||||||
|
dir = filepath.Join(home, "Desktop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
dir = filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "join.txt")
|
||||||
|
body := text
|
||||||
|
if body == "" {
|
||||||
|
body = "(empty)"
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
fallbackDir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fallbackPath := filepath.Join(fallbackDir, "join.txt")
|
||||||
|
f, err = os.OpenFile(fallbackPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = fallbackPath
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendDesktopJoinLog(title, worldLabel, text string) error {
|
||||||
|
return appendDesktopJoinLog(title, worldLabel, text)
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
|
|||||||
if nextState == "joined" {
|
if nextState == "joined" {
|
||||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
|
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
|
||||||
log.Printf("self monitor discord mute failed: %v", err)
|
log.Printf("self monitor discord mute failed: %v", err)
|
||||||
|
SetDiscordSource("self_monitor_error")
|
||||||
}
|
}
|
||||||
} else if nextState == "left" {
|
} else if nextState == "left" {
|
||||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
|
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
|
||||||
log.Printf("self monitor discord mute failed: %v", err)
|
log.Printf("self monitor discord mute failed: %v", err)
|
||||||
|
SetDiscordSource("self_monitor_error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SetDiscordSource("self_monitor")
|
||||||
lastState = nextState
|
lastState = nextState
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
type RuntimeState struct {
|
type RuntimeState struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
DiscordMuted bool
|
DiscordMuted bool
|
||||||
|
DiscordWindow string
|
||||||
|
DiscordSource string
|
||||||
|
DiscordAction string
|
||||||
LastOCRText string
|
LastOCRText string
|
||||||
LastTranslate string
|
LastTranslate string
|
||||||
CurrentWorld string
|
CurrentWorld string
|
||||||
@@ -39,6 +42,27 @@ func SetDiscordMuted(v bool) {
|
|||||||
_ = persistRuntimeState()
|
_ = persistRuntimeState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetDiscordWindow(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordWindow = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDiscordSource(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordSource = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDiscordAction(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordAction = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
func SetOCRText(v string) {
|
func SetOCRText(v string) {
|
||||||
runtimeState.mu.Lock()
|
runtimeState.mu.Lock()
|
||||||
runtimeState.LastOCRText = v
|
runtimeState.LastOCRText = v
|
||||||
@@ -81,6 +105,9 @@ func persistRuntimeState() error {
|
|||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(map[string]any{
|
return enc.Encode(map[string]any{
|
||||||
"discord_muted": s.DiscordMuted,
|
"discord_muted": s.DiscordMuted,
|
||||||
|
"discord_window": s.DiscordWindow,
|
||||||
|
"discord_source": s.DiscordSource,
|
||||||
|
"discord_action": s.DiscordAction,
|
||||||
"ocr": s.LastOCRText,
|
"ocr": s.LastOCRText,
|
||||||
"translate": s.LastTranslate,
|
"translate": s.LastTranslate,
|
||||||
"world": s.CurrentWorld,
|
"world": s.CurrentWorld,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,6 +8,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -77,6 +79,12 @@ func findLatestVrchatLog() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func watchVrchatLog() {
|
func watchVrchatLog() {
|
||||||
|
log.Printf("vrchat log watcher started")
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("vrchat log watcher panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
lastPath := ""
|
lastPath := ""
|
||||||
lastSize := int64(0)
|
lastSize := int64(0)
|
||||||
state := &vrcLogState{
|
state := &vrcLogState{
|
||||||
@@ -85,20 +93,26 @@ func watchVrchatLog() {
|
|||||||
for {
|
for {
|
||||||
path, err := findLatestVrchatLog()
|
path, err := findLatestVrchatLog()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("vrchat log not found: %v", err)
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("vrchat log stat failed: %v", err)
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
|
||||||
if path != lastPath {
|
if path != lastPath {
|
||||||
log.Printf("vrchat log watching %s", path)
|
log.Printf("vrchat log watching %s", path)
|
||||||
lastPath = path
|
lastPath = path
|
||||||
lastSize = 0
|
lastSize = 0
|
||||||
|
log.Printf("vrchat log initial scan begin path=%s", path)
|
||||||
if err := scanExistingVrchatLog(path, state); err != nil {
|
if err := scanExistingVrchatLog(path, state); err != nil {
|
||||||
log.Printf("vrchat log initial scan failed: %v", err)
|
log.Printf("vrchat log initial scan failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("vrchat log initial scan ok path=%s", path)
|
||||||
}
|
}
|
||||||
if info2, err := os.Stat(path); err == nil {
|
if info2, err := os.Stat(path); err == nil {
|
||||||
lastSize = info2.Size()
|
lastSize = info2.Size()
|
||||||
@@ -113,15 +127,22 @@ func watchVrchatLog() {
|
|||||||
lastSize = 0
|
lastSize = 0
|
||||||
}
|
}
|
||||||
if info.Size() > lastSize {
|
if info.Size() > lastSize {
|
||||||
f, err := os.Open(path)
|
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("vrchat log read failed: %v", err)
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, _ = f.Seek(lastSize, 0)
|
text := decodeVRChatLog(b)
|
||||||
scanner := bufio.NewScanner(f)
|
lines := strings.Split(text, "\n")
|
||||||
for scanner.Scan() {
|
joinHits := 0
|
||||||
line := scanner.Text()
|
leaveHits := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := appendRuntimeLog("VRC LOG", line); err != nil {
|
if err := appendRuntimeLog("VRC LOG", line); err != nil {
|
||||||
log.Printf("append runtime log failed: %v", err)
|
log.Printf("append runtime log failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -137,41 +158,47 @@ func watchVrchatLog() {
|
|||||||
at := extractLineTime(line)
|
at := extractLineTime(line)
|
||||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
|
log.Printf("join detected: %s", name)
|
||||||
|
joinHits++
|
||||||
state.presentSet[name] = struct{}{}
|
state.presentSet[name] = struct{}{}
|
||||||
if tracker := GetGuestTracker(); tracker != nil {
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
tracker.MarkJoin(name, at)
|
tracker.MarkJoin(name, at)
|
||||||
}
|
}
|
||||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||||
log.Print(out)
|
log.Print(out)
|
||||||
_ = appendJoinLeaveLog(out)
|
if err := appendJoinLeaveLog(out); err != nil {
|
||||||
|
log.Printf("append join log failed: %v", err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
|
log.Printf("leave detected: %s", name)
|
||||||
|
leaveHits++
|
||||||
delete(state.presentSet, name)
|
delete(state.presentSet, name)
|
||||||
if tracker := GetGuestTracker(); tracker != nil {
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
tracker.MarkLeave(name, at)
|
tracker.MarkLeave(name, at)
|
||||||
}
|
}
|
||||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||||
log.Print(out)
|
log.Print(out)
|
||||||
_ = appendJoinLeaveLog(out)
|
if err := appendJoinLeaveLog(out); err != nil {
|
||||||
|
log.Printf("append leave log failed: %v", err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
|
||||||
lastSize = info.Size()
|
lastSize = info.Size()
|
||||||
_ = f.Close()
|
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||||
f, err := os.Open(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
state.presentSet = map[string]struct{}{}
|
state.presentSet = map[string]struct{}{}
|
||||||
state.location = ""
|
state.location = ""
|
||||||
state.worldID = ""
|
state.worldID = ""
|
||||||
@@ -180,9 +207,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
state.pendingWorldName = ""
|
state.pendingWorldName = ""
|
||||||
state.initialized = false
|
state.initialized = false
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
text := decodeVRChatLog(b)
|
||||||
for scanner.Scan() {
|
for _, raw := range strings.Split(text, "\n") {
|
||||||
line := scanner.Text()
|
line := strings.TrimRight(raw, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if changed, _ := updateWorldState(state, line); changed {
|
if changed, _ := updateWorldState(state, line); changed {
|
||||||
state.presentSet = map[string]struct{}{}
|
state.presentSet = map[string]struct{}{}
|
||||||
}
|
}
|
||||||
@@ -203,9 +233,6 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state.initialized = true
|
state.initialized = true
|
||||||
if state.worldName != "" {
|
if state.worldName != "" {
|
||||||
log.Printf("world changed: %s", state.worldName)
|
log.Printf("world changed: %s", state.worldName)
|
||||||
@@ -213,11 +240,130 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
if tracker := GetGuestTracker(); tracker != nil {
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
tracker.SetCurrentInstance(state.worldName)
|
tracker.SetCurrentInstance(state.worldName)
|
||||||
}
|
}
|
||||||
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
|
||||||
|
log.Printf("append world log failed: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeVRChatLog(b []byte) string {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if utf8.Valid(b) {
|
||||||
|
return strings.TrimPrefix(string(b), "\ufeff")
|
||||||
|
}
|
||||||
|
if len(b) >= 2 {
|
||||||
|
if b[0] == 0xff && b[1] == 0xfe {
|
||||||
|
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||||
|
for i := 2; i+1 < len(b); i += 2 {
|
||||||
|
u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
|
||||||
|
}
|
||||||
|
if b[0] == 0xfe && b[1] == 0xff {
|
||||||
|
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||||
|
for i := 2; i+1 < len(b); i += 2 {
|
||||||
|
u16 = append(u16, uint16(b[i+1])|uint16(b[i])<<8)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cp := range []uint32{932, 1252, 65001} {
|
||||||
|
if decoded := decodeWithCodePage(b, cp); decoded != "" {
|
||||||
|
return strings.TrimPrefix(fixMojibake(decoded), "\ufeff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(fixMojibake(string(b)), "\ufeff")
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeVRChatLog(b []byte) string {
|
||||||
|
return decodeVRChatLog(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixMojibake(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
candidates := []string{s}
|
||||||
|
if repaired := tryRepairShiftJISMojibake(s); repaired != "" {
|
||||||
|
candidates = append(candidates, repaired)
|
||||||
|
}
|
||||||
|
best := s
|
||||||
|
bestScore := scoreReadable(best)
|
||||||
|
for _, cand := range candidates {
|
||||||
|
if score := scoreReadable(cand); score > bestScore {
|
||||||
|
best = cand
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryRepairShiftJISMojibake(s string) string {
|
||||||
|
raw := []byte(s)
|
||||||
|
// Try the common "UTF-8 bytes interpreted as CP932" pattern repair by
|
||||||
|
// round-tripping through CP932 in the reverse direction.
|
||||||
|
if repaired := decodeWithCodePage(raw, 932); repaired != "" && repaired != s {
|
||||||
|
return repaired
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreReadable(s string) int {
|
||||||
|
if s == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '<27>':
|
||||||
|
score -= 8
|
||||||
|
case r >= 0x3040 && r <= 0x30ff:
|
||||||
|
score += 3
|
||||||
|
case r >= 0x4e00 && r <= 0x9fff:
|
||||||
|
score += 3
|
||||||
|
case r >= 0x0020 && r <= 0x007e:
|
||||||
|
score += 1
|
||||||
|
case r == '\n' || r == '\r' || r == '\t':
|
||||||
|
score += 1
|
||||||
|
default:
|
||||||
|
score -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeWithCodePage(b []byte, codePage uint32) string {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar")
|
||||||
|
n, _, _ := multiByteToWideChar.Call(
|
||||||
|
uintptr(codePage),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&b[0])),
|
||||||
|
uintptr(len(b)),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
buf := make([]uint16, n)
|
||||||
|
multiByteToWideChar.Call(
|
||||||
|
uintptr(codePage),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&b[0])),
|
||||||
|
uintptr(len(b)),
|
||||||
|
uintptr(unsafe.Pointer(&buf[0])),
|
||||||
|
uintptr(len(buf)),
|
||||||
|
)
|
||||||
|
return syscall.UTF16ToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||||
worldID := ""
|
worldID := ""
|
||||||
instanceID := ""
|
instanceID := ""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var rootDirFunc = RuntimeBaseDir
|
||||||
|
|
||||||
func RuntimeBaseDir() string {
|
func RuntimeBaseDir() string {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -13,4 +15,12 @@ func RuntimeBaseDir() string {
|
|||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
|
|
||||||
func RootDir() string { return RuntimeBaseDir() }
|
func RootDir() string { return rootDirFunc() }
|
||||||
|
|
||||||
|
func SetRootDirForTest(fn func() string) func() {
|
||||||
|
prev := rootDirFunc
|
||||||
|
rootDirFunc = fn
|
||||||
|
return func() {
|
||||||
|
rootDirFunc = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
OSC OSCConfig
|
OSC OSCConfig
|
||||||
VrcLog VrcLogConfig
|
VrcLog VrcLogConfig
|
||||||
|
GUI GUIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type OSCConfig struct {
|
type OSCConfig struct {
|
||||||
@@ -29,9 +31,15 @@ type VrcLogConfig struct {
|
|||||||
LogPatterns []string
|
LogPatterns []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GUIConfig struct {
|
||||||
|
TopMost bool
|
||||||
|
FontSize int
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||||
|
GUI: GUIConfig{FontSize: 18},
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = filepath.Join(common.RootDir(), "config", "config.toml")
|
path = filepath.Join(common.RootDir(), "config", "config.toml")
|
||||||
@@ -98,6 +106,15 @@ func Load(path string) (*Config, error) {
|
|||||||
case "file":
|
case "file":
|
||||||
cfg.VrcLog.GuestFile = val
|
cfg.VrcLog.GuestFile = val
|
||||||
}
|
}
|
||||||
|
case "gui":
|
||||||
|
switch key {
|
||||||
|
case "top_most":
|
||||||
|
cfg.GUI.TopMost = parseBool(val, cfg.GUI.TopMost)
|
||||||
|
case "font_size":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||||
|
cfg.GUI.FontSize = n
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.VrcLog.GuestFile != "" {
|
if cfg.VrcLog.GuestFile != "" {
|
||||||
@@ -111,6 +128,19 @@ func Load(path string) (*Config, error) {
|
|||||||
return cfg, scanner.Err()
|
return cfg, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SaveGUI(path string, gui GUIConfig) error {
|
||||||
|
path = resolvePath(path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := upsertGUISection(string(data), gui)
|
||||||
|
return os.WriteFile(path, []byte(content), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
func parseList(value string) []string {
|
func parseList(value string) []string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
value = strings.TrimPrefix(value, "[")
|
value = strings.TrimPrefix(value, "[")
|
||||||
@@ -129,6 +159,76 @@ func parseList(value string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBool(value string, fallback bool) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "true", "1", "on", "yes":
|
||||||
|
return true
|
||||||
|
case "false", "0", "off", "no":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return filepath.Join(common.RootDir(), "config", "config.toml")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return filepath.Join(common.RootDir(), path)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertGUISection(existing string, gui GUIConfig) string {
|
||||||
|
normalized := strings.ReplaceAll(existing, "\r\n", "\n")
|
||||||
|
lines := strings.Split(normalized, "\n")
|
||||||
|
out := make([]string, 0, len(lines)+6)
|
||||||
|
inGUI := false
|
||||||
|
replaced := false
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := raw
|
||||||
|
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||||||
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||||
|
section := strings.Trim(trimmed, "[]")
|
||||||
|
if section == "gui" {
|
||||||
|
if !replaced {
|
||||||
|
appendGUISection(&out, gui)
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
inGUI = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inGUI {
|
||||||
|
inGUI = false
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inGUI {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
appendGUISection(&out, gui)
|
||||||
|
}
|
||||||
|
content := strings.Join(out, "\n")
|
||||||
|
content = strings.TrimRight(content, "\n")
|
||||||
|
return content + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGUISection(out *[]string, gui GUIConfig) {
|
||||||
|
*out = append(*out,
|
||||||
|
"[gui]",
|
||||||
|
fmt.Sprintf("top_most = %t", gui.TopMost),
|
||||||
|
fmt.Sprintf("font_size = %d", gui.FontSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func loadLines(path string) ([]string, error) {
|
func loadLines(path string) ([]string, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,3 +32,44 @@ func TestLoadOSCValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadGUIValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 {
|
||||||
|
t.Fatalf("unexpected gui values: %+v", cfg.GUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveGUIUpdatesSection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
initial := []byte("[self]\nname = \"alice\"\n\n[gui]\ntop_most = false\nfont_size = 18\n")
|
||||||
|
if err := os.WriteFile(path, initial, 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26}); err != nil {
|
||||||
|
t.Fatalf("SaveGUI returned error: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 {
|
||||||
|
t.Fatalf("unexpected saved gui values: %+v", cfg.GUI)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, "[self]") || !strings.Contains(text, "name = \"alice\"") {
|
||||||
|
t.Fatalf("non-gui config content was lost: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user